From 5fb85a6aa3a3782bca9d1af3f18f75af8ccc1f27 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 29 May 2026 21:50:05 -0400 Subject: [PATCH 001/840] fix(tui): align wrapped inline tool rows (#28664) --- .../src/cli/cmd/tui/routes/session/index.tsx | 146 +++++++++++++----- .../inline-tool-wrap-snapshot.test.tsx.snap | 54 +++++++ .../tui/inline-tool-wrap-snapshot.test.tsx | 119 ++++++++++++++ 3 files changed, 284 insertions(+), 35 deletions(-) create mode 100644 packages/opencode/test/cli/tui/__snapshots__/inline-tool-wrap-snapshot.test.tsx.snap create mode 100644 packages/opencode/test/cli/tui/inline-tool-wrap-snapshot.test.tsx diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index b3dc2a4a7d..5c0e5e73fe 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -1524,6 +1524,8 @@ const PART_MAPPING = { reasoning: ReasoningPart, } +const INLINE_TOOL_ICON_WIDTH = 2 + function ReasoningPart(props: { last: boolean; part: ReasoningPart; message: AssistantMessage }) { const { theme } = useTheme() const ctx = use() @@ -1789,12 +1791,12 @@ function InlineTool(props: { part: ToolPart onClick?: () => void }) { - const [margin, setMargin] = createSignal(0) const { theme } = useTheme() const ctx = use() const sync = useSync() const renderer = useRenderer() const [hover, setHover] = createSignal(false) + const [errorExpanded, setErrorExpanded] = createSignal(false) const permission = createMemo(() => { const callID = sync.data.permission[ctx.sessionID]?.at(0)?.tool?.callID @@ -1802,14 +1804,6 @@ function InlineTool(props: { return callID === props.part.callID }) - const fg = createMemo(() => { - if (props.color) return props.color - if (permission()) return theme.warning - if (hover() && props.onClick) return theme.text - if (props.complete) return theme.textMuted - return theme.text - }) - const error = createMemo(() => (props.part.state.status === "error" ? props.part.state.error : undefined)) const denied = createMemo( @@ -1820,53 +1814,134 @@ function InlineTool(props: { error()?.includes("user dismissed"), ) + const failed = createMemo(() => Boolean(error() && !denied())) + const clickable = createMemo(() => Boolean(props.onClick || failed())) + const fg = createMemo(() => { + if (props.color) return props.color + if (permission()) return theme.warning + if (failed()) return theme.error + if (hover() && props.onClick) return theme.text + if (props.complete) return theme.textMuted + return theme.text + }) + + return ( + + sync.data.message[ctx.sessionID]?.some((message) => message.role === "user" && message.id === id) ?? false + } + onMouseOver={() => clickable() && setHover(true)} + onMouseOut={() => setHover(false)} + onMouseUp={() => { + if (renderer.getSelection()?.getSelectedText()) return + if (failed()) { + setErrorExpanded((value) => !value) + return + } + props.onClick?.() + }} + > + {props.children} + + ) +} + +export function InlineToolRow(props: { + icon: string + iconColor?: RGBA + color?: RGBA + errorColor?: RGBA + failed?: boolean + denied?: boolean + error?: string + errorExpanded?: boolean + complete: any + pending: string + spinner?: boolean + children: JSX.Element + separateAfter?: (id: string | undefined) => boolean + onMouseOver?: () => void + onMouseOut?: () => void + onMouseUp?: () => void +}) { + const [margin, setMargin] = createSignal(0) + return ( props.onClick && setHover(true)} - onMouseOut={() => setHover(false)} - onMouseUp={() => { - if (renderer.getSelection()?.getSelectedText()) return - props.onClick?.() - }} + onMouseOver={props.onMouseOver} + onMouseOut={props.onMouseOut} + onMouseUp={props.onMouseUp} renderBefore={function () { const el = this as BoxRenderable const parent = el.parent if (!parent) { return } - if (el.height > 1) { - setMargin(1) - return - } const children = parent.getChildren() const index = children.indexOf(el) const previous = children[index - 1] - if (!previous) { - setMargin(0) - return - } - if (previous.height > 1 || previous.id.startsWith("text-")) { - setMargin(1) - return - } + setMargin( + previous?.id.startsWith("text-") || + previous?.id.startsWith("tool-block-") || + props.separateAfter?.(previous?.id) + ? 1 + : 0, + ) }} > - + - - ~ {props.pending}} when={props.complete}> - {props.icon} {props.children} - - + + ~ {props.pending} + + } + when={props.complete} + > + + + {props.icon} + + + {props.children} + + + - - {error()} + + + {props.error} + ) @@ -1885,6 +1960,7 @@ function BlockTool(props: { const error = createMemo(() => (props.part?.state.status === "error" ? props.part.state.error : undefined)) return ( > | undefined + +afterEach(() => { + testSetup?.renderer.destroy() + testSetup = undefined +}) + +type ToolFixture = { icon: string; label: string; error?: string } + +const tools: readonly ToolFixture[] = [ + { + icon: "✱", + label: + 'Grep "OPENCODE.*DB|database|sqlite|drizzle|dev.*db|data.*dir|xdg|APPDATA" in packages/opencode/src (151 matches)', + }, + { + icon: "✱", + label: 'Glob "**/*db*" in packages/opencode (6 matches)', + }, + { + icon: "→", + label: "Read packages/opencode/src/storage/db.ts [offset=1, limit=130]", + }, + { + icon: "→", + label: "Read packages/opencode/src/index.ts [offset=1, limit=100]", + error: "No LSP server available for this file type.", + }, + { + icon: "✱", + label: + 'Grep "export const OPENCODE_DB|OPENCODE_DB|OPENCODE_DEV|Global\\.Path\\.data|data =" in packages/opencode/src (115 matches)', + }, +] as const + +function ShellOutput() { + return ( + + # List files + + $ ls + file.ts + + + ) +} + +function UserMessage() { + return ( + + + Check whether the next tool remains separated. + + + ) +} + +function Fixture(props: { errorExpanded?: boolean; before?: "shell" | "user" }) { + return ( + + + {props.before === "shell" && } + {props.before === "user" && } + + {(item) => ( + id === "message-user"} + > + {item.label} + + )} + + + + ) +} + +async function renderFrame(component: () => JSX.Element, options: { width: number; height: number }) { + testSetup = await testRender(component, options) + await testSetup.renderOnce() + await Bun.sleep(25) + await testSetup.renderOnce() + + return testSetup + .captureCharFrame() + .split("\n") + .map((line) => line.trimEnd()) + .join("\n") + .trimEnd() +} + +describe("TUI inline tool wrapping", () => { + test("snapshots consecutive grep, glob, and read rows at a narrow width", async () => { + expect(await renderFrame(() => , { width: 72, height: 12 })).toMatchSnapshot() + }) + + test("snapshots expanded tool errors under the tool text", async () => { + expect(await renderFrame(() => , { width: 72, height: 12 })).toMatchSnapshot() + }) + + test("keeps separation after a shell output block", async () => { + expect(await renderFrame(() => , { width: 72, height: 16 })).toMatchSnapshot() + }) + + test("keeps separation after a padded user message", async () => { + expect(await renderFrame(() => , { width: 72, height: 14 })).toMatchSnapshot() + }) +}) From 9583e08be44590eff3ec17e984e2d54356a08456 Mon Sep 17 00:00:00 2001 From: Dax Date: Sat, 30 May 2026 00:06:08 -0400 Subject: [PATCH 002/840] feat(core): add location-scoped config loading (#29625) --- bun.lock | 1 + packages/core/package.json | 1 + packages/core/src/agent.ts | 191 +++----- packages/core/src/catalog.ts | 227 ++++----- packages/core/src/config.ts | 202 ++++++++ packages/core/src/config/agent.ts | 25 + packages/core/src/config/attachments.ts | 15 + packages/core/src/config/compaction.ts | 16 + packages/core/src/config/experimental.ts | 18 + packages/core/src/config/formatter.ts | 12 + packages/core/src/config/lsp.ts | 18 + packages/core/src/config/mcp.ts | 36 ++ packages/core/src/config/plugin.ts | 13 + packages/core/src/config/plugin/agent.ts | 66 +++ packages/core/src/config/plugin/provider.ts | 95 ++++ packages/core/src/config/provider.ts | 62 +++ packages/core/src/config/reference.ts | 17 + packages/core/src/config/tool-output.ts | 9 + packages/core/src/config/watcher.ts | 7 + packages/core/src/event.ts | 2 +- packages/core/src/location-layer.ts | 15 +- packages/core/src/location.ts | 35 +- packages/core/src/model.ts | 2 +- packages/core/src/plugin.ts | 46 +- packages/core/src/plugin/account.ts | 2 +- packages/core/src/plugin/boot.ts | 16 +- packages/core/src/plugin/env.ts | 2 +- packages/core/src/plugin/models-dev.ts | 4 +- .../src/plugin/provider/amazon-bedrock.ts | 2 +- .../core/src/plugin/provider/anthropic.ts | 2 +- packages/core/src/plugin/provider/azure.ts | 4 +- packages/core/src/plugin/provider/cerebras.ts | 2 +- .../plugin/provider/cloudflare-workers-ai.ts | 2 +- .../src/plugin/provider/github-copilot.ts | 2 +- .../core/src/plugin/provider/google-vertex.ts | 4 +- packages/core/src/plugin/provider/kilo.ts | 2 +- .../core/src/plugin/provider/llmgateway.ts | 2 +- packages/core/src/plugin/provider/nvidia.ts | 2 +- packages/core/src/plugin/provider/openai.ts | 2 +- packages/core/src/plugin/provider/opencode.ts | 2 +- .../core/src/plugin/provider/openrouter.ts | 2 +- packages/core/src/plugin/provider/vercel.ts | 2 +- packages/core/src/plugin/provider/zenmux.ts | 2 +- packages/core/src/policy.ts | 44 ++ packages/core/src/project.ts | 3 +- packages/core/src/state.ts | 67 +++ packages/core/test/account.test.ts | 15 +- packages/core/test/agent.test.ts | 105 ++++ packages/core/test/catalog.test.ts | 93 +++- packages/core/test/config/agent.test.ts | 186 ++++++++ packages/core/test/config/config.test.ts | 451 ++++++++++++++++++ packages/core/test/config/provider.test.ts | 131 +++++ packages/core/test/event.test.ts | 6 +- packages/core/test/fixture/location.ts | 12 + packages/core/test/location.test.ts | 38 ++ packages/core/test/model.test.ts | 23 + .../plugin/provider-amazon-bedrock.test.ts | 4 +- .../test/plugin/provider-anthropic.test.ts | 8 +- .../provider-azure-cognitive-services.test.ts | 8 +- .../core/test/plugin/provider-azure.test.ts | 28 +- .../test/plugin/provider-cerebras.test.ts | 8 +- .../provider-cloudflare-workers-ai.test.ts | 24 +- .../plugin/provider-github-copilot.test.ts | 8 +- .../core/test/plugin/provider-gitlab.test.ts | 25 +- .../provider-google-vertex-anthropic.test.ts | 8 +- .../plugin/provider-google-vertex.test.ts | 20 +- packages/core/test/plugin/provider-helper.ts | 12 +- .../core/test/plugin/provider-kilo.test.ts | 12 +- .../test/plugin/provider-llmgateway.test.ts | 8 +- .../core/test/plugin/provider-nvidia.test.ts | 12 +- .../core/test/plugin/provider-openai.test.ts | 8 +- .../test/plugin/provider-opencode.test.ts | 48 +- .../test/plugin/provider-openrouter.test.ts | 12 +- .../core/test/plugin/provider-vercel.test.ts | 12 +- .../core/test/plugin/provider-zenmux.test.ts | 16 +- packages/core/test/policy.test.ts | 83 ++++ packages/core/test/project.test.ts | 2 +- packages/opencode/src/cli/cmd/debug/v2.ts | 3 +- packages/opencode/src/config/config.ts | 4 + .../instance/httpapi/groups/v2/location.ts | 5 +- packages/opencode/src/v2/session.ts | 2 +- .../test/provider/header-timeout.test.ts | 115 +++-- .../test/session/llm-native-recorded.test.ts | 2 + packages/web/astro.config.mjs | 1 + packages/web/src/content/docs/config.mdx | 23 + packages/web/src/content/docs/policies.mdx | 137 ++++++ specs/v2/catalog-config-plugin-lifecycle.md | 326 +++++++++++++ specs/v2/config.md | 394 +++++++++++++++ specs/v2/provider-policy.md | 291 +++++++++++ 89 files changed, 3507 insertions(+), 525 deletions(-) create mode 100644 packages/core/src/config.ts create mode 100644 packages/core/src/config/agent.ts create mode 100644 packages/core/src/config/attachments.ts create mode 100644 packages/core/src/config/compaction.ts create mode 100644 packages/core/src/config/experimental.ts create mode 100644 packages/core/src/config/formatter.ts create mode 100644 packages/core/src/config/lsp.ts create mode 100644 packages/core/src/config/mcp.ts create mode 100644 packages/core/src/config/plugin.ts create mode 100644 packages/core/src/config/plugin/agent.ts create mode 100644 packages/core/src/config/plugin/provider.ts create mode 100644 packages/core/src/config/provider.ts create mode 100644 packages/core/src/config/reference.ts create mode 100644 packages/core/src/config/tool-output.ts create mode 100644 packages/core/src/config/watcher.ts create mode 100644 packages/core/src/policy.ts create mode 100644 packages/core/src/state.ts create mode 100644 packages/core/test/agent.test.ts create mode 100644 packages/core/test/config/agent.test.ts create mode 100644 packages/core/test/config/config.test.ts create mode 100644 packages/core/test/config/provider.test.ts create mode 100644 packages/core/test/fixture/location.ts create mode 100644 packages/core/test/location.test.ts create mode 100644 packages/core/test/model.test.ts create mode 100644 packages/core/test/policy.test.ts create mode 100644 packages/web/src/content/docs/policies.mdx create mode 100644 specs/v2/catalog-config-plugin-lifecycle.md create mode 100644 specs/v2/config.md create mode 100644 specs/v2/provider-policy.md diff --git a/bun.lock b/bun.lock index 74cdcb72fc..d787d6f55d 100644 --- a/bun.lock +++ b/bun.lock @@ -256,6 +256,7 @@ "glob": "13.0.5", "google-auth-library": "10.5.0", "immer": "11.1.4", + "jsonc-parser": "3.3.1", "mime-types": "3.0.2", "minimatch": "10.2.5", "npm-package-arg": "13.0.2", diff --git a/packages/core/package.json b/packages/core/package.json index 20eb5074f8..c5d599f210 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -63,6 +63,7 @@ "glob": "13.0.5", "google-auth-library": "10.5.0", "immer": "11.1.4", + "jsonc-parser": "3.3.1", "mime-types": "3.0.2", "minimatch": "10.2.5", "npm-package-arg": "13.0.2", diff --git a/packages/core/src/agent.ts b/packages/core/src/agent.ts index 7f4456c59f..55d95ef77c 100644 --- a/packages/core/src/agent.ts +++ b/packages/core/src/agent.ts @@ -1,147 +1,104 @@ export * as AgentV2 from "./agent" -import { Context, Effect, HashMap, Layer, Option, Order, pipe, Schema, Array } from "effect" -import { produce, type Draft } from "immer" +import { Array, Context, Effect, Layer, Schema } from "effect" +import { castDraft, enableMapSet, type Draft } from "immer" import { ModelV2 } from "./model" import { PermissionV2 } from "./permission" -import { PluginV2 } from "./plugin" import { ProviderV2 } from "./provider" +import { PositiveInt } from "./schema" +import { State } from "./state" export const ID = Schema.String.pipe(Schema.brand("AgentV2.ID")) export type ID = typeof ID.Type -export const Mode = Schema.Literals(["subagent", "primary", "all"]).annotate({ identifier: "AgentV2.Mode" }) -export type Mode = typeof Mode.Type +export const Color = Schema.Union([ + Schema.String.check(Schema.isPattern(/^#[0-9a-fA-F]{6}$/)), + Schema.Literals(["primary", "secondary", "accent", "success", "warning", "error", "info"]), +]) -export const Info = Schema.Struct({ - name: ID, - description: Schema.optional(Schema.String), - mode: Mode, - hidden: Schema.Boolean.pipe(Schema.optional), - color: Schema.String.pipe(Schema.optional), - permission: PermissionV2.Ruleset, +export class Info extends Schema.Class("AgentV2.Info")({ + id: ID, model: ModelV2.Ref.pipe(Schema.optional), + options: ProviderV2.Options, system: Schema.String.pipe(Schema.optional), - options: ProviderV2.Options.pipe(Schema.optional), - steps: Schema.Int.pipe(Schema.optional), -}).annotate({ identifier: "AgentV2.Info" }) -export type Info = typeof Info.Type + description: Schema.String.pipe(Schema.optional), + mode: Schema.Literals(["subagent", "primary", "all"]), + hidden: Schema.Boolean, + color: Color.pipe(Schema.optional), + steps: PositiveInt.pipe(Schema.optional), + permissions: PermissionV2.Ruleset, +}) { + static empty(id: ID) { + return new Info({ + id, + options: { + headers: {}, + body: {}, + aisdk: { + provider: {}, + request: {}, + }, + }, + mode: "all", + hidden: false, + permissions: [], + }) + } +} -export class NotFoundError extends Schema.TaggedErrorClass()("AgentV2.NotFound", { - agent: ID, -}) {} +type Data = { + agents: Map +} -export class InvalidDefaultError extends Schema.TaggedErrorClass()("AgentV2.InvalidDefault", { - agent: ID, - reason: Schema.Literals(["missing", "subagent", "hidden"]), -}) {} - -export class NoDefaultError extends Schema.TaggedErrorClass()("AgentV2.NoDefault", {}) {} +export type Editor = { + list: () => readonly Info[] + get: (id: ID) => Info | undefined + update: (id: ID, fn: (agent: Draft) => void) => void + remove: (id: ID) => void +} export interface Interface { - readonly get: (agent: ID) => Effect.Effect - readonly list: () => Effect.Effect - readonly update: (agent: ID, fn: (agent: Draft) => void) => Effect.Effect - readonly remove: (agent: ID) => Effect.Effect - readonly defaultInfo: () => Effect.Effect - readonly defaultAgent: () => Effect.Effect - readonly setDefault: (agent: ID) => Effect.Effect + readonly transform: State.Interface["transform"] + readonly update: State.Interface["update"] + readonly get: (id: ID) => Effect.Effect + readonly all: () => Effect.Effect } export class Service extends Context.Service()("@opencode/v2/Agent") {} +enableMapSet() + export const layer = Layer.effect( Service, Effect.gen(function* () { - const plugin = yield* PluginV2.Service - let agents = HashMap.empty() - let defaultAgent: ID | undefined - - const result: Interface = { - get: Effect.fn("AgentV2.get")(function* (agent) { - const match = HashMap.get(agents, agent) - if (!match.valueOrUndefined) return yield* new NotFoundError({ agent }) - return match.value + const state = State.create({ + initial: () => ({ agents: new Map() }), + editor: (draft) => ({ + list: () => Array.fromIterable(draft.agents.values()) as Info[], + get: (id) => draft.agents.get(id), + update: (id, fn) => { + const current = draft.agents.get(id) ?? castDraft(Info.empty(id)) + if (!draft.agents.has(id)) draft.agents.set(id, current) + fn(current) + current.id = id + }, + remove: (id) => { + draft.agents.delete(id) + }, }), + }) - list: Effect.fn("AgentV2.list")(function* () { - return pipe( - HashMap.toValues(agents), - Array.sortWith((agent) => agent.name, Order.String), - ) + return Service.of({ + transform: state.transform, + update: state.update, + get: Effect.fn("AgentV2.get")(function* (id) { + return state.get().agents.get(id) }), - - update: Effect.fnUntraced(function* (agent, fn) { - const next = produce( - HashMap.get(agents, agent).pipe( - Option.getOrElse( - () => - ({ - name: agent, - mode: "all", - permission: [], - options: { - headers: {}, - body: {}, - aisdk: { - provider: {}, - request: {}, - }, - }, - }) satisfies Info, - ), - ), - fn, - ) - const updated = yield* plugin.trigger("agent.update", {}, { agent: next, cancel: false }) - if (updated.cancel) return - agents = HashMap.set(agents, agent, { ...updated.agent, name: agent }) + all: Effect.fn("AgentV2.all")(function* () { + return Array.fromIterable(state.get().agents.values()) }), - - remove: Effect.fn("AgentV2.remove")(function* (agent) { - const existing = Option.getOrUndefined(HashMap.get(agents, agent)) - if (!existing) return - if ((yield* plugin.trigger("agent.remove", { agent: existing }, { cancel: false })).cancel) return - agents = HashMap.remove(agents, agent) - if (defaultAgent === agent) defaultAgent = undefined - }), - - defaultInfo: Effect.fn("AgentV2.defaultInfo")(function* () { - const updated = yield* plugin.trigger("agent.default", {}, { agent: defaultAgent }) - const selected = updated.agent - if (selected) { - const agent = yield* result - .get(selected) - .pipe( - Effect.catchTag("AgentV2.NotFound", () => - Effect.fail(new InvalidDefaultError({ agent: selected, reason: "missing" })), - ), - ) - if (agent.mode === "subagent") return yield* new InvalidDefaultError({ agent: selected, reason: "subagent" }) - if (agent.hidden === true) return yield* new InvalidDefaultError({ agent: selected, reason: "hidden" }) - return agent - } - - const visible = pipe( - yield* result.list(), - Array.findFirst((agent) => agent.mode !== "subagent" && agent.hidden !== true), - ) - if (Option.isSome(visible)) return visible.value - return yield* new NoDefaultError() - }), - - defaultAgent: Effect.fn("AgentV2.defaultAgent")(function* () { - return (yield* result.defaultInfo()).name - }), - - setDefault: Effect.fn("AgentV2.setDefault")(function* (agent) { - yield* result.get(agent) - defaultAgent = agent - }), - } - - return Service.of(result) + }) }), ) -export const defaultLayer = layer.pipe(Layer.provide(PluginV2.defaultLayer)) +export const defaultLayer = layer diff --git a/packages/core/src/catalog.ts b/packages/core/src/catalog.ts index 2bbd7bc49b..a12de5d476 100644 --- a/packages/core/src/catalog.ts +++ b/packages/core/src/catalog.ts @@ -1,18 +1,22 @@ export * as Catalog from "./catalog" -import { Context, Effect, HashMap, Layer, Option, Order, pipe, Schema, Array, Scope, Stream } from "effect" -import { produce, type Draft } from "immer" +import { Context, Effect, Layer, Option, Order, pipe, Schema, Array, Scope, Stream } from "effect" +import { castDraft, enableMapSet, type Draft } from "immer" import { ModelV2 } from "./model" import { PluginV2 } from "./plugin" import { ProviderV2 } from "./provider" import { Location } from "./location" import { EventV2 } from "./event" +import { Policy } from "./policy" +import { State } from "./state" export type ProviderRecord = { provider: ProviderV2.Info models: Map } +export type DefaultModel = { providerID: ProviderV2.ID; modelID: ModelV2.ID } + export class ProviderNotFoundError extends Schema.TaggedErrorClass()( "CatalogV2.ProviderNotFound", { @@ -25,6 +29,8 @@ export class ModelNotFoundError extends Schema.TaggedErrorClass) => void) => void - updateModel: (providerID: ProviderV2.ID, modelID: ModelV2.ID, fn: (model: Draft) => void) => void +type Data = { + providers: Map + defaultModel?: DefaultModel +} + +export type Editor = { provider: { + list: () => readonly ProviderRecord[] + get: (providerID: ProviderV2.ID) => ProviderRecord | undefined update: (providerID: ProviderV2.ID, fn: (provider: Draft) => void) => void remove: (providerID: ProviderV2.ID) => void } model: { + get: (providerID: ProviderV2.ID, modelID: ModelV2.ID) => ModelV2.Info | undefined update: (providerID: ProviderV2.ID, modelID: ModelV2.ID, fn: (model: Draft) => void) => void remove: (providerID: ProviderV2.ID, modelID: ModelV2.ID) => void + default: { + get: () => DefaultModel | undefined + set: (providerID: ProviderV2.ID, modelID: ModelV2.ID) => void + } } } -export type Loader = (update: (ctx: Context) => void) => Effect.Effect - export interface Interface { - readonly loader: () => Effect.Effect + readonly transform: State.Interface["transform"] readonly provider: { readonly get: (providerID: ProviderV2.ID) => Effect.Effect readonly all: () => Effect.Effect @@ -65,29 +78,25 @@ export interface Interface { readonly all: () => Effect.Effect readonly available: () => Effect.Effect readonly default: () => Effect.Effect> - readonly setDefault: ( - providerID: ProviderV2.ID, - modelID: ModelV2.ID, - ) => Effect.Effect readonly small: (providerID: ProviderV2.ID) => Effect.Effect> } } export class Service extends Context.Service()("@opencode/v2/Catalog") {} +enableMapSet() + export const layer = Layer.effect( Service, Effect.gen(function* () { yield* Location.Service - let records = HashMap.empty() - let loaders: { update: (ctx: Context) => void }[] = [] - let defaultModel: { providerID: ProviderV2.ID; modelID: ModelV2.ID } | undefined const plugin = yield* PluginV2.Service const events = yield* EventV2.Service + const policy = yield* Policy.Service const scope = yield* Scope.Scope const resolve = (model: ModelV2.Info) => { - const provider = Option.getOrThrow(HashMap.get(records, model.providerID)).provider + const provider = state.get().providers.get(model.providerID)!.provider const endpoint = model.endpoint.type === "unknown" ? provider.endpoint @@ -120,9 +129,9 @@ export const layer = Layer.effect( } function* getRecord(providerID: ProviderV2.ID) { - const match = HashMap.get(records, providerID) - if (!match.valueOrUndefined) return yield* new ProviderNotFoundError({ providerID }) - return match.value + const match = state.get().providers.get(providerID) + if (!match) return yield* new ProviderNotFoundError({ providerID }) + return match } const normalizeEndpoint = (item: Draft | Draft) => { @@ -131,114 +140,73 @@ export const layer = Layer.effect( delete item.options.aisdk.provider.baseURL } - const clone = (input: HashMap.HashMap) => - HashMap.fromIterable( - HashMap.toEntries(input).map(([key, value]) => [key, { ...value, models: new Map(value.models) }] as const), - ) - - const context = (draft: { - records: HashMap.HashMap - data: ProviderRecord[] - }): Context => { - const result: Context = { - data: draft.data, - updateProvider: (providerID, fn) => result.provider.update(providerID, fn), - updateModel: (providerID, modelID, fn) => result.model.update(providerID, modelID, fn), - provider: { - update: (providerID, fn) => { - const current = Option.getOrUndefined(HashMap.get(draft.records, providerID)) - const provider = produce(current?.provider ?? ProviderV2.Info.empty(providerID), (draft) => { - fn(draft) - normalizeEndpoint(draft) - }) - const next = { - provider, - models: current?.models ?? new Map(), - } - draft.records = HashMap.set(draft.records, providerID, next) - const index = draft.data.findIndex((item) => item.provider.id === providerID) - if (index === -1) draft.data.push(next) - else draft.data[index] = next + const state = State.create({ + initial: () => ({ providers: new Map() }), + editor: (draft) => { + const result: Editor = { + provider: { + list: () => Array.fromIterable(draft.providers.values()) as ProviderRecord[], + get: (providerID) => draft.providers.get(providerID), + update: (providerID, fn) => { + let current = draft.providers.get(providerID) + if (!current) { + current = castDraft({ + provider: ProviderV2.Info.empty(providerID), + models: new Map(), + }) + draft.providers.set(providerID, current) + } + fn(current.provider) + normalizeEndpoint(current.provider) + }, + remove: (providerID) => { + draft.providers.delete(providerID) + }, }, - remove: (providerID) => { - draft.records = HashMap.remove(draft.records, providerID) - const index = draft.data.findIndex((item) => item.provider.id === providerID) - if (index !== -1) draft.data.splice(index, 1) + model: { + get: (providerID, modelID) => draft.providers.get(providerID)?.models.get(modelID), + update: (providerID, modelID, fn) => { + result.provider.update(providerID, () => {}) + const record = draft.providers.get(providerID)! + const model = record.models.get(modelID) ?? castDraft(ModelV2.Info.empty(providerID, modelID)) + if (!record.models.has(modelID)) record.models.set(modelID, model) + fn(model) + model.id = modelID + model.providerID = providerID + normalizeEndpoint(model) + }, + remove: (providerID, modelID) => { + draft.providers.get(providerID)?.models.delete(modelID) + }, + default: { + get: () => draft.defaultModel, + set: (providerID, modelID) => { + draft.defaultModel = { providerID, modelID } + }, + }, }, - }, - model: { - update: (providerID, modelID, fn) => { - const current = Option.getOrThrow(HashMap.get(draft.records, providerID)) - const model = produce(current.models.get(modelID) ?? ModelV2.Info.empty(providerID, modelID), (draft) => { - fn(draft) - normalizeEndpoint(draft) - }) - const next = { - provider: current.provider, - models: new Map(current.models).set(modelID, new ModelV2.Info({ ...model, id: modelID, providerID })), - } - draft.records = HashMap.set(draft.records, providerID, next) - const index = draft.data.findIndex((item) => item.provider.id === providerID) - if (index === -1) draft.data.push(next) - else draft.data[index] = next - }, - remove: (providerID, modelID) => { - const current = Option.getOrUndefined(HashMap.get(draft.records, providerID)) - if (!current) return - const next = { - provider: current.provider, - models: new Map(current.models), - } - next.models.delete(modelID) - draft.records = HashMap.set(draft.records, providerID, next) - const index = draft.data.findIndex((item) => item.provider.id === providerID) - if (index !== -1) draft.data[index] = next - }, - }, - } - return result - } - - const transform = Effect.fn("CatalogV2.transform")(function* () { - const draft = { records: clone(records), data: HashMap.toValues(records) } - yield* plugin.trigger("catalog.transform", context(draft), {}) - records = draft.records + } + return result + }, + finalize: Effect.fn("CatalogV2.finalize")(function* (catalog, reason) { + if (reason !== "plugin.added") yield* plugin.trigger("catalog.transform", catalog, {}).pipe(Effect.asVoid) + for (const record of [...catalog.provider.list()]) { + if ((yield* policy.evaluate("provider.use", record.provider.id, "allow")) === "deny") { + catalog.provider.remove(record.provider.id) + } + } + }), }) - const rebuild = Effect.fn("CatalogV2.rebuild")(function* () { - const draft = { records: HashMap.empty(), data: [] as ProviderRecord[] } - for (const loader of loaders) loader.update(context(draft)) - yield* plugin.trigger("catalog.transform", context(draft), {}) - records = draft.records - }) - - yield* plugin.added().pipe( - Stream.runForEach((id) => - Effect.gen(function* () { - const draft = { records: clone(records), data: HashMap.toValues(records) } - yield* plugin.triggerFor(id, "catalog.transform", context(draft), {}) - records = draft.records - }), + yield* events.subscribe(PluginV2.Event.Added).pipe( + Stream.runForEach((event) => + state.update((catalog) => plugin.triggerFor(event.data.id, "catalog.transform", catalog, {}), "plugin.added"), ), Effect.forkIn(scope, { startImmediately: true }), ) const result: Interface = { - loader: Effect.fn("CatalogV2.loader")(function* () { - const loader = { update: (_ctx: Context) => {} } - loaders = [...loaders, loader] - const scope = yield* Scope.Scope - yield* Scope.addFinalizer( - scope, - Effect.sync(() => { - loaders = loaders.filter((item) => item !== loader) - }).pipe(Effect.andThen(rebuild())), - ) - return Effect.fnUntraced(function* (update) { - loader.update = update - yield* rebuild() - }) - }), + transform: state.transform, provider: { get: Effect.fn("CatalogV2.provider.get")(function* (providerID) { @@ -247,11 +215,11 @@ export const layer = Layer.effect( }), all: Effect.fn("CatalogV2.provider.all")(function* () { - return globalThis.Array.from(HashMap.values(records)).map((record) => record.provider) + return Array.fromIterable(state.get().providers.values()).map((record) => record.provider) }), available: Effect.fn("CatalogV2.provider.available")(function* () { - return globalThis.Array.from(HashMap.values(records)) + return Array.fromIterable(state.get().providers.values()) .map((record) => record.provider) .filter((provider) => provider.enabled) }), @@ -267,9 +235,8 @@ export const layer = Layer.effect( all: Effect.fn("CatalogV2.model.all")(function* () { return pipe( - records, - HashMap.toValues, - Array.flatMap((record) => globalThis.Array.from(record.models.values())), + Array.fromIterable(state.get().providers.values()), + Array.flatMap((record) => Array.fromIterable(record.models.values())), Array.map(resolve), Array.sortWith((item) => item.time.released.epochMilliseconds, Order.flip(Order.Number)), ) @@ -277,12 +244,13 @@ export const layer = Layer.effect( available: Effect.fn("CatalogV2.model.available")(function* () { return (yield* result.model.all()).filter((model) => { - const record = Option.getOrUndefined(HashMap.get(records, model.providerID)) + const record = state.get().providers.get(model.providerID) return record?.provider.enabled !== false && model.enabled }) }), default: Effect.fn("CatalogV2.model.default")(function* () { + const defaultModel = state.get().defaultModel if (defaultModel) { const model = yield* result.model.get(defaultModel.providerID, defaultModel.modelID).pipe(Effect.option) if (Option.isSome(model) && model.value.enabled) return model @@ -295,13 +263,8 @@ export const layer = Layer.effect( ) }), - setDefault: Effect.fn("CatalogV2.model.setDefault")(function* (providerID, modelID) { - yield* result.model.get(providerID, modelID) - defaultModel = { providerID, modelID } - }), - small: Effect.fn("CatalogV2.model.small")(function* (providerID) { - const record = Option.getOrUndefined(HashMap.get(records, providerID)) + const record = state.get().providers.get(providerID) if (!record) return Option.none() if (providerID === ProviderV2.ID.opencode) { @@ -310,7 +273,7 @@ export const layer = Layer.effect( } const candidates = pipe( - globalThis.Array.from(record.models.values()), + Array.fromIterable(record.models.values()), Array.filter( (model) => model.providerID === providerID && diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts new file mode 100644 index 0000000000..88ff2ef91e --- /dev/null +++ b/packages/core/src/config.ts @@ -0,0 +1,202 @@ +export * as Config from "./config" + +import path from "path" +import { type ParseError, parse } from "jsonc-parser" +import { Context, Effect, Layer, Option, Schema } from "effect" +import { AppFileSystem } from "./filesystem" +import { Global } from "./global" +import { Location } from "./location" +import { PermissionV2 } from "./permission" +import { Policy } from "./policy" +import { AbsolutePath } from "./schema" +import { ConfigAgent } from "./config/agent" +import { ConfigAttachments } from "./config/attachments" +import { ConfigCompaction } from "./config/compaction" +import { ConfigExperimental } from "./config/experimental" +import { ConfigFormatter } from "./config/formatter" +import { ConfigLSP } from "./config/lsp" +import { ConfigMCP } from "./config/mcp" +import { ConfigPlugin } from "./config/plugin" +import { ConfigProvider } from "./config/provider" +import { ConfigReference } from "./config/reference" +import { ConfigToolOutput } from "./config/tool-output" +import { ConfigWatcher } from "./config/watcher" + +export class Info extends Schema.Class("Config.Info")({ + $schema: Schema.optional(Schema.String).annotate({ + description: "JSON schema reference for configuration validation", + }), + shell: Schema.String.pipe(Schema.optional).annotate({ + description: "Default shell to use for terminal and shell tool execution", + }), + model: Schema.String.pipe(Schema.optional).annotate({ + description: "Default model to use when no session or agent model is selected", + }), + autoupdate: Schema.Union([Schema.Boolean, Schema.Literal("notify")]).pipe(Schema.optional).annotate({ + description: "Automatically update or notify when a new version is available", + }), + share: Schema.Literals(["manual", "auto", "disabled"]).pipe(Schema.optional).annotate({ + description: "Control whether sessions may be shared manually, automatically, or not at all", + }), + enterprise: Schema.Struct({ + url: Schema.String.pipe(Schema.optional), + }).pipe(Schema.optional).annotate({ + description: "Enterprise sharing service configuration", + }), + username: Schema.String.pipe(Schema.optional).annotate({ + description: "Username displayed in conversations and used for telemetry identity", + }), + permissions: PermissionV2.Ruleset.pipe(Schema.optional).annotate({ + description: "Ordered tool permission rules applied to agent tool use", + }), + agents: Schema.Record(Schema.String, ConfigAgent.Info).pipe(Schema.optional).annotate({ + description: "Named built-in agent overrides and custom agent definitions", + }), + snapshots: Schema.Boolean.pipe(Schema.optional).annotate({ + description: "Enable snapshots used for undo and revert behavior", + }), + watcher: ConfigWatcher.Info.pipe(Schema.optional).annotate({ + description: "Filesystem watcher configuration", + }), + formatter: ConfigFormatter.Info.pipe(Schema.optional).annotate({ + description: "Enable built-in formatters or configure formatter overrides", + }), + lsp: ConfigLSP.Info.pipe(Schema.optional).annotate({ + description: "Enable built-in language servers or configure server overrides", + }), + attachments: ConfigAttachments.Info.pipe(Schema.optional).annotate({ + description: "Attachment processing configuration", + }), + tool_output: ConfigToolOutput.Info.pipe(Schema.optional).annotate({ + description: "Tool output truncation thresholds", + }), + mcp: ConfigMCP.Info.pipe(Schema.optional).annotate({ + description: "MCP server configuration", + }), + compaction: ConfigCompaction.Info.pipe(Schema.optional).annotate({ + description: "Conversation compaction behavior", + }), + skills: Schema.String.pipe(Schema.Array, Schema.optional).annotate({ + description: "Additional paths or URLs to discover skills from", + }), + instructions: Schema.String.pipe(Schema.Array, Schema.optional).annotate({ + description: "Additional paths or URLs supplying ambient instructions", + }), + references: ConfigReference.Info.pipe(Schema.optional).annotate({ + description: "Named local directories or Git repositories available as external context", + }), + plugins: ConfigPlugin.Plugins.pipe(Schema.optional).annotate({ + description: "Ordered external plugin packages to load", + }), + experimental: ConfigExperimental.Experimental.pipe(Schema.optional), + providers: Schema.Record(Schema.String, ConfigProvider.Info).pipe(Schema.optional), +}) {} + +export const FileSource = Schema.Struct({ + type: Schema.Literal("file"), + path: Schema.String, +}).annotate({ identifier: "Config.FileSource" }) +export type FileSource = typeof FileSource.Type + +export const MemorySource = Schema.Struct({ + type: Schema.Literal("memory"), +}).annotate({ identifier: "Config.MemorySource" }) +export type MemorySource = typeof MemorySource.Type + +export const Source = Schema.Union([FileSource, MemorySource]).pipe(Schema.toTaggedUnion("type")) +export type Source = typeof Source.Type + +export class Loaded extends Schema.Class("Config.Loaded")({ + source: Source, + info: Info, +}) {} + +export interface Interface { + /** Returns supplemental config directories from lowest to highest priority. */ + readonly directories: () => Effect.Effect + /** Loads location config files from lowest to highest priority. */ + readonly get: () => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/v2/Config") {} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const global = yield* Global.Service + const location = yield* Location.Service + const policy = yield* Policy.Service + const names = ["config.json", "opencode.json", "opencode.jsonc"] + + const loadFile = Effect.fnUntraced(function* (filepath: string) { + const text = yield* fs.readFileStringSafe(filepath) + if (!text) return + + const errors: ParseError[] = [] + const input: unknown = parse(text, errors, { allowTrailingComma: true }) + if (errors.length) return + + // Accept legacy fields while v2 is migrated incrementally; recognized + // fields still have to satisfy the v2 schema. + const info = Option.getOrUndefined( + Schema.decodeUnknownOption(Info)(input, { errors: "all", onExcessProperty: "ignore" }), + ) + if (!info) return + return new Loaded({ source: { type: "file", path: filepath }, info }) + }) + + const loadDirectory = Effect.fnUntraced(function* (directory: AbsolutePath) { + return yield* Effect.forEach(names, (file) => loadFile(path.join(directory, file))).pipe( + Effect.map((configs) => configs.filter((config): config is Loaded => config !== undefined)), + ) + }) + + const globalDirectory = AbsolutePath.make(global.config) + const locationIsGlobal = path.resolve(location.directory) === path.resolve(global.config) + // Read configuration once when this location opens. Later calls reuse these + // values until the location is reopened. + const directories = locationIsGlobal + ? [globalDirectory] + : [ + globalDirectory, + ...(yield* fs + .up({ targets: [".opencode"], start: location.directory, stop: location.project.directory }) + .pipe(Effect.orDie)) + .toReversed() + .map((directory) => AbsolutePath.make(directory)), + ] + // A config closer to the opened directory should win over one higher up. + // Search starts nearby, so reverse the results before applying them. + const directPaths = locationIsGlobal + ? [] + : (yield* fs + .up({ targets: names.toReversed(), start: location.directory, stop: location.project.directory }) + .pipe(Effect.orDie)).toReversed() + const direct = yield* Effect.forEach(directPaths, loadFile).pipe( + Effect.orDie, + Effect.map((configs) => configs.filter((config): config is Loaded => config !== undefined)), + ) + const supplementary = yield* Effect.forEach(directories, loadDirectory).pipe(Effect.orDie) + // Apply general settings first and more specific settings last: + // global config, project files, then `.opencode` files. + const configs = [...(supplementary[0] ?? []), ...direct, ...supplementary.slice(1).flat()] + // Rules use the opposite order so a user-global rule can override a + // repository rule. Statement order inside each file stays unchanged. + yield* policy.load(configs.toReversed().flatMap((config) => config.info.experimental?.policies ?? [])) + + return Service.of({ + directories: Effect.fn("Config.directories")(function* () { + return directories + }), + get: Effect.fn("Config.get")(function* () { + return configs + }), + }) + }), +) + +export const defaultLayer = layer.pipe( + Layer.provide(AppFileSystem.defaultLayer), + Layer.provide(Global.defaultLayer), +) diff --git a/packages/core/src/config/agent.ts b/packages/core/src/config/agent.ts new file mode 100644 index 0000000000..40d2bc94b5 --- /dev/null +++ b/packages/core/src/config/agent.ts @@ -0,0 +1,25 @@ +export * as ConfigAgent from "./agent" + +import { Schema } from "effect" +import { PermissionV2 } from "../permission" +import { ConfigProvider } from "./provider" +import { PositiveInt } from "../schema" + +export const Color = Schema.Union([ + Schema.String.check(Schema.isPattern(/^#[0-9a-fA-F]{6}$/)), + Schema.Literals(["primary", "secondary", "accent", "success", "warning", "error", "info"]), +]) + +export class Info extends Schema.Class("ConfigV2.Agent")({ + model: Schema.String.pipe(Schema.optional), + variant: Schema.String.pipe(Schema.optional), + options: ConfigProvider.Options.pipe(Schema.optional), + system: Schema.String.pipe(Schema.optional), + description: Schema.String.pipe(Schema.optional), + mode: Schema.Literals(["subagent", "primary", "all"]).pipe(Schema.optional), + hidden: Schema.Boolean.pipe(Schema.optional), + color: Color.pipe(Schema.optional), + steps: PositiveInt.pipe(Schema.optional), + disabled: Schema.Boolean.pipe(Schema.optional), + permissions: PermissionV2.Ruleset.pipe(Schema.optional), +}) {} diff --git a/packages/core/src/config/attachments.ts b/packages/core/src/config/attachments.ts new file mode 100644 index 0000000000..f14775ff3f --- /dev/null +++ b/packages/core/src/config/attachments.ts @@ -0,0 +1,15 @@ +export * as ConfigAttachments from "./attachments" + +import { Schema } from "effect" +import { PositiveInt } from "../schema" + +export class Image extends Schema.Class("ConfigV2.Attachments.Image")({ + auto_resize: Schema.Boolean.pipe(Schema.optional), + max_width: PositiveInt.pipe(Schema.optional), + max_height: PositiveInt.pipe(Schema.optional), + max_base64_bytes: PositiveInt.pipe(Schema.optional), +}) {} + +export class Info extends Schema.Class("ConfigV2.Attachments")({ + image: Image.pipe(Schema.optional), +}) {} diff --git a/packages/core/src/config/compaction.ts b/packages/core/src/config/compaction.ts new file mode 100644 index 0000000000..eef67ee26a --- /dev/null +++ b/packages/core/src/config/compaction.ts @@ -0,0 +1,16 @@ +export * as ConfigCompaction from "./compaction" + +import { Schema } from "effect" +import { NonNegativeInt } from "../schema" + +export class Keep extends Schema.Class("ConfigV2.Compaction.Keep")({ + turns: NonNegativeInt.pipe(Schema.optional), + tokens: NonNegativeInt.pipe(Schema.optional), +}) {} + +export class Info extends Schema.Class("ConfigV2.Compaction")({ + auto: Schema.Boolean.pipe(Schema.optional), + prune: Schema.Boolean.pipe(Schema.optional), + keep: Keep.pipe(Schema.optional), + buffer: NonNegativeInt.pipe(Schema.optional), +}) {} diff --git a/packages/core/src/config/experimental.ts b/packages/core/src/config/experimental.ts new file mode 100644 index 0000000000..12a02635db --- /dev/null +++ b/packages/core/src/config/experimental.ts @@ -0,0 +1,18 @@ +export * as ConfigExperimental from "./experimental" + +import { Schema } from "effect" +import { Catalog } from "../catalog" +import { Policy as PolicyV2 } from "../policy" + +// Each core domain exports the policy actions it supports. Adding an action to +// this union makes it valid in authored config while keeping Policy generic. +export const PolicyAction = Schema.Union([Catalog.PolicyActions]) + +export class Policy extends Schema.Class("ConfigV2.Experimental.Policy")({ + ...PolicyV2.Info.fields, + action: PolicyAction, +}) {} + +export class Experimental extends Schema.Class("ConfigV2.Experimental")({ + policies: Policy.pipe(Schema.Array, Schema.optional), +}) {} diff --git a/packages/core/src/config/formatter.ts b/packages/core/src/config/formatter.ts new file mode 100644 index 0000000000..e1f90302d1 --- /dev/null +++ b/packages/core/src/config/formatter.ts @@ -0,0 +1,12 @@ +export * as ConfigFormatter from "./formatter" + +import { Schema } from "effect" + +export class Entry extends Schema.Class("ConfigV2.Formatter.Entry")({ + disabled: Schema.Boolean.pipe(Schema.optional), + command: Schema.String.pipe(Schema.Array, Schema.optional), + environment: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional), + extensions: Schema.String.pipe(Schema.Array, Schema.optional), +}) {} + +export const Info = Schema.Union([Schema.Boolean, Schema.Record(Schema.String, Entry)]) diff --git a/packages/core/src/config/lsp.ts b/packages/core/src/config/lsp.ts new file mode 100644 index 0000000000..651597befd --- /dev/null +++ b/packages/core/src/config/lsp.ts @@ -0,0 +1,18 @@ +export * as ConfigLSP from "./lsp" + +import { Schema } from "effect" + +export const Disabled = Schema.Struct({ + disabled: Schema.Literal(true), +}) + +export class Server extends Schema.Class("ConfigV2.LSP.Server")({ + command: Schema.String.pipe(Schema.Array), + extensions: Schema.String.pipe(Schema.Array, Schema.optional), + disabled: Schema.Boolean.pipe(Schema.optional), + env: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional), + initialization: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), +}) {} + +export const Entry = Schema.Union([Disabled, Server]) +export const Info = Schema.Union([Schema.Boolean, Schema.Record(Schema.String, Entry)]) diff --git a/packages/core/src/config/mcp.ts b/packages/core/src/config/mcp.ts new file mode 100644 index 0000000000..fce853815b --- /dev/null +++ b/packages/core/src/config/mcp.ts @@ -0,0 +1,36 @@ +export * as ConfigMCP from "./mcp" + +import { Schema } from "effect" +import { PositiveInt } from "../schema" + +export class Local extends Schema.Class("ConfigV2.MCP.Local")({ + type: Schema.Literal("local"), + command: Schema.String.pipe(Schema.Array), + environment: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional), + disabled: Schema.Boolean.pipe(Schema.optional), + timeout: PositiveInt.pipe(Schema.optional), +}) {} + +export class OAuth extends Schema.Class("ConfigV2.MCP.OAuth")({ + client_id: Schema.String.pipe(Schema.optional), + client_secret: Schema.String.pipe(Schema.optional), + scope: Schema.String.pipe(Schema.optional), + callback_port: Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 })).pipe(Schema.optional), + redirect_uri: Schema.String.pipe(Schema.optional), +}) {} + +export class Remote extends Schema.Class("ConfigV2.MCP.Remote")({ + type: Schema.Literal("remote"), + url: Schema.String, + headers: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional), + oauth: Schema.Union([OAuth, Schema.Literal(false)]).pipe(Schema.optional), + disabled: Schema.Boolean.pipe(Schema.optional), + timeout: PositiveInt.pipe(Schema.optional), +}) {} + +export const Server = Schema.Union([Local, Remote]).pipe(Schema.toTaggedUnion("type")) + +export class Info extends Schema.Class("ConfigV2.MCP")({ + timeout: PositiveInt.pipe(Schema.optional), + servers: Schema.Record(Schema.String, Server).pipe(Schema.optional), +}) {} diff --git a/packages/core/src/config/plugin.ts b/packages/core/src/config/plugin.ts new file mode 100644 index 0000000000..e5fd6661ff --- /dev/null +++ b/packages/core/src/config/plugin.ts @@ -0,0 +1,13 @@ +export * as ConfigPlugin from "./plugin" + +import { Schema } from "effect" + +export class Entry extends Schema.Class("ConfigV2.Plugin.Entry")({ + package: Schema.String, + options: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), +}) {} + +export const Plugin = Schema.Union([Schema.String, Entry]) +export type Plugin = typeof Plugin.Type + +export const Plugins = Plugin.pipe(Schema.Array) diff --git a/packages/core/src/config/plugin/agent.ts b/packages/core/src/config/plugin/agent.ts new file mode 100644 index 0000000000..be2aca6297 --- /dev/null +++ b/packages/core/src/config/plugin/agent.ts @@ -0,0 +1,66 @@ +export * as ConfigAgentPlugin from "./agent" + +import { Effect } from "effect" +import { AgentV2 } from "../../agent" +import { Config } from "../../config" +import { ModelV2 } from "../../model" +import { PermissionV2 } from "../../permission" +import { PluginV2 } from "../../plugin" + +export const Plugin = PluginV2.define({ + id: PluginV2.ID.make("config-agent"), + effect: Effect.gen(function* () { + const agent = yield* AgentV2.Service + const config = yield* Config.Service + const transform = yield* agent.transform() + const files = yield* config.get() + + yield* transform((editor) => { + const permissions = new Map() + + for (const file of files) { + for (const [id, item] of Object.entries(file.info.agents ?? {})) { + const agentID = AgentV2.ID.make(id) + if (item.disabled) { + editor.remove(agentID) + permissions.delete(agentID) + continue + } + + editor.update(agentID, (agent) => { + if (item.model !== undefined) { + const model = ModelV2.parse(item.model) + agent.model = { id: model.modelID, providerID: model.providerID, variant: agent.model?.variant } + } + if (item.variant !== undefined && agent.model !== undefined) { + agent.model.variant = ModelV2.VariantID.make(item.variant) + } + if (item.options !== undefined) { + Object.assign(agent.options.headers, item.options.headers ?? {}) + Object.assign(agent.options.body, item.options.body ?? {}) + Object.assign(agent.options.aisdk.provider, item.options.aisdk?.provider ?? {}) + Object.assign(agent.options.aisdk.request, item.options.aisdk?.request ?? {}) + } + if (item.system !== undefined) agent.system = item.system + if (item.description !== undefined) agent.description = item.description + if (item.mode !== undefined) agent.mode = item.mode + if (item.hidden !== undefined) agent.hidden = item.hidden + if (item.color !== undefined) agent.color = item.color + if (item.steps !== undefined) agent.steps = item.steps + }) + + if (item.permissions !== undefined) { + permissions.set(agentID, [...(permissions.get(agentID) ?? []), ...item.permissions]) + } + } + } + + const global = files.flatMap((file) => file.info.permissions ?? []) + for (const current of editor.list()) { + editor.update(current.id, (agent) => { + agent.permissions.push(...global, ...(permissions.get(current.id) ?? [])) + }) + } + }) + }), +}) diff --git a/packages/core/src/config/plugin/provider.ts b/packages/core/src/config/plugin/provider.ts new file mode 100644 index 0000000000..fca2e53302 --- /dev/null +++ b/packages/core/src/config/plugin/provider.ts @@ -0,0 +1,95 @@ +export * as ConfigProviderPlugin from "./provider" + +import { Effect } from "effect" +import { Catalog } from "../../catalog" +import { Config } from "../../config" +import { ModelV2 } from "../../model" +import { PluginV2 } from "../../plugin" +import { ProviderV2 } from "../../provider" + +export const Plugin = PluginV2.define({ + id: PluginV2.ID.make("config-provider"), + effect: Effect.gen(function* () { + const catalog = yield* Catalog.Service + const config = yield* Config.Service + const transform = yield* catalog.transform() + const files = yield* config.get() + + yield* transform((catalog) => { + for (const file of files) { + for (const [id, item] of Object.entries(file.info.providers ?? {})) { + const providerID = ProviderV2.ID.make(id) + catalog.provider.update(providerID, (provider) => { + if (item.name !== undefined) provider.name = item.name + if (item.env !== undefined) provider.env = [...item.env] + provider.enabled = { via: "custom", data: {} } + if (item.endpoint !== undefined) provider.endpoint = { ...item.endpoint } + if (item.options !== undefined) { + Object.assign(provider.options.headers, item.options.headers ?? {}) + Object.assign(provider.options.body, item.options.body ?? {}) + Object.assign(provider.options.aisdk.provider, item.options.aisdk?.provider ?? {}) + Object.assign(provider.options.aisdk.request, item.options.aisdk?.request ?? {}) + } + }) + + for (const [id, config] of Object.entries(item.models ?? {})) { + catalog.model.update(providerID, ModelV2.ID.make(id), (model) => { + if (config.api_id !== undefined) model.apiID = config.api_id + if (config.family !== undefined) model.family = config.family + if (config.name !== undefined) model.name = config.name + if (config.endpoint !== undefined) model.endpoint = { ...config.endpoint } + if (config.capabilities !== undefined) { + model.capabilities = { + tools: config.capabilities.tools, + input: [...config.capabilities.input], + output: [...config.capabilities.output], + } + } + if (config.options !== undefined) { + Object.assign(model.options.headers, config.options.headers ?? {}) + Object.assign(model.options.body, config.options.body ?? {}) + Object.assign(model.options.aisdk.provider, config.options.aisdk?.provider ?? {}) + Object.assign(model.options.aisdk.request, config.options.aisdk?.request ?? {}) + if (config.options.variant !== undefined) model.options.variant = config.options.variant + } + if (config.variants !== undefined) { + for (const variant of config.variants) { + let existing = model.variants.find((item) => item.id === variant.id) + if (!existing) { + existing = { + id: variant.id, + headers: {}, + body: {}, + aisdk: { + provider: {}, + request: {}, + }, + } + model.variants.push(existing) + } + Object.assign(existing.headers, variant.headers ?? {}) + Object.assign(existing.body, variant.body ?? {}) + Object.assign(existing.aisdk.provider, variant.aisdk?.provider ?? {}) + Object.assign(existing.aisdk.request, variant.aisdk?.request ?? {}) + } + } + if (config.cost !== undefined) { + model.cost = (Array.isArray(config.cost) ? config.cost : [config.cost]).map((cost) => ({ + tier: cost.tier && { ...cost.tier }, + input: cost.input, + output: cost.output, + cache: { + read: cost.cache?.read ?? 0, + write: cost.cache?.write ?? 0, + }, + })) + } + if (config.disabled !== undefined) model.enabled = !config.disabled + if (config.limit !== undefined) model.limit = { ...model.limit, ...config.limit } + }) + } + } + } + }) + }), +}) diff --git a/packages/core/src/config/provider.ts b/packages/core/src/config/provider.ts new file mode 100644 index 0000000000..fbb0e1c3ef --- /dev/null +++ b/packages/core/src/config/provider.ts @@ -0,0 +1,62 @@ +export * as ConfigProvider from "./provider" + +import { Schema } from "effect" +import { ProviderV2 } from "../provider" +import { ModelV2 } from "../model" + +export class Options extends Schema.Class("ConfigV2.Provider.Options")({ + headers: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional), + body: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), + aisdk: Schema.Struct({ + provider: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), + request: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), + }).pipe(Schema.optional), +}) {} + +class Cache extends Schema.Class("ConfigV2.Model.Cost.Cache")({ + read: Schema.Finite.pipe(Schema.optional), + write: Schema.Finite.pipe(Schema.optional), +}) {} + +class Cost extends Schema.Class("ConfigV2.Model.Cost")({ + tier: Schema.Struct({ + type: Schema.Literal("context"), + size: Schema.Int, + }).pipe(Schema.optional), + input: Schema.Finite, + output: Schema.Finite, + cache: Cache.pipe(Schema.optional), +}) {} + +class Limit extends Schema.Class("ConfigV2.Model.Limit")({ + context: Schema.Int.pipe(Schema.optional), + input: Schema.Int.pipe(Schema.optional), + output: Schema.Int.pipe(Schema.optional), +}) {} + +class Model extends Schema.Class("ConfigV2.Model")({ + api_id: ModelV2.ID.pipe(Schema.optional), + family: ModelV2.Family.pipe(Schema.optional), + name: Schema.String.pipe(Schema.optional), + endpoint: ProviderV2.Endpoint.pipe(Schema.optional), + capabilities: ModelV2.Capabilities.pipe(Schema.optional), + options: Schema.Struct({ + ...Options.fields, + variant: Schema.String.pipe(Schema.optional), + }).pipe(Schema.optional), + variants: Schema.Struct({ + id: ModelV2.VariantID, + ...Options.fields, + }).pipe(Schema.Array, Schema.optional), + cost: Schema.Union([Cost, Cost.pipe(Schema.Array)]).pipe(Schema.optional), + disabled: Schema.Boolean.pipe(Schema.optional), + limit: Limit.pipe(Schema.optional), +}) {} + +export class Info extends Schema.Class("ConfigV2.Provider")({ + name: Schema.String.pipe(Schema.optional), + env: Schema.String.pipe(Schema.Array, Schema.optional), + endpoint: ProviderV2.Endpoint.pipe(Schema.optional), + options: Options.pipe(Schema.optional), + models: Schema.Record(Schema.String, Model).pipe(Schema.optional), +}) {} diff --git a/packages/core/src/config/reference.ts b/packages/core/src/config/reference.ts new file mode 100644 index 0000000000..dc9042e6f7 --- /dev/null +++ b/packages/core/src/config/reference.ts @@ -0,0 +1,17 @@ +export * as ConfigReference from "./reference" + +import { Schema } from "effect" + +export class Git extends Schema.Class("ConfigV2.Reference.Git")({ + repository: Schema.String, + branch: Schema.String.pipe(Schema.optional), +}) {} + +export class Local extends Schema.Class("ConfigV2.Reference.Local")({ + path: Schema.String, +}) {} + +export const Entry = Schema.Union([Schema.String, Git, Local]) +export type Entry = typeof Entry.Type + +export const Info = Schema.Record(Schema.String, Entry) diff --git a/packages/core/src/config/tool-output.ts b/packages/core/src/config/tool-output.ts new file mode 100644 index 0000000000..53e4d4d088 --- /dev/null +++ b/packages/core/src/config/tool-output.ts @@ -0,0 +1,9 @@ +export * as ConfigToolOutput from "./tool-output" + +import { Schema } from "effect" +import { PositiveInt } from "../schema" + +export class Info extends Schema.Class("ConfigV2.ToolOutput")({ + max_lines: PositiveInt.pipe(Schema.optional), + max_bytes: PositiveInt.pipe(Schema.optional), +}) {} diff --git a/packages/core/src/config/watcher.ts b/packages/core/src/config/watcher.ts new file mode 100644 index 0000000000..2df6c876bf --- /dev/null +++ b/packages/core/src/config/watcher.ts @@ -0,0 +1,7 @@ +export * as ConfigWatcher from "./watcher" + +import { Schema } from "effect" + +export class Info extends Schema.Class("ConfigV2.Watcher")({ + ignore: Schema.String.pipe(Schema.Array, Schema.optional), +}) {} diff --git a/packages/core/src/event.ts b/packages/core/src/event.ts index a4a5dd8595..339fbddecf 100644 --- a/packages/core/src/event.ts +++ b/packages/core/src/event.ts @@ -128,7 +128,7 @@ export const layer = Layer.effect( ...(options?.metadata ? { metadata: options.metadata } : {}), type: definition.type, ...(definition.version === undefined ? {} : { version: definition.version }), - ...(location ? { location } : {}), + ...(location ? { location: { directory: location.directory, workspaceID: location.workspaceID } } : {}), data, } as Payload return yield* publishEvent(event) diff --git a/packages/core/src/location-layer.ts b/packages/core/src/location-layer.ts index c40a940430..67293f7c5f 100644 --- a/packages/core/src/location-layer.ts +++ b/packages/core/src/location-layer.ts @@ -2,12 +2,17 @@ import { Layer, LayerMap } from "effect" import { Location } from "./location" import { Catalog } from "./catalog" import { PluginBoot } from "./plugin/boot" +import { Policy } from "./policy" +import { Config } from "./config" export class LocationServiceMap extends LayerMap.Service()("@opencode/example/LocationServiceMap", { - lookup: (ref: Location.Ref) => - Layer.mergeAll(Catalog.defaultLayer, PluginBoot.defaultLayer).pipe( - Layer.provide([Layer.succeed(Location.Service, Location.Service.of(ref))]), - ), - idleTimeToLive: "5 minutes", + lookup: (ref: Location.Ref) => { + const result = Layer.mergeAll(Catalog.defaultLayer, PluginBoot.defaultLayer, Config.defaultLayer).pipe( + Layer.provideMerge(Policy.defaultLayer), + Layer.provideMerge(Location.defaultLayer(ref)), + ) + return result + }, + idleTimeToLive: "60 minutes", dependencies: [], }) {} diff --git a/packages/core/src/location.ts b/packages/core/src/location.ts index 00ff9cd3ea..68c9a8f791 100644 --- a/packages/core/src/location.ts +++ b/packages/core/src/location.ts @@ -1,11 +1,40 @@ -import { Context, Schema } from "effect" +import { Context, Effect, Layer, Schema } from "effect" +import { Project } from "./project" +import { AbsolutePath } from "./schema" export * as Location from "./location" export const Ref = Schema.Struct({ - directory: Schema.String, + directory: AbsolutePath, workspaceID: Schema.optional(Schema.String), }).annotate({ identifier: "Location.Ref" }) export type Ref = typeof Ref.Type -export class Service extends Context.Service()("@opencode/Location") {} +export interface Interface { + readonly directory: AbsolutePath + readonly workspaceID?: string + readonly project: { + readonly id: Project.ID + readonly directory: AbsolutePath + } + readonly vcs?: Project.Vcs +} + +export class Service extends Context.Service()("@opencode/Location") {} + +export const layer = (ref: Ref) => + Layer.effect( + Service, + Effect.gen(function* () { + const project = yield* Project.Service + const resolved = yield* project.resolve(ref.directory) + return Service.of({ + directory: ref.directory, + workspaceID: ref.workspaceID, + project: { id: resolved.id, directory: resolved.directory }, + vcs: resolved.vcs, + }) + }), + ) + +export const defaultLayer = (ref: Ref) => layer(ref).pipe(Layer.provide(Project.defaultLayer)) diff --git a/packages/core/src/model.ts b/packages/core/src/model.ts index 77b8c60ebe..8cf02ddfe0 100644 --- a/packages/core/src/model.ts +++ b/packages/core/src/model.ts @@ -36,7 +36,7 @@ export const Cost = Schema.Struct({ export const Ref = Schema.Struct({ id: ID, providerID: ProviderV2.ID, - variant: VariantID, + variant: VariantID.pipe(Schema.optional), }) export type Ref = typeof Ref.Type diff --git a/packages/core/src/plugin.ts b/packages/core/src/plugin.ts index ab2d4cbf7d..df8a40c1fa 100644 --- a/packages/core/src/plugin.ts +++ b/packages/core/src/plugin.ts @@ -2,17 +2,26 @@ export * as PluginV2 from "./plugin" import { createDraft, finishDraft, type Draft } from "immer" import type { LanguageModelV3 } from "@ai-sdk/provider" -import { Context, Effect, Exit, Layer, PubSub, Schema, Scope, Stream } from "effect" +import { Context, Effect, Exit, Layer, Schema, Scope } from "effect" import type { ModelV2 } from "./model" -import type { AgentV2 } from "./agent" import type { Catalog } from "./catalog" +import { EventV2 } from "./event" export const ID = Schema.String.pipe(Schema.brand("Plugin.ID")) export type ID = typeof ID.Type +export const Event = { + Added: EventV2.define({ + type: "plugin.added", + schema: { + id: ID, + }, + }), +} + type HookSpec = { "catalog.transform": { - input: Catalog.Context + input: Catalog.Editor output: {} } "account.switched": { @@ -43,27 +52,6 @@ type HookSpec = { sdk?: any } } - "agent.update": { - input: {} - output: { - agent: AgentV2.Info - cancel: boolean - } - } - "agent.remove": { - input: { - agent: AgentV2.Info - } - output: { - cancel: boolean - } - } - "agent.default": { - input: {} - output: { - agent?: AgentV2.ID - } - } } export type Hooks = { @@ -93,7 +81,6 @@ export interface Interface { effect: Effect.Effect }) => Effect.Effect readonly remove: (id: ID) => Effect.Effect - readonly added: () => Stream.Stream readonly triggerFor: ( id: ID, name: Name, @@ -117,9 +104,7 @@ export const layer = Layer.effect( hooks: HookFunctions scope: Scope.Closeable }[] = [] - const added = yield* PubSub.unbounded() - - yield* Effect.addFinalizer(() => PubSub.shutdown(added)) + const events = yield* EventV2.Service const svc = Service.of({ add: Effect.fn("Plugin.add")(function* (input) { @@ -135,9 +120,8 @@ export const layer = Layer.effect( scope, }, ] - yield* PubSub.publish(added, input.id) + yield* events.publish(Event.Added, { id: input.id }) }), - added: () => Stream.fromPubSub(added), trigger: Effect.fn("Plugin.trigger")(function* (name, input, output) { return yield* svc.triggerFor(ID.make("*"), name, input, output) }), @@ -185,7 +169,7 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer +export const defaultLayer = layer.pipe(Layer.provide(EventV2.defaultLayer)) // opencode // sdcok diff --git a/packages/core/src/plugin/account.ts b/packages/core/src/plugin/account.ts index d4d00c3ab6..4e77701f31 100644 --- a/packages/core/src/plugin/account.ts +++ b/packages/core/src/plugin/account.ts @@ -19,7 +19,7 @@ export const AccountPlugin = PluginV2.define({ return { "catalog.transform": Effect.fn(function* (evt) { - for (const item of evt.data) { + for (const item of evt.provider.list()) { const account = yield* accounts.active(AccountV2.ServiceID.make(item.provider.id)).pipe(Effect.orDie) if (!account) continue evt.provider.update(item.provider.id, (provider) => { diff --git a/packages/core/src/plugin/boot.ts b/packages/core/src/plugin/boot.ts index 5624369e04..39faa62f8d 100644 --- a/packages/core/src/plugin/boot.ts +++ b/packages/core/src/plugin/boot.ts @@ -4,10 +4,13 @@ import { Context, Deferred, Effect, Layer } from "effect" import { AccountV2 } from "../account" import { AgentV2 } from "../agent" import { Catalog } from "../catalog" +import { Config } from "../config" +import { ConfigAgentPlugin } from "../config/plugin/agent" import { EventV2 } from "../event" import { Npm } from "../npm" import { PluginV2 } from "../plugin" import { AccountPlugin } from "./account" +import { ConfigProviderPlugin } from "../config/plugin/provider" import { EnvPlugin } from "./env" import { ModelsDevPlugin } from "./models-dev" import { ProviderPlugins } from "./provider" @@ -15,7 +18,7 @@ import { ProviderPlugins } from "./provider" type Plugin = { id: PluginV2.ID effect: PluginV2.Effect< - Catalog.Service | AgentV2.Service | AccountV2.Service | Npm.Service | EventV2.Service | PluginV2.Service + Catalog.Service | AccountV2.Service | AgentV2.Service | Npm.Service | EventV2.Service | PluginV2.Service | Config.Service > } @@ -28,10 +31,11 @@ export class Service extends Context.Service()("@opencode/v2 export const layer = Layer.effect( Service, Effect.gen(function* () { - const agent = yield* AgentV2.Service const catalog = yield* Catalog.Service const plugin = yield* PluginV2.Service const accounts = yield* AccountV2.Service + const agents = yield* AgentV2.Service + const config = yield* Config.Service const npm = yield* Npm.Service const events = yield* EventV2.Service const done = yield* Deferred.make() @@ -41,8 +45,9 @@ export const layer = Layer.effect( id: input.id, effect: input.effect.pipe( Effect.provideService(Catalog.Service, catalog), - Effect.provideService(AgentV2.Service, agent), Effect.provideService(AccountV2.Service, accounts), + Effect.provideService(AgentV2.Service, agents), + Effect.provideService(Config.Service, config), Effect.provideService(Npm.Service, npm), Effect.provideService(EventV2.Service, events), Effect.provideService(PluginV2.Service, plugin), @@ -57,6 +62,8 @@ export const layer = Layer.effect( yield* add(item) } yield* add(ModelsDevPlugin) + yield* add(ConfigProviderPlugin.Plugin) + yield* add(ConfigAgentPlugin.Plugin) }).pipe(Effect.withSpan("PluginBoot.boot")) yield* boot.pipe( @@ -72,10 +79,11 @@ export const layer = Layer.effect( ) export const defaultLayer = layer.pipe( - Layer.provide(AgentV2.defaultLayer), Layer.provide(Catalog.defaultLayer), Layer.provide(EventV2.defaultLayer), Layer.provide(PluginV2.defaultLayer), Layer.provide(AccountV2.defaultLayer), + Layer.provide(AgentV2.defaultLayer), + Layer.provide(Config.defaultLayer), Layer.provide(Npm.defaultLayer), ) diff --git a/packages/core/src/plugin/env.ts b/packages/core/src/plugin/env.ts index 3d716fe6f3..35e6981a40 100644 --- a/packages/core/src/plugin/env.ts +++ b/packages/core/src/plugin/env.ts @@ -6,7 +6,7 @@ export const EnvPlugin = PluginV2.define({ effect: Effect.gen(function* () { return { "catalog.transform": Effect.fn(function* (evt) { - for (const item of evt.data) { + for (const item of evt.provider.list()) { const key = item.provider.env.find((env) => process.env[env]) if (!key) continue evt.provider.update(item.provider.id, (provider) => { diff --git a/packages/core/src/plugin/models-dev.ts b/packages/core/src/plugin/models-dev.ts index bde162d729..4733833fdb 100644 --- a/packages/core/src/plugin/models-dev.ts +++ b/packages/core/src/plugin/models-dev.ts @@ -57,10 +57,10 @@ export const ModelsDevPlugin = PluginV2.define({ const modelsDev = yield* ModelsDev.Service const events = yield* EventV2.Service const scope = yield* Scope.Scope - const load = yield* catalog.loader() + const transform = yield* catalog.transform() const refresh = Effect.fn("ModelsDevPlugin.refresh")(function* () { const data = yield* modelsDev.get() - yield* load((catalog) => { + yield* transform((catalog) => { for (const item of Object.values(data)) { const providerID = ProviderV2.ID.make(item.id) catalog.provider.update(providerID, (provider) => { diff --git a/packages/core/src/plugin/provider/amazon-bedrock.ts b/packages/core/src/plugin/provider/amazon-bedrock.ts index 44a5d44e2b..e7452ac2e9 100644 --- a/packages/core/src/plugin/provider/amazon-bedrock.ts +++ b/packages/core/src/plugin/provider/amazon-bedrock.ts @@ -51,7 +51,7 @@ export const AmazonBedrockPlugin = PluginV2.define({ effect: Effect.gen(function* () { return { "catalog.transform": Effect.fn(function* (evt) { - for (const item of evt.data) { + for (const item of evt.provider.list()) { if (item.provider.endpoint.type !== "aisdk") continue if (item.provider.endpoint.package !== "@ai-sdk/amazon-bedrock") continue evt.provider.update(item.provider.id, (provider) => { diff --git a/packages/core/src/plugin/provider/anthropic.ts b/packages/core/src/plugin/provider/anthropic.ts index 99fbf56ab3..026da36349 100644 --- a/packages/core/src/plugin/provider/anthropic.ts +++ b/packages/core/src/plugin/provider/anthropic.ts @@ -6,7 +6,7 @@ export const AnthropicPlugin = PluginV2.define({ effect: Effect.gen(function* () { return { "catalog.transform": Effect.fn(function* (evt) { - for (const item of evt.data) { + for (const item of evt.provider.list()) { if (item.provider.endpoint.type !== "aisdk") continue if (item.provider.endpoint.package !== "@ai-sdk/anthropic") continue evt.provider.update(item.provider.id, (provider) => { diff --git a/packages/core/src/plugin/provider/azure.ts b/packages/core/src/plugin/provider/azure.ts index ea5c34d7c0..bea98cf211 100644 --- a/packages/core/src/plugin/provider/azure.ts +++ b/packages/core/src/plugin/provider/azure.ts @@ -15,7 +15,7 @@ export const AzurePlugin = PluginV2.define({ effect: Effect.gen(function* () { return { "catalog.transform": Effect.fn(function* (evt) { - for (const item of evt.data) { + for (const item of evt.provider.list()) { if (item.provider.endpoint.type !== "aisdk") continue if (item.provider.endpoint.package !== "@ai-sdk/azure") continue const configured = item.provider.options.aisdk.provider.resourceName @@ -58,7 +58,7 @@ export const AzureCognitiveServicesPlugin = PluginV2.define({ "catalog.transform": Effect.fn(function* (evt) { const resourceName = process.env.AZURE_COGNITIVE_SERVICES_RESOURCE_NAME if (!resourceName) return - for (const item of evt.data) { + for (const item of evt.provider.list()) { if (item.provider.endpoint.type !== "aisdk") continue if (item.provider.endpoint.package !== "@ai-sdk/openai-compatible") continue if (!item.provider.id.includes("azure-cognitive-services")) continue diff --git a/packages/core/src/plugin/provider/cerebras.ts b/packages/core/src/plugin/provider/cerebras.ts index 12da385920..b18884cb6e 100644 --- a/packages/core/src/plugin/provider/cerebras.ts +++ b/packages/core/src/plugin/provider/cerebras.ts @@ -6,7 +6,7 @@ export const CerebrasPlugin = PluginV2.define({ effect: Effect.gen(function* () { return { "catalog.transform": Effect.fn(function* (ctx) { - for (const item of ctx.data) { + for (const item of ctx.provider.list()) { if (item.provider.endpoint.type !== "aisdk") continue if (item.provider.endpoint.package !== "@ai-sdk/cerebras") continue ctx.provider.update(item.provider.id, (provider) => { diff --git a/packages/core/src/plugin/provider/cloudflare-workers-ai.ts b/packages/core/src/plugin/provider/cloudflare-workers-ai.ts index d10f8e79e4..32cfb059f4 100644 --- a/packages/core/src/plugin/provider/cloudflare-workers-ai.ts +++ b/packages/core/src/plugin/provider/cloudflare-workers-ai.ts @@ -11,7 +11,7 @@ export const CloudflareWorkersAIPlugin = PluginV2.define({ effect: Effect.gen(function* () { return { "catalog.transform": Effect.fn(function* (evt) { - const item = evt.data.find((record) => record.provider.id === providerID) + const item = evt.provider.get(providerID) if (!item) return evt.provider.update(item.provider.id, (provider) => { if (provider.endpoint.type !== "aisdk") return diff --git a/packages/core/src/plugin/provider/github-copilot.ts b/packages/core/src/plugin/provider/github-copilot.ts index dc984cdef9..20b1ad6d6c 100644 --- a/packages/core/src/plugin/provider/github-copilot.ts +++ b/packages/core/src/plugin/provider/github-copilot.ts @@ -31,7 +31,7 @@ export const GithubCopilotPlugin = PluginV2.define({ : evt.sdk.chat(evt.model.apiID) }), "catalog.transform": Effect.fn(function* (evt) { - const item = evt.data.find((record) => record.provider.id === ProviderV2.ID.githubCopilot) + const item = evt.provider.get(ProviderV2.ID.githubCopilot) if (!item || !item.models.has(ModelV2.ID.make("gpt-5-chat-latest"))) return evt.model.update(item.provider.id, ModelV2.ID.make("gpt-5-chat-latest"), (model) => { // This chat-only alias conflicts with the Copilot GPT-5 Responses route, diff --git a/packages/core/src/plugin/provider/google-vertex.ts b/packages/core/src/plugin/provider/google-vertex.ts index 9baaaef5d0..ae1692b933 100644 --- a/packages/core/src/plugin/provider/google-vertex.ts +++ b/packages/core/src/plugin/provider/google-vertex.ts @@ -59,7 +59,7 @@ export const GoogleVertexPlugin = PluginV2.define({ effect: Effect.gen(function* () { return { "catalog.transform": Effect.fn(function* (evt) { - for (const item of evt.data) { + for (const item of evt.provider.list()) { if (item.provider.endpoint.type !== "aisdk") continue if ( item.provider.endpoint.package !== "@ai-sdk/google-vertex" && @@ -110,7 +110,7 @@ export const GoogleVertexAnthropicPlugin = PluginV2.define({ effect: Effect.gen(function* () { return { "catalog.transform": Effect.fn(function* (evt) { - for (const item of evt.data) { + for (const item of evt.provider.list()) { if (item.provider.endpoint.type !== "aisdk") continue if (item.provider.endpoint.package !== "@ai-sdk/google-vertex/anthropic") continue const project = diff --git a/packages/core/src/plugin/provider/kilo.ts b/packages/core/src/plugin/provider/kilo.ts index 17436d9f96..098e5576c4 100644 --- a/packages/core/src/plugin/provider/kilo.ts +++ b/packages/core/src/plugin/provider/kilo.ts @@ -6,7 +6,7 @@ export const KiloPlugin = PluginV2.define({ effect: Effect.gen(function* () { return { "catalog.transform": Effect.fn(function* (evt) { - for (const item of evt.data) { + for (const item of evt.provider.list()) { if (item.provider.endpoint.type !== "aisdk") continue if (item.provider.endpoint.package !== "@ai-sdk/openai-compatible") continue if (item.provider.endpoint.url !== "https://api.kilo.ai/api/gateway") continue diff --git a/packages/core/src/plugin/provider/llmgateway.ts b/packages/core/src/plugin/provider/llmgateway.ts index de1872935c..8a971f0a0d 100644 --- a/packages/core/src/plugin/provider/llmgateway.ts +++ b/packages/core/src/plugin/provider/llmgateway.ts @@ -6,7 +6,7 @@ export const LLMGatewayPlugin = PluginV2.define({ effect: Effect.gen(function* () { return { "catalog.transform": Effect.fn(function* (evt) { - for (const item of evt.data) { + for (const item of evt.provider.list()) { if (item.provider.enabled === false) continue if (item.provider.endpoint.type !== "aisdk") continue if (item.provider.endpoint.package !== "@ai-sdk/openai-compatible") continue diff --git a/packages/core/src/plugin/provider/nvidia.ts b/packages/core/src/plugin/provider/nvidia.ts index 0c1301c842..f9c2a0420b 100644 --- a/packages/core/src/plugin/provider/nvidia.ts +++ b/packages/core/src/plugin/provider/nvidia.ts @@ -6,7 +6,7 @@ export const NvidiaPlugin = PluginV2.define({ effect: Effect.gen(function* () { return { "catalog.transform": Effect.fn(function* (evt) { - for (const item of evt.data) { + for (const item of evt.provider.list()) { if (item.provider.endpoint.type !== "aisdk") continue if (item.provider.endpoint.package !== "@ai-sdk/openai-compatible") continue if (item.provider.endpoint.url !== "https://integrate.api.nvidia.com/v1") continue diff --git a/packages/core/src/plugin/provider/openai.ts b/packages/core/src/plugin/provider/openai.ts index d76d7417f1..2d33fbcbbe 100644 --- a/packages/core/src/plugin/provider/openai.ts +++ b/packages/core/src/plugin/provider/openai.ts @@ -17,7 +17,7 @@ export const OpenAIPlugin = PluginV2.define({ evt.language = evt.sdk.responses(evt.model.apiID) }), "catalog.transform": Effect.fn(function* (evt) { - for (const item of evt.data) { + for (const item of evt.provider.list()) { if (item.provider.endpoint.type !== "aisdk") continue if (item.provider.endpoint.package !== "@ai-sdk/openai") continue if (!item.models.has(ModelV2.ID.make("gpt-5-chat-latest"))) continue diff --git a/packages/core/src/plugin/provider/opencode.ts b/packages/core/src/plugin/provider/opencode.ts index 411cbe091e..64d20f8bd4 100644 --- a/packages/core/src/plugin/provider/opencode.ts +++ b/packages/core/src/plugin/provider/opencode.ts @@ -8,7 +8,7 @@ export const OpencodePlugin = PluginV2.define({ let hasKey = false return { "catalog.transform": Effect.fn(function* (evt) { - const item = evt.data.find((record) => record.provider.id === ProviderV2.ID.opencode) + const item = evt.provider.get(ProviderV2.ID.opencode) if (!item) return hasKey = Boolean( process.env.OPENCODE_API_KEY || diff --git a/packages/core/src/plugin/provider/openrouter.ts b/packages/core/src/plugin/provider/openrouter.ts index 317f48158a..dd3e16070b 100644 --- a/packages/core/src/plugin/provider/openrouter.ts +++ b/packages/core/src/plugin/provider/openrouter.ts @@ -7,7 +7,7 @@ export const OpenRouterPlugin = PluginV2.define({ effect: Effect.gen(function* () { return { "catalog.transform": Effect.fn(function* (evt) { - for (const item of evt.data) { + for (const item of evt.provider.list()) { if (item.provider.endpoint.type !== "aisdk") continue if (item.provider.endpoint.package !== "@openrouter/ai-sdk-provider") continue evt.provider.update(item.provider.id, (provider) => { diff --git a/packages/core/src/plugin/provider/vercel.ts b/packages/core/src/plugin/provider/vercel.ts index fa368d10d7..1da00989b3 100644 --- a/packages/core/src/plugin/provider/vercel.ts +++ b/packages/core/src/plugin/provider/vercel.ts @@ -6,7 +6,7 @@ export const VercelPlugin = PluginV2.define({ effect: Effect.gen(function* () { return { "catalog.transform": Effect.fn(function* (evt) { - for (const item of evt.data) { + for (const item of evt.provider.list()) { if (item.provider.endpoint.type !== "aisdk") continue if (item.provider.endpoint.package !== "@ai-sdk/vercel") continue evt.provider.update(item.provider.id, (provider) => { diff --git a/packages/core/src/plugin/provider/zenmux.ts b/packages/core/src/plugin/provider/zenmux.ts index 3b0fcff165..01c3bd8adb 100644 --- a/packages/core/src/plugin/provider/zenmux.ts +++ b/packages/core/src/plugin/provider/zenmux.ts @@ -6,7 +6,7 @@ export const ZenmuxPlugin = PluginV2.define({ effect: Effect.gen(function* () { return { "catalog.transform": Effect.fn(function* (evt) { - for (const item of evt.data) { + for (const item of evt.provider.list()) { if (item.provider.endpoint.type !== "aisdk") continue if (item.provider.endpoint.package !== "@ai-sdk/openai-compatible") continue if (item.provider.endpoint.url !== "https://zenmux.ai/api/v1") continue diff --git a/packages/core/src/policy.ts b/packages/core/src/policy.ts new file mode 100644 index 0000000000..78bd74f1cd --- /dev/null +++ b/packages/core/src/policy.ts @@ -0,0 +1,44 @@ +export * as Policy from "./policy" + +import { Context, Effect as EffectRuntime, Layer, Schema } from "effect" +import { Wildcard } from "./util/wildcard" +import { Location } from "./location" + +export const Effect = Schema.Literals(["allow", "deny"]).annotate({ identifier: "Policy.Effect" }) +export type Effect = typeof Effect.Type + +export class Info extends Schema.Class("Policy.Info")({ + action: Schema.String, + effect: Effect, + resource: Schema.String, +}) {} + +export interface Interface { + readonly load: (statements: Info[]) => EffectRuntime.Effect + readonly evaluate: (action: string, resource: string, fallback: Effect) => EffectRuntime.Effect +} + +export class Service extends Context.Service()("@opencode/v2/Policy") {} + +export const layer = Layer.effect( + Service, + EffectRuntime.gen(function* () { + let statements: Info[] = [] + yield* Location.Service + + return Service.of({ + load: EffectRuntime.fn("Policy.load")(function* (input) { + statements = input + }), + evaluate: EffectRuntime.fn("Policy.evaluate")(function* (action, resource, fallback) { + return ( + statements.findLast( + (statement) => Wildcard.match(action, statement.action) && Wildcard.match(resource, statement.resource), + )?.effect ?? fallback + ) + }), + }) + }), +) + +export const defaultLayer = layer diff --git a/packages/core/src/project.ts b/packages/core/src/project.ts index 9c265d75be..90638873d7 100644 --- a/packages/core/src/project.ts +++ b/packages/core/src/project.ts @@ -25,7 +25,6 @@ export type Vcs = typeof Vcs.Type export class Info extends Schema.Class("Project.Info")({ id: ID, - vcs: Schema.optional(Vcs), }) {} export interface Interface { @@ -105,7 +104,7 @@ export const layer = Layer.effect( const resolve = Effect.fn("Project.resolve")(function* (input: AbsolutePath) { const repo = yield* git.find(input) - if (!repo) return { id: ID.global, directory: input, vcs: undefined } + if (!repo) return { id: ID.global, directory: AbsolutePath.make(path.parse(input).root), vcs: undefined } const previous = yield* cached(repo.store) const id = (yield* remote(repo)) ?? previous ?? (yield* root(repo)) diff --git a/packages/core/src/state.ts b/packages/core/src/state.ts new file mode 100644 index 0000000000..1d67acaa92 --- /dev/null +++ b/packages/core/src/state.ts @@ -0,0 +1,67 @@ +export * as State from "./state" + +import { Effect, Scope, Semaphore } from "effect" +import { createDraft, finishDraft, type Draft, type Objectish } from "immer" + +export type Transform = (editor: Editor) => void +export type MakeEditor = (draft: Draft) => Editor + +export interface Options { + readonly initial: () => State + readonly editor: MakeEditor + /** Completes every committed edit; reason identifies exceptional update origins. */ + readonly finalize?: (editor: Editor, reason?: string) => Effect.Effect +} + +export interface Interface { + readonly get: () => State + readonly transform: () => Effect.Effect< + (transform: Transform) => Effect.Effect, + never, + Scope.Scope + > + readonly update: (update: (editor: Editor) => Effect.Effect, reason?: string) => Effect.Effect +} + +export function create(options: Options): Interface { + let state = options.initial() + let transforms: { update: Transform }[] = [] + const semaphore = Semaphore.makeUnsafe(1) + + const commit = Effect.fn("State.commit")(function* (draft: Draft, reason?: string) { + const api = options.editor(draft) + if (options.finalize) yield* options.finalize(api, reason) + state = finishDraft(draft) as State + }) + + const rebuild = Effect.fn("State.rebuild")(function* () { + const draft = createDraft(options.initial()) + const api = options.editor(draft) + for (const transform of transforms) transform.update(api) + yield* commit(draft) + }, semaphore.withPermit) + + return { + get: () => state, + transform: Effect.fn("State.transform")(function* () { + const transform = { update: (_editor: Editor) => {} } + transforms = [...transforms, transform] + const scope = yield* Scope.Scope + yield* Scope.addFinalizer( + scope, + Effect.sync(() => { + transforms = transforms.filter((item) => item !== transform) + }).pipe(Effect.andThen(rebuild())), + ) + return Effect.fnUntraced(function* (update: Transform) { + transform.update = update + yield* rebuild() + }) + }), + update: Effect.fn("State.update")(function* (update, reason) { + const draft = createDraft(state) + yield* update(options.editor(draft)) + yield* commit(draft, reason) + }, semaphore.withPermit), + } +} diff --git a/packages/core/test/account.test.ts b/packages/core/test/account.test.ts index cf60740b1e..1e50fd340a 100644 --- a/packages/core/test/account.test.ts +++ b/packages/core/test/account.test.ts @@ -19,12 +19,11 @@ const it = testEffect(PluginV2.defaultLayer) function context( records: { provider: ProviderV2.Info; models: Map }[], updates: Array<{ id: ProviderV2.ID; enabled: ProviderV2.Info["enabled"]; apiKey?: string }>, -): Catalog.Context { +): Catalog.Editor { return { - data: records, - updateProvider: (providerID, fn) => context(records, updates).provider.update(providerID, fn), - updateModel: (providerID, modelID, fn) => context(records, updates).model.update(providerID, modelID, fn), provider: { + list: () => records, + get: (providerID) => records.find((item) => item.provider.id === providerID), update: (providerID, fn) => { const record = records.find((item) => item.provider.id === providerID) const provider = produce(record?.provider ?? ProviderV2.Info.empty(providerID), fn) @@ -45,8 +44,13 @@ function context( }, }, model: { + get: () => undefined, update: () => {}, remove: () => {}, + default: { + get: () => undefined, + set: () => {}, + }, }, } } @@ -192,7 +196,7 @@ describe("AccountV2", () => { ] const updates: Array<{ id: ProviderV2.ID; enabled: ProviderV2.Info["enabled"]; apiKey?: string }> = [] const catalog = Catalog.Service.of({ - loader: () => Effect.die("unexpected catalog.loader"), + transform: () => Effect.die("unexpected catalog.transform"), provider: { get: () => Effect.die("unexpected provider.get"), all: () => Effect.succeed([]), @@ -203,7 +207,6 @@ describe("AccountV2", () => { all: () => Effect.succeed([]), available: () => Effect.succeed([]), default: () => Effect.succeed(Option.none()), - setDefault: () => Effect.die("unexpected model.setDefault"), small: () => Effect.succeed(Option.none()), }, }) diff --git a/packages/core/test/agent.test.ts b/packages/core/test/agent.test.ts new file mode 100644 index 0000000000..2449a91a7c --- /dev/null +++ b/packages/core/test/agent.test.ts @@ -0,0 +1,105 @@ +import { describe, expect } from "bun:test" +import { Effect, Exit, Scope } from "effect" +import { AgentV2 } from "@opencode-ai/core/agent" +import { testEffect } from "./lib/effect" + +const it = testEffect(AgentV2.defaultLayer) + +describe("AgentV2", () => { + it.effect("starts without agents", () => + Effect.gen(function* () { + const agent = yield* AgentV2.Service + + expect(yield* agent.all()).toEqual([]) + expect(yield* agent.get(AgentV2.ID.make("build"))).toBeUndefined() + }), + ) + + it.effect("materializes replayable agent transforms", () => + Effect.gen(function* () { + const agent = yield* AgentV2.Service + const id = AgentV2.ID.make("reviewer") + const transform = yield* agent.transform() + + yield* transform((editor) => + editor.update(id, (info) => { + info.description = "Reviews code" + info.mode = "subagent" + }), + ) + + expect(yield* agent.get(id)).toMatchObject({ id, description: "Reviews code", mode: "subagent" }) + expect((yield* agent.all()).map((info) => info.id)).toEqual([id]) + }), + ) + + it.effect("rebuilds state when a transform is replaced", () => + Effect.gen(function* () { + const agent = yield* AgentV2.Service + const id = AgentV2.ID.make("reviewer") + const transform = yield* agent.transform() + + yield* transform((editor) => + editor.update(id, (info) => { + info.description = "Old description" + info.hidden = true + }), + ) + yield* transform((editor) => + editor.update(id, (info) => { + info.description = "New description" + }), + ) + + expect(yield* agent.get(id)).toMatchObject({ description: "New description", hidden: false }) + }), + ) + + it.effect("removes a transform contribution when its scope closes", () => + Effect.gen(function* () { + const agent = yield* AgentV2.Service + const id = AgentV2.ID.make("scoped") + const scope = yield* Scope.make() + const transform = yield* agent.transform().pipe(Scope.provide(scope)) + + yield* transform((editor) => editor.update(id, () => {})) + expect(yield* agent.get(id)).toBeDefined() + + yield* Scope.close(scope, Exit.void) + expect(yield* agent.get(id)).toBeUndefined() + }), + ) + + it.effect("applies direct agent updates", () => + Effect.gen(function* () { + const agent = yield* AgentV2.Service + const id = AgentV2.ID.make("build") + + yield* agent.update((editor) => + Effect.sync(() => + editor.update(id, (info) => { + info.mode = "primary" + info.hidden = true + }), + ), + ) + + expect(yield* agent.get(id)).toMatchObject({ id, mode: "primary", hidden: true }) + }), + ) + + it.effect("creates agents with runtime defaults and supports direct removal", () => + Effect.gen(function* () { + const agent = yield* AgentV2.Service + const id = AgentV2.ID.make("custom") + + yield* agent.update((editor) => Effect.sync(() => editor.update(id, () => {}))) + expect(yield* agent.get(id)).toEqual( + AgentV2.Info.empty(id), + ) + + yield* agent.update((editor) => Effect.sync(() => editor.remove(id))) + expect(yield* agent.get(id)).toBeUndefined() + }), + ) +}) diff --git a/packages/core/test/catalog.test.ts b/packages/core/test/catalog.test.ts index 97f816d005..9736e2a739 100644 --- a/packages/core/test/catalog.test.ts +++ b/packages/core/test/catalog.test.ts @@ -5,14 +5,21 @@ import { EventV2 } from "@opencode-ai/core/event" import { Location } from "@opencode-ai/core/location" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { Policy } from "@opencode-ai/core/policy" import { ProviderV2 } from "@opencode-ai/core/provider" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { location } from "./fixture/location" import { testEffect } from "./lib/effect" -const locationLayer = Layer.succeed(Location.Service, Location.Service.of({ directory: "test" })) +const locationLayer = Layer.succeed( + Location.Service, + Location.Service.of(location({ directory: AbsolutePath.make("test") })), +) const it = testEffect( Catalog.layer.pipe( Layer.provideMerge(EventV2.defaultLayer), Layer.provideMerge(PluginV2.defaultLayer), + Layer.provideMerge(Policy.defaultLayer), Layer.provideMerge(locationLayer), ), ) @@ -22,9 +29,9 @@ describe("CatalogV2", () => { Effect.gen(function* () { const catalog = yield* Catalog.Service const providerID = ProviderV2.ID.make("test") - const load = yield* catalog.loader() + const transform = yield* catalog.transform() - yield* load((catalog) => + yield* transform((catalog) => catalog.provider.update(providerID, (provider) => { provider.endpoint = { type: "aisdk", @@ -48,9 +55,9 @@ describe("CatalogV2", () => { const catalog = yield* Catalog.Service const providerID = ProviderV2.ID.make("test") const modelID = ModelV2.ID.make("model") - const load = yield* catalog.loader() + const transform = yield* catalog.transform() - yield* load((catalog) => { + yield* transform((catalog) => { catalog.provider.update(providerID, (provider) => { provider.endpoint = { type: "aisdk", @@ -77,9 +84,9 @@ describe("CatalogV2", () => { const catalog = yield* Catalog.Service const providerID = ProviderV2.ID.make("test") const modelID = ModelV2.ID.make("model") - const load = yield* catalog.loader() + const transform = yield* catalog.transform() - yield* load((catalog) => { + yield* transform((catalog) => { catalog.provider.update(providerID, (provider) => { provider.endpoint = { type: "aisdk", @@ -104,14 +111,14 @@ describe("CatalogV2", () => { const plugin = yield* PluginV2.Service const providerID = ProviderV2.ID.make("test") const seen: unknown[] = [] - const load = yield* catalog.loader() + const transform = yield* catalog.transform() yield* plugin.add({ id: PluginV2.ID.make("test"), effect: Effect.succeed({ "catalog.transform": (evt) => Effect.sync(() => { - const item = evt.data.find((record) => record.provider.id === providerID) + const item = evt.provider.get(providerID) if (!item) return seen.push(item.provider.endpoint.type) if (item?.provider.endpoint.type === "aisdk") seen.push(item.provider.endpoint.url) @@ -119,7 +126,7 @@ describe("CatalogV2", () => { }), }), }) - yield* load((catalog) => + yield* transform((catalog) => catalog.provider.update(providerID, (provider) => { provider.endpoint = { type: "aisdk", package: "@ai-sdk/openai-compatible" } provider.options.aisdk.provider.baseURL = "https://provider.example.com" @@ -135,9 +142,9 @@ describe("CatalogV2", () => { const catalog = yield* Catalog.Service const plugin = yield* PluginV2.Service const providerID = ProviderV2.ID.make("test") - const load = yield* catalog.loader() + const transform = yield* catalog.transform() - yield* load((catalog) => + yield* transform((catalog) => catalog.provider.update(providerID, (provider) => { provider.name = "Before" }), @@ -164,9 +171,9 @@ describe("CatalogV2", () => { const catalog = yield* Catalog.Service const providerID = ProviderV2.ID.make("test") const modelID = ModelV2.ID.make("model") - const load = yield* catalog.loader() + const transform = yield* catalog.transform() - yield* load((catalog) => { + yield* transform((catalog) => { catalog.provider.update(providerID, (provider) => { provider.options.headers.provider = "provider" provider.options.headers.shared = "provider" @@ -194,9 +201,9 @@ describe("CatalogV2", () => { Effect.gen(function* () { const catalog = yield* Catalog.Service const providerID = ProviderV2.ID.make("test") - const load = yield* catalog.loader() + const transform = yield* catalog.transform() - yield* load((catalog) => { + yield* transform((catalog) => { catalog.provider.update(providerID, (provider) => { provider.enabled = { via: "custom", data: {} } }) @@ -212,13 +219,44 @@ describe("CatalogV2", () => { }), ) + it.effect("uses a transform-provided default model until that transform is replaced", () => + Effect.gen(function* () { + const catalog = yield* Catalog.Service + const providerID = ProviderV2.ID.make("test") + const old = ModelV2.ID.make("old") + const newest = ModelV2.ID.make("new") + const transform = yield* catalog.transform() + + const models = (catalog: Catalog.Editor) => { + catalog.provider.update(providerID, (provider) => { + provider.enabled = { via: "custom", data: {} } + }) + catalog.model.update(providerID, old, (model) => { + model.time.released = DateTime.makeUnsafe(1000) + }) + catalog.model.update(providerID, newest, (model) => { + model.time.released = DateTime.makeUnsafe(2000) + }) + } + + yield* transform((catalog) => { + models(catalog) + catalog.model.default.set(providerID, old) + }) + expect(Option.getOrUndefined(yield* catalog.model.default())?.id).toBe(old) + + yield* transform(models) + expect(Option.getOrUndefined(yield* catalog.model.default())?.id).toBe(newest) + }), + ) + it.effect("small model prefers small keyword candidates before cost scoring", () => Effect.gen(function* () { const catalog = yield* Catalog.Service const providerID = ProviderV2.ID.make("test") - const load = yield* catalog.loader() + const transform = yield* catalog.transform() - yield* load((catalog) => { + yield* transform((catalog) => { catalog.provider.update(providerID, () => {}) catalog.model.update(providerID, ModelV2.ID.make("cheap-large"), (model) => { model.capabilities.input = ["text"] @@ -237,4 +275,23 @@ describe("CatalogV2", () => { expect(Option.getOrUndefined(yield* catalog.model.small(providerID))?.id).toMatch("expensive-mini") }), ) + + it.effect("removes providers denied by policy after loading", () => + Effect.gen(function* () { + const catalog = yield* Catalog.Service + const policy = yield* Policy.Service + const providerID = ProviderV2.ID.make("blocked") + const transform = yield* catalog.transform() + + yield* policy.load([new Policy.Info({ effect: "deny", action: "provider.use", resource: "blocked" })]) + yield* transform((catalog) => { + catalog.provider.update(providerID, () => {}) + catalog.model.update(providerID, ModelV2.ID.make("model"), () => {}) + }) + + expect(yield* catalog.provider.all()).toEqual([]) + expect(yield* catalog.model.all()).toEqual([]) + expect(yield* catalog.provider.get(providerID).pipe(Effect.option)).toEqual(Option.none()) + }), + ) }) diff --git a/packages/core/test/config/agent.test.ts b/packages/core/test/config/agent.test.ts new file mode 100644 index 0000000000..0d8562313b --- /dev/null +++ b/packages/core/test/config/agent.test.ts @@ -0,0 +1,186 @@ +import { describe, expect } from "bun:test" +import { Effect, Schema } from "effect" +import { AgentV2 } from "@opencode-ai/core/agent" +import { Config } from "@opencode-ai/core/config" +import { ConfigAgentPlugin } from "@opencode-ai/core/config/plugin/agent" +import { PermissionV2 } from "@opencode-ai/core/permission" +import { testEffect } from "../lib/effect" + +const it = testEffect(AgentV2.defaultLayer) +const decode = Schema.decodeUnknownSync(Config.Info) + +describe("ConfigAgentPlugin.Plugin", () => { + it.effect("applies global permissions between built-in and agent-specific permissions", () => + Effect.gen(function* () { + const agents = yield* AgentV2.Service + const build = AgentV2.ID.make("build") + const defaults = yield* agents.transform() + + yield* defaults((editor) => + editor.update(build, (agent) => { + agent.mode = "primary" + agent.permissions.push({ permission: "bash", pattern: "*", action: "allow" }) + }), + ) + + const config = Config.Service.of({ + directories: () => Effect.succeed([]), + get: () => + Effect.succeed([ + new Config.Loaded({ + source: { type: "memory" }, + info: decode({ + permissions: [{ permission: "bash", pattern: "*", action: "ask" }], + agents: { + build: { + permissions: [{ permission: "bash", pattern: "git *", action: "allow" }], + }, + reviewer: { + model: "openrouter/openai/gpt-5", + description: "Review changes", + mode: "subagent", + permissions: [{ permission: "edit", pattern: "*", action: "deny" }], + }, + removed: { description: "Removed later" }, + }, + }), + }), + new Config.Loaded({ + source: { type: "memory" }, + info: decode({ + agents: { + reviewer: { variant: "high", hidden: true }, + removed: { disabled: true }, + }, + }), + }), + ]), + }) + + yield* ConfigAgentPlugin.Plugin.effect.pipe( + Effect.provideService(Config.Service, config), + Effect.provideService(AgentV2.Service, agents), + ) + + const buildAgent = yield* agents.get(build) + if (!buildAgent) throw new Error("expected configured build agent") + expect(buildAgent.permissions).toEqual([ + { permission: "bash", pattern: "*", action: "allow" }, + { permission: "bash", pattern: "*", action: "ask" }, + { permission: "bash", pattern: "git *", action: "allow" }, + ]) + expect(PermissionV2.evaluate("bash", "git status", buildAgent.permissions).action).toBe("allow") + expect(PermissionV2.evaluate("bash", "bun test", buildAgent.permissions).action).toBe("ask") + + const reviewer = yield* agents.get(AgentV2.ID.make("reviewer")) + if (!reviewer) throw new Error("expected configured reviewer agent") + expect(reviewer).toMatchObject({ + description: "Review changes", + mode: "subagent", + hidden: true, + model: { providerID: "openrouter", id: "openai/gpt-5", variant: "high" }, + }) + expect(reviewer.permissions).toEqual([ + { permission: "bash", pattern: "*", action: "ask" }, + { permission: "edit", pattern: "*", action: "deny" }, + ]) + expect(yield* agents.get(AgentV2.ID.make("removed"))).toBeUndefined() + }), + ) + + it.effect("maps configured agent fields and preserves an unspecified model variant", () => + Effect.gen(function* () { + const agents = yield* AgentV2.Service + const config = Config.Service.of({ + directories: () => Effect.succeed([]), + get: () => + Effect.succeed([ + new Config.Loaded({ + source: { type: "memory" }, + info: decode({ + agents: { + reviewer: { + model: "anthropic/claude-sonnet", + system: "Review carefully.", + description: "Reviews changes", + mode: "subagent", + hidden: true, + color: "warning", + steps: 12, + options: { + headers: { first: "one", shared: "first" }, + body: { enabled: true }, + aisdk: { provider: { profile: "review" }, request: { effort: "medium" } }, + }, + }, + }, + }), + }), + new Config.Loaded({ + source: { type: "memory" }, + info: decode({ + agents: { + reviewer: { + options: { + headers: { shared: "last", second: "two" }, + body: { retries: 2 }, + aisdk: { request: { effort: "high" } }, + }, + }, + }, + }), + }), + ]), + }) + + yield* ConfigAgentPlugin.Plugin.effect.pipe( + Effect.provideService(Config.Service, config), + Effect.provideService(AgentV2.Service, agents), + ) + + const reviewer = yield* agents.get(AgentV2.ID.make("reviewer")) + if (!reviewer) throw new Error("expected configured reviewer agent") + expect(reviewer).toMatchObject({ + system: "Review carefully.", + description: "Reviews changes", + mode: "subagent", + hidden: true, + color: "warning", + steps: 12, + model: { providerID: "anthropic", id: "claude-sonnet", variant: undefined }, + }) + expect(reviewer.options).toEqual({ + headers: { first: "one", shared: "last", second: "two" }, + body: { enabled: true, retries: 2 }, + aisdk: { provider: { profile: "review" }, request: { effort: "high" } }, + }) + }), + ) + + it.effect("removes a built-in agent disabled by configuration", () => + Effect.gen(function* () { + const agents = yield* AgentV2.Service + const build = AgentV2.ID.make("build") + const defaults = yield* agents.transform() + yield* defaults((editor) => editor.update(build, () => {})) + + const config = Config.Service.of({ + directories: () => Effect.succeed([]), + get: () => + Effect.succeed([ + new Config.Loaded({ + source: { type: "memory" }, + info: decode({ agents: { build: { disabled: true } } }), + }), + ]), + }) + + yield* ConfigAgentPlugin.Plugin.effect.pipe( + Effect.provideService(Config.Service, config), + Effect.provideService(AgentV2.Service, agents), + ) + + expect(yield* agents.get(build)).toBeUndefined() + }), + ) +}) diff --git a/packages/core/test/config/config.test.ts b/packages/core/test/config/config.test.ts new file mode 100644 index 0000000000..0a39ed4b3e --- /dev/null +++ b/packages/core/test/config/config.test.ts @@ -0,0 +1,451 @@ +import path from "path" +import fs from "fs/promises" +import { describe, expect } from "bun:test" +import { Effect, Layer } from "effect" +import { Config } from "@opencode-ai/core/config" +import { ConfigProvider } from "@opencode-ai/core/config/provider" +import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { Global } from "@opencode-ai/core/global" +import { Location } from "@opencode-ai/core/location" +import { Policy } from "@opencode-ai/core/policy" +import { Project } from "@opencode-ai/core/project" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { location } from "../fixture/location" +import { tmpdir } from "../fixture/tmpdir" +import { testEffect } from "../lib/effect" + +const it = testEffect(Layer.empty) + +function testLayer( + directory: string, + globalDirectory = path.join(directory, "global"), + projectDirectory = directory, + vcs?: Project.Vcs, +) { + return Config.layer.pipe( + Layer.provide(AppFileSystem.defaultLayer), + Layer.provide(Global.layerWith({ config: globalDirectory })), + Layer.provideMerge(Policy.defaultLayer), + Layer.provide( + Layer.succeed( + Location.Service, + Location.Service.of( + location( + { directory: AbsolutePath.make(directory) }, + { projectDirectory: AbsolutePath.make(projectDirectory), vcs }, + ), + ), + ), + ), + ) +} + +const provider = { + endpoint: { type: "unknown" }, + options: { + headers: {}, + body: {}, + aisdk: { + provider: {}, + request: {}, + }, + }, + models: {}, +} + +describe("Config", () => { + it.live("returns an empty configuration when directory files do not exist", () => + Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ).pipe( + Effect.flatMap((tmp) => + Effect.gen(function* () { + const config = yield* Config.Service + const documents = yield* config.get() + + expect(documents).toEqual([]) + }).pipe(Effect.provide(testLayer(tmp.path))), + ), + ), + ) + + it.live("loads JSON and JSONC files from lowest to highest priority", () => + Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ).pipe( + Effect.flatMap((tmp) => + Effect.gen(function* () { + yield* Effect.promise(() => + Promise.all([ + fs.writeFile( + path.join(tmp.path, "config.json"), + JSON.stringify({ $schema: "base", providers: { base: provider } }), + ), + fs.writeFile( + path.join(tmp.path, "opencode.json"), + JSON.stringify({ $schema: "middle", providers: { middle: provider } }), + ), + fs.writeFile( + path.join(tmp.path, "opencode.jsonc"), + `{ + // Later global files override scalar fields while retaining providers. + "$schema": "last", + "providers": { "last": ${JSON.stringify(provider)} }, + }`, + ), + ]), + ) + return yield* Effect.gen(function* () { + const config = yield* Config.Service + const documents = yield* config.get() + + expect(documents).toHaveLength(3) + expect(documents.map((document) => document.source.type)).toEqual(["file", "file", "file"]) + expect(documents.map((document) => document.info.$schema)).toEqual(["base", "middle", "last"]) + expect(documents[0]).toBeInstanceOf(Config.Loaded) + expect(documents[0]?.source.type === "file" ? documents[0].source.path : undefined).toBe( + path.join(tmp.path, "config.json"), + ) + expect(documents[2]?.info.providers?.last).toBeInstanceOf(ConfigProvider.Info) + + yield* Effect.promise(() => + fs.writeFile(path.join(tmp.path, "opencode.jsonc"), JSON.stringify({ $schema: "changed" })), + ) + expect((yield* config.get()).map((document) => document.info.$schema)).toEqual(["base", "middle", "last"]) + }).pipe(Effect.provide(testLayer(tmp.path))) + }), + ), + ), + ) + + it.live("accepts $schema metadata without writing it into config files", () => + Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ).pipe( + Effect.flatMap((tmp) => + Effect.gen(function* () { + const file = path.join(tmp.path, "opencode.json") + const contents = JSON.stringify({ + shell: "/bin/zsh", + experimental: { policies: [{ effect: "deny", action: "provider.use", resource: "openai" }] }, + providers: { local: provider }, + }) + yield* Effect.promise(() => fs.writeFile(file, contents)) + + return yield* Effect.gen(function* () { + const config = yield* Config.Service + const documents = yield* config.get() + + expect(documents[0]?.info.$schema).toBeUndefined() + expect(documents[0]?.info.shell).toBe("/bin/zsh") + expect(documents[0]?.info.experimental?.policies?.[0]).toEqual({ + effect: "deny", + action: "provider.use", + resource: "openai", + }) + expect(yield* Effect.promise(() => fs.readFile(file, "utf8"))).toBe(contents) + }).pipe(Effect.provide(testLayer(tmp.path))) + }), + ), + ), + ) + + it.live("loads supported scalar and resource configuration", () => + Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ).pipe( + Effect.flatMap((tmp) => + Effect.gen(function* () { + yield* Effect.promise(() => + fs.writeFile( + path.join(tmp.path, "opencode.json"), + JSON.stringify({ + shell: "/bin/bash", + model: "anthropic/claude", + autoupdate: "notify", + share: "disabled", + enterprise: { url: "https://share.example.com" }, + username: "test-user", + permissions: [ + { permission: "bash", pattern: "*", action: "ask" }, + { permission: "bash", pattern: "git status", action: "allow" }, + ], + agents: { + reviewer: { + model: "openrouter/openai/gpt-5", + variant: "high", + options: { + headers: { "x-agent": "reviewer" }, + aisdk: { request: { reasoningEffort: "high" } }, + }, + description: "Review changes for correctness", + system: "Find regressions.", + mode: "subagent", + hidden: false, + color: "warning", + steps: 12, + disabled: false, + permissions: [{ permission: "edit", pattern: "*", action: "deny" }], + }, + }, + snapshots: false, + watcher: { ignore: ["node_modules/**", "dist/**", ".git"] }, + formatter: { prettier: { disabled: true }, custom: { command: ["custom-fmt", "$FILE"], extensions: [".foo"] } }, + lsp: { typescript: { disabled: true }, custom: { command: ["custom-lsp"], extensions: [".foo"] } }, + attachments: { image: { auto_resize: false, max_width: 1200, max_height: 900, max_base64_bytes: 1048576 } }, + tool_output: { max_lines: 1000, max_bytes: 32768 }, + mcp: { + timeout: 5000, + servers: { + local: { + type: "local", + command: ["node", "./mcp/server.js"], + environment: { API_KEY: "secret" }, + disabled: false, + timeout: 10000, + }, + remote: { + type: "remote", + url: "https://mcp.example.com/mcp", + headers: { Authorization: "Bearer token" }, + oauth: { client_id: "client", scope: "read write", callback_port: 19876 }, + disabled: true, + }, + }, + }, + compaction: { + auto: true, + prune: false, + keep: { turns: 3, tokens: 2000 }, + buffer: 10000, + }, + skills: ["./skills", "~/shared-skills", "https://example.com/.well-known/skills/"], + instructions: ["CONTRIBUTING.md", ".cursor/rules/*.md", "https://example.com/shared-rules.md"], + references: { + local: { path: "../library" }, + sdk: { repository: "github.com/example/sdk", branch: "main" }, + shorthand: "github.com/example/docs", + }, + plugins: [ + "opencode-helicone-session", + { package: "@my-org/audit-plugin", options: { endpoint: "https://audit.example.com" } }, + ], + }), + ), + ) + + return yield* Effect.gen(function* () { + const config = yield* Config.Service + const documents = yield* config.get() + + expect(documents).toHaveLength(1) + expect(documents[0]?.info.shell).toBe("/bin/bash") + expect(documents[0]?.info.model).toBe("anthropic/claude") + expect(documents[0]?.info.autoupdate).toBe("notify") + expect(documents[0]?.info.share).toBe("disabled") + expect(documents[0]?.info.enterprise).toEqual({ url: "https://share.example.com" }) + expect(documents[0]?.info.username).toBe("test-user") + expect(documents[0]?.info.permissions).toEqual([ + { permission: "bash", pattern: "*", action: "ask" }, + { permission: "bash", pattern: "git status", action: "allow" }, + ]) + expect(documents[0]?.info.agents?.reviewer).toEqual({ + model: "openrouter/openai/gpt-5", + variant: "high", + options: { + headers: { "x-agent": "reviewer" }, + aisdk: { request: { reasoningEffort: "high" } }, + }, + description: "Review changes for correctness", + system: "Find regressions.", + mode: "subagent", + hidden: false, + color: "warning", + steps: 12, + disabled: false, + permissions: [{ permission: "edit", pattern: "*", action: "deny" }], + }) + expect(documents[0]?.info.snapshots).toBe(false) + expect(documents[0]?.info.watcher).toEqual({ ignore: ["node_modules/**", "dist/**", ".git"] }) + expect(documents[0]?.info.formatter).toEqual({ + prettier: { disabled: true }, + custom: { command: ["custom-fmt", "$FILE"], extensions: [".foo"] }, + }) + expect(documents[0]?.info.lsp).toEqual({ + typescript: { disabled: true }, + custom: { command: ["custom-lsp"], extensions: [".foo"] }, + }) + expect(documents[0]?.info.attachments).toEqual({ + image: { auto_resize: false, max_width: 1200, max_height: 900, max_base64_bytes: 1048576 }, + }) + expect(documents[0]?.info.tool_output).toEqual({ max_lines: 1000, max_bytes: 32768 }) + expect(documents[0]?.info.mcp).toEqual({ + timeout: 5000, + servers: { + local: { + type: "local", + command: ["node", "./mcp/server.js"], + environment: { API_KEY: "secret" }, + disabled: false, + timeout: 10000, + }, + remote: { + type: "remote", + url: "https://mcp.example.com/mcp", + headers: { Authorization: "Bearer token" }, + oauth: { client_id: "client", scope: "read write", callback_port: 19876 }, + disabled: true, + }, + }, + }) + expect(documents[0]?.info.compaction).toEqual({ + auto: true, + prune: false, + keep: { turns: 3, tokens: 2000 }, + buffer: 10000, + }) + expect(documents[0]?.info.skills).toEqual([ + "./skills", + "~/shared-skills", + "https://example.com/.well-known/skills/", + ]) + expect(documents[0]?.info.instructions).toEqual([ + "CONTRIBUTING.md", + ".cursor/rules/*.md", + "https://example.com/shared-rules.md", + ]) + expect(documents[0]?.info.references).toEqual({ + local: { path: "../library" }, + sdk: { repository: "github.com/example/sdk", branch: "main" }, + shorthand: "github.com/example/docs", + }) + expect(documents[0]?.info.plugins).toEqual([ + "opencode-helicone-session", + { package: "@my-org/audit-plugin", options: { endpoint: "https://audit.example.com" } }, + ]) + }).pipe(Effect.provide(testLayer(tmp.path))) + }), + ), + ), + ) + + it.live("ignores invalid files while loading valid config values", () => + Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ).pipe( + Effect.flatMap((tmp) => + Effect.gen(function* () { + yield* Effect.promise(() => + Promise.all([ + fs.writeFile(path.join(tmp.path, "config.json"), JSON.stringify({ $schema: "base" })), + fs.writeFile(path.join(tmp.path, "opencode.json"), "{ invalid"), + fs.writeFile(path.join(tmp.path, "opencode.jsonc"), JSON.stringify({ providers: { invalid: true } })), + ]), + ) + return yield* Effect.gen(function* () { + const config = yield* Config.Service + const documents = yield* config.get() + + expect(documents.map((document) => document.info.$schema)).toEqual(["base"]) + }).pipe(Effect.provide(testLayer(tmp.path))) + }), + ), + ), + ) + + it.live("loads policy statements in reverse config order", () => + Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ).pipe( + Effect.flatMap((tmp) => { + const global = path.join(tmp.path, "global") + return Effect.gen(function* () { + yield* Effect.promise(async () => { + await fs.mkdir(global, { recursive: true }) + await fs.writeFile( + path.join(global, "opencode.json"), + JSON.stringify({ experimental: { policies: [{ effect: "deny", action: "provider.use", resource: "openai" }] } }), + ) + await fs.writeFile( + path.join(tmp.path, "opencode.json"), + JSON.stringify({ experimental: { policies: [{ effect: "allow", action: "provider.use", resource: "openai" }] } }), + ) + }) + + return yield* Effect.gen(function* () { + const policy = yield* Policy.Service + + expect(yield* policy.evaluate("provider.use", "openai", "allow")).toBe("deny") + }).pipe(Effect.provide(testLayer(tmp.path, global))) + }) + }), + ), + ) + + it.live("loads global, ancestor, and .opencode configuration up to the project boundary", () => + Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ).pipe( + Effect.flatMap((tmp) => { + const global = path.join(tmp.path, "global") + const root = path.join(tmp.path, "repo") + const parent = path.join(root, "packages") + const directory = path.join(parent, "app") + return Effect.gen(function* () { + yield* Effect.promise(async () => { + await fs.mkdir(global, { recursive: true }) + await fs.mkdir(directory, { recursive: true }) + await fs.mkdir(path.join(root, ".opencode"), { recursive: true }) + await fs.mkdir(path.join(directory, ".opencode"), { recursive: true }) + await Promise.all([ + fs.writeFile(path.join(tmp.path, "opencode.json"), JSON.stringify({ $schema: "outside" })), + fs.writeFile(path.join(global, "opencode.json"), JSON.stringify({ $schema: "global" })), + fs.writeFile(path.join(root, "opencode.json"), JSON.stringify({ $schema: "root" })), + fs.writeFile(path.join(parent, "opencode.jsonc"), JSON.stringify({ $schema: "parent" })), + fs.writeFile(path.join(directory, "config.json"), JSON.stringify({ $schema: "directory" })), + fs.writeFile(path.join(root, ".opencode", "opencode.json"), JSON.stringify({ $schema: "root-dot" })), + fs.writeFile( + path.join(directory, ".opencode", "opencode.jsonc"), + JSON.stringify({ $schema: "directory-dot" }), + ), + ]) + }) + + return yield* Effect.gen(function* () { + const config = yield* Config.Service + const directories = yield* config.directories() + const documents = yield* config.get() + + expect(directories).toEqual([ + AbsolutePath.make(global), + AbsolutePath.make(path.join(root, ".opencode")), + AbsolutePath.make(path.join(directory, ".opencode")), + ]) + expect(documents.map((document) => document.info.$schema)).toEqual([ + "global", + "root", + "parent", + "directory", + "root-dot", + "directory-dot", + ]) + }).pipe( + Effect.provide( + testLayer(directory, global, root, { + type: "git", + store: AbsolutePath.make(path.join(root, ".git")), + }), + ), + ) + }) + }), + ), + ) +}) diff --git a/packages/core/test/config/provider.test.ts b/packages/core/test/config/provider.test.ts new file mode 100644 index 0000000000..01b8c9c9aa --- /dev/null +++ b/packages/core/test/config/provider.test.ts @@ -0,0 +1,131 @@ +import { describe, expect } from "bun:test" +import { Effect, Schema } from "effect" +import { Catalog } from "@opencode-ai/core/catalog" +import { Config } from "@opencode-ai/core/config" +import { ConfigProviderPlugin } from "@opencode-ai/core/config/plugin/provider" +import { ModelV2 } from "@opencode-ai/core/model" +import { PluginV2 } from "@opencode-ai/core/plugin" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { it } from "../plugin/provider-helper" + +function options(headers: Record, variant?: string) { + return { + headers, + variant, + } +} + +const decode = Schema.decodeUnknownSync(Config.Info) + +describe("ConfigProviderPlugin.Plugin", () => { + it.effect("loads configured providers and applies later model overrides", () => + Effect.gen(function* () { + const catalog = yield* Catalog.Service + const plugin = yield* PluginV2.Service + const providerID = ProviderV2.ID.make("custom") + const modelID = ModelV2.ID.make("chat") + const config = Config.Service.of({ + directories: () => Effect.succeed([]), + get: () => + Effect.succeed([ + new Config.Loaded({ + source: { type: "memory" }, + info: decode({ + providers: { + custom: { + name: "Configured", + env: ["CUSTOM_API_KEY"], + endpoint: { type: "unknown" }, + options: options({ first: "first", shared: "first" }), + models: { + chat: { + name: "First", + capabilities: { tools: true, input: ["text"], output: ["text"] }, + disabled: true, + limit: { context: 100, output: 50 }, + cost: { input: 1, output: 2 }, + options: options({ first: "first", shared: "first" }, "retained"), + variants: [ + { + id: "fast", + headers: { first: "first", shared: "first" }, + }, + ], + }, + }, + }, + }, + }), + }), + new Config.Loaded({ + source: { type: "memory" }, + info: decode({ + providers: { + custom: { + endpoint: { type: "aisdk", package: "custom-sdk", url: "https://example.test" }, + options: options({ last: "last", shared: "last" }), + models: { + chat: { + api_id: "api-chat", + name: "Last", + limit: { output: 75 }, + options: options({ last: "last", shared: "last" }), + variants: [ + { + id: "fast", + headers: { last: "last", shared: "last" }, + }, + { + id: "slow", + headers: { slow: "slow" }, + }, + ], + }, + }, + }, + }, + }), + }), + new Config.Loaded({ + source: { type: "memory" }, + info: decode({ + providers: { + custom: { name: "Renamed" }, + }, + }), + }), + ]), + }) + + yield* plugin.add({ + ...ConfigProviderPlugin.Plugin, + effect: ConfigProviderPlugin.Plugin.effect.pipe( + Effect.provideService(Config.Service, config), + Effect.provideService(Catalog.Service, catalog), + ), + }) + + const provider = yield* catalog.provider.get(providerID) + const model = yield* catalog.model.get(providerID, modelID) + expect(provider.name).toBe("Renamed") + expect(provider.env).toEqual(["CUSTOM_API_KEY"]) + expect(provider.enabled).toEqual({ via: "custom", data: {} }) + expect(provider.endpoint).toEqual({ type: "aisdk", package: "custom-sdk", url: "https://example.test" }) + expect(provider.options.headers).toEqual({ first: "first", shared: "last", last: "last" }) + expect(model.apiID).toBe(ModelV2.ID.make("api-chat")) + expect(model.name).toBe("Last") + expect(model.capabilities).toEqual({ tools: true, input: ["text"], output: ["text"] }) + expect(model.enabled).toBe(false) + expect(model.limit).toEqual({ context: 100, output: 75 }) + expect(model.cost).toEqual([{ input: 1, output: 2, cache: { read: 0, write: 0 }, tier: undefined }]) + expect(model.options.headers).toEqual({ first: "first", shared: "last", last: "last" }) + expect(model.options.variant).toBe("retained") + expect(model.variants.map((variant) => variant.id)).toEqual([ + ModelV2.VariantID.make("fast"), + ModelV2.VariantID.make("slow"), + ]) + expect(model.variants[0]?.headers).toEqual({ first: "first", shared: "last", last: "last" }) + expect(model.variants[1]?.headers).toEqual({ slow: "slow" }) + }), + ) +}) diff --git a/packages/core/test/event.test.ts b/packages/core/test/event.test.ts index b67b2897a1..f5cce54de4 100644 --- a/packages/core/test/event.test.ts +++ b/packages/core/test/event.test.ts @@ -2,11 +2,13 @@ import { describe, expect } from "bun:test" import { Effect, Fiber, Layer, Schema, Stream } from "effect" import { EventV2 } from "@opencode-ai/core/event" import { Location } from "@opencode-ai/core/location" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { location } from "./fixture/location" import { testEffect } from "./lib/effect" const locationLayer = Layer.succeed( Location.Service, - Location.Service.of({ directory: "project", workspaceID: "workspace" }), + Location.Service.of(location({ directory: AbsolutePath.make("project"), workspaceID: "workspace" })), ) const it = testEffect(EventV2.layer.pipe(Layer.provideMerge(locationLayer))) const itWithoutLocation = testEffect(EventV2.layer) @@ -46,7 +48,7 @@ describe("EventV2", () => { expect(event.type).toBe("test.message") expect(event).not.toHaveProperty("version") expect(event.data).toEqual({ text: "hello" }) - expect(event.location).toEqual({ directory: "project", workspaceID: "workspace" }) + expect(event.location).toEqual({ directory: AbsolutePath.make("project"), workspaceID: "workspace" }) }), ) diff --git a/packages/core/test/fixture/location.ts b/packages/core/test/fixture/location.ts new file mode 100644 index 0000000000..00b3ffbd13 --- /dev/null +++ b/packages/core/test/fixture/location.ts @@ -0,0 +1,12 @@ +import { Location } from "@opencode-ai/core/location" +import { Project } from "@opencode-ai/core/project" +import { AbsolutePath } from "@opencode-ai/core/schema" + +export function location(ref: Location.Ref, input: { projectDirectory?: AbsolutePath; vcs?: Project.Vcs } = {}) { + return { + directory: ref.directory, + workspaceID: ref.workspaceID, + project: { id: Project.ID.global, directory: input.projectDirectory ?? ref.directory }, + vcs: input.vcs, + } satisfies Location.Interface +} diff --git a/packages/core/test/location.test.ts b/packages/core/test/location.test.ts new file mode 100644 index 0000000000..305083bfed --- /dev/null +++ b/packages/core/test/location.test.ts @@ -0,0 +1,38 @@ +import { describe, expect } from "bun:test" +import { Effect, Layer } from "effect" +import { Location } from "@opencode-ai/core/location" +import { Project } from "@opencode-ai/core/project" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { testEffect } from "./lib/effect" + +const ref = { directory: AbsolutePath.make("/repo/packages/app"), workspaceID: "workspace" } +const projectLayer = Layer.succeed( + Project.Service, + Project.Service.of({ + resolve: () => + Effect.succeed({ + id: Project.ID.make("project"), + directory: AbsolutePath.make("/repo"), + vcs: { type: "git", store: AbsolutePath.make("/repo/.git") }, + }), + commit: () => Effect.void, + }), +) +const it = testEffect(Location.layer(ref).pipe(Layer.provide(projectLayer))) + +describe("Location", () => { + it.effect("resolves the current project and vcs information", () => + Effect.gen(function* () { + const location = yield* Location.Service + + expect(location.directory).toBe(AbsolutePath.make("/repo/packages/app")) + expect(location.workspaceID).toBe("workspace") + expect(location.project.id).toBe(Project.ID.make("project")) + expect(location.project.directory).toBe(AbsolutePath.make("/repo")) + expect(location.vcs).toEqual({ + type: "git", + store: AbsolutePath.make("/repo/.git"), + }) + }), + ) +}) diff --git a/packages/core/test/model.test.ts b/packages/core/test/model.test.ts new file mode 100644 index 0000000000..fe97acc25a --- /dev/null +++ b/packages/core/test/model.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, test } from "bun:test" +import { Schema } from "effect" +import { ModelV2 } from "@opencode-ai/core/model" +import { ProviderV2 } from "@opencode-ai/core/provider" + +const decode = Schema.decodeUnknownSync(ModelV2.Ref) + +describe("ModelV2.Ref", () => { + test("accepts a model selection without a variant", () => { + expect(decode({ id: "claude-sonnet", providerID: "anthropic" })).toEqual({ + id: ModelV2.ID.make("claude-sonnet"), + providerID: ProviderV2.ID.make("anthropic"), + }) + }) + + test("preserves an explicit model variant", () => { + expect(decode({ id: "claude-sonnet", providerID: "anthropic", variant: "high" })).toEqual({ + id: ModelV2.ID.make("claude-sonnet"), + providerID: ProviderV2.ID.make("anthropic"), + variant: ModelV2.VariantID.make("high"), + }) + }) +}) diff --git a/packages/core/test/plugin/provider-amazon-bedrock.test.ts b/packages/core/test/plugin/provider-amazon-bedrock.test.ts index f2034eb1e3..602e85624b 100644 --- a/packages/core/test/plugin/provider-amazon-bedrock.test.ts +++ b/packages/core/test/plugin/provider-amazon-bedrock.test.ts @@ -24,8 +24,8 @@ describe("AmazonBedrockPlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(AmazonBedrockPlugin) - const load = yield* catalog.loader() - yield* load((catalog) => { + const transform = yield* catalog.transform() + yield* transform((catalog) => { const bedrock = provider("amazon-bedrock", { endpoint: { type: "aisdk", package: "@ai-sdk/amazon-bedrock" }, options: { diff --git a/packages/core/test/plugin/provider-anthropic.test.ts b/packages/core/test/plugin/provider-anthropic.test.ts index 0a1d5662d3..6cae612fd2 100644 --- a/packages/core/test/plugin/provider-anthropic.test.ts +++ b/packages/core/test/plugin/provider-anthropic.test.ts @@ -12,8 +12,8 @@ describe("AnthropicPlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(AnthropicPlugin) - const load = yield* catalog.loader() - yield* load((catalog) => { + const transform = yield* catalog.transform() + yield* transform((catalog) => { const item = provider("anthropic", { endpoint: { type: "aisdk", package: "@ai-sdk/anthropic" }, options: { headers: { Existing: "1" }, body: {}, aisdk: { provider: {}, request: {} } }, @@ -35,8 +35,8 @@ describe("AnthropicPlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(AnthropicPlugin) - const load = yield* catalog.loader() - yield* load((catalog) => catalog.provider.update(provider("openai").id, () => {})) + const transform = yield* catalog.transform() + yield* transform((catalog) => catalog.provider.update(provider("openai").id, () => {})) expect((yield* catalog.provider.get(ProviderV2.ID.openai)).options.headers["anthropic-beta"]).toBeUndefined() }), ) diff --git a/packages/core/test/plugin/provider-azure-cognitive-services.test.ts b/packages/core/test/plugin/provider-azure-cognitive-services.test.ts index 8b8baacb7e..a3837a66ad 100644 --- a/packages/core/test/plugin/provider-azure-cognitive-services.test.ts +++ b/packages/core/test/plugin/provider-azure-cognitive-services.test.ts @@ -13,8 +13,8 @@ describe("AzureCognitiveServicesPlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(AzureCognitiveServicesPlugin) - const load = yield* catalog.loader() - yield* load((catalog) => { + const transform = yield* catalog.transform() + yield* transform((catalog) => { catalog.provider.update(ProviderV2.ID.make("azure-cognitive-services"), (item) => { item.endpoint = { type: "aisdk", package: "@ai-sdk/openai-compatible" } }) @@ -37,8 +37,8 @@ describe("AzureCognitiveServicesPlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(AzureCognitiveServicesPlugin) - const load = yield* catalog.loader() - yield* load((catalog) => { + const transform = yield* catalog.transform() + yield* transform((catalog) => { const azure = provider("azure-cognitive-services", { endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible" }, }) diff --git a/packages/core/test/plugin/provider-azure.test.ts b/packages/core/test/plugin/provider-azure.test.ts index 8c8995a372..18670d6901 100644 --- a/packages/core/test/plugin/provider-azure.test.ts +++ b/packages/core/test/plugin/provider-azure.test.ts @@ -5,9 +5,12 @@ import { Catalog } from "@opencode-ai/core/catalog" import { EventV2 } from "@opencode-ai/core/event" import { Location } from "@opencode-ai/core/location" import { PluginV2 } from "@opencode-ai/core/plugin" +import { Policy } from "@opencode-ai/core/policy" import { AccountPlugin } from "@opencode-ai/core/plugin/account" import { AzurePlugin } from "@opencode-ai/core/plugin/provider/azure" import { ProviderV2 } from "@opencode-ai/core/provider" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { location } from "../fixture/location" import { testEffect } from "../lib/effect" import { fakeSelectorSdk, it, model, npmLayer, provider, withEnv } from "./provider-helper" @@ -16,7 +19,10 @@ const itWithAccount = testEffect( Layer.provideMerge(PluginV2.defaultLayer), Layer.provideMerge(AccountV2.defaultLayer), Layer.provideMerge(EventV2.defaultLayer), - Layer.provideMerge(Layer.succeed(Location.Service, Location.Service.of({ directory: "test" }))), + Layer.provide(Policy.defaultLayer), + Layer.provideMerge( + Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("test") }))), + ), Layer.provideMerge(npmLayer), ), ) @@ -28,8 +34,8 @@ describe("AzurePlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(AzurePlugin) - const load = yield* catalog.loader() - yield* load((catalog) => { + const transform = yield* catalog.transform() + yield* transform((catalog) => { catalog.provider.update(ProviderV2.ID.azure, (item) => { item.endpoint = { type: "aisdk", package: "@ai-sdk/azure" } }) @@ -45,8 +51,8 @@ describe("AzurePlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(AzurePlugin) - const load = yield* catalog.loader() - yield* load((catalog) => { + const transform = yield* catalog.transform() + yield* transform((catalog) => { const azure = provider("azure", { endpoint: { type: "aisdk", package: "@ai-sdk/azure" }, options: { headers: {}, body: {}, aisdk: { provider: { resourceName: "from-config" }, request: {} } }, @@ -94,8 +100,8 @@ describe("AzurePlugin", () => { ), }) yield* plugin.add(AzurePlugin) - const load = yield* catalog.loader() - yield* load((catalog) => { + const transform = yield* catalog.transform() + yield* transform((catalog) => { catalog.provider.update(ProviderV2.ID.azure, (item) => { item.endpoint = { type: "aisdk", package: "@ai-sdk/azure" } }) @@ -113,8 +119,8 @@ describe("AzurePlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(AzurePlugin) - const load = yield* catalog.loader() - yield* load((catalog) => { + const transform = yield* catalog.transform() + yield* transform((catalog) => { const azure = provider("azure", { endpoint: { type: "aisdk", package: "@ai-sdk/azure" }, options: { headers: {}, body: {}, aisdk: { provider: { resourceName: "" }, request: {} } }, @@ -135,8 +141,8 @@ describe("AzurePlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(AzurePlugin) - const load = yield* catalog.loader() - yield* load((catalog) => { + const transform = yield* catalog.transform() + yield* transform((catalog) => { const azure = provider("azure", { endpoint: { type: "aisdk", package: "@ai-sdk/azure" }, options: { headers: {}, body: {}, aisdk: { provider: { resourceName: " " }, request: {} } }, diff --git a/packages/core/test/plugin/provider-cerebras.test.ts b/packages/core/test/plugin/provider-cerebras.test.ts index dd8d34c7df..982b587a71 100644 --- a/packages/core/test/plugin/provider-cerebras.test.ts +++ b/packages/core/test/plugin/provider-cerebras.test.ts @@ -24,8 +24,8 @@ describe("CerebrasPlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(CerebrasPlugin) - const load = yield* catalog.loader() - yield* load((catalog) => { + const transform = yield* catalog.transform() + yield* transform((catalog) => { catalog.provider.update(ProviderV2.ID.make("cerebras"), (item) => { item.endpoint = { type: "aisdk", package: "@ai-sdk/cerebras" } item.options.headers.Existing = "1" @@ -43,8 +43,8 @@ describe("CerebrasPlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(CerebrasPlugin) - const load = yield* catalog.loader() - yield* load((catalog) => catalog.provider.update(ProviderV2.ID.make("groq"), () => {})) + const transform = yield* catalog.transform() + yield* transform((catalog) => catalog.provider.update(ProviderV2.ID.make("groq"), () => {})) expect((yield* catalog.provider.get(ProviderV2.ID.make("groq"))).options.headers).toEqual({}) }), ) diff --git a/packages/core/test/plugin/provider-cloudflare-workers-ai.test.ts b/packages/core/test/plugin/provider-cloudflare-workers-ai.test.ts index d1db7b27a1..e6c6618599 100644 --- a/packages/core/test/plugin/provider-cloudflare-workers-ai.test.ts +++ b/packages/core/test/plugin/provider-cloudflare-workers-ai.test.ts @@ -6,9 +6,12 @@ import { Location } from "@opencode-ai/core/location" import { EventV2 } from "@opencode-ai/core/event" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { Policy } from "@opencode-ai/core/policy" import { AccountPlugin } from "@opencode-ai/core/plugin/account" import { CloudflareWorkersAIPlugin } from "@opencode-ai/core/plugin/provider/cloudflare-workers-ai" import { ProviderV2 } from "@opencode-ai/core/provider" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { location } from "../fixture/location" import { testEffect } from "../lib/effect" import { fakeSelectorSdk, it, model, npmLayer, withEnv } from "./provider-helper" @@ -17,7 +20,10 @@ const itWithAccount = testEffect( Layer.provideMerge(PluginV2.defaultLayer), Layer.provideMerge(AccountV2.defaultLayer), Layer.provideMerge(EventV2.defaultLayer), - Layer.provideMerge(Layer.succeed(Location.Service, Location.Service.of({ directory: "test" }))), + Layer.provide(Policy.defaultLayer), + Layer.provideMerge( + Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("test") }))), + ), Layer.provideMerge(npmLayer), ), ) @@ -48,8 +54,8 @@ describe("CloudflareWorkersAIPlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(CloudflareWorkersAIPlugin) - const load = yield* catalog.loader() - yield* load((catalog) => + const transform = yield* catalog.transform() + yield* transform((catalog) => catalog.provider.update(ProviderV2.ID.make("cloudflare-workers-ai"), (provider) => { provider.endpoint = { type: "aisdk", package: "test-provider" } }), @@ -80,8 +86,8 @@ describe("CloudflareWorkersAIPlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(CloudflareWorkersAIPlugin) - const load = yield* catalog.loader() - yield* load((catalog) => + const transform = yield* catalog.transform() + yield* transform((catalog) => catalog.provider.update(ProviderV2.ID.make("cloudflare-workers-ai"), (provider) => { provider.endpoint = { type: "aisdk", package: "test-provider", url: "https://proxy.example/v1" } }), @@ -146,8 +152,8 @@ describe("CloudflareWorkersAIPlugin", () => { ), }) yield* plugin.add(CloudflareWorkersAIPlugin) - const load = yield* catalog.loader() - yield* load((catalog) => + const transform = yield* catalog.transform() + yield* transform((catalog) => catalog.provider.update(ProviderV2.ID.make("cloudflare-workers-ai"), (provider) => { provider.endpoint = { type: "aisdk", package: "test-provider" } }), @@ -167,8 +173,8 @@ describe("CloudflareWorkersAIPlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(CloudflareWorkersAIPlugin) - const load = yield* catalog.loader() - yield* load((catalog) => + const transform = yield* catalog.transform() + yield* transform((catalog) => catalog.provider.update(ProviderV2.ID.make("cloudflare-workers-ai"), (provider) => { provider.endpoint = { type: "aisdk", package: "test-provider" } provider.options.aisdk.provider.accountId = "configured-acct" diff --git a/packages/core/test/plugin/provider-github-copilot.test.ts b/packages/core/test/plugin/provider-github-copilot.test.ts index 51822c26ac..c07f70597a 100644 --- a/packages/core/test/plugin/provider-github-copilot.test.ts +++ b/packages/core/test/plugin/provider-github-copilot.test.ts @@ -152,8 +152,8 @@ describe("GithubCopilotPlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(GithubCopilotPlugin) - const load = yield* catalog.loader() - yield* load((catalog) => { + const transform = yield* catalog.transform() + yield* transform((catalog) => { catalog.provider.update(ProviderV2.ID.make("github-copilot"), () => {}) catalog.model.update(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5-chat-latest"), () => {}) }) @@ -168,8 +168,8 @@ describe("GithubCopilotPlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(GithubCopilotPlugin) - const load = yield* catalog.loader() - yield* load((catalog) => { + const transform = yield* catalog.transform() + yield* transform((catalog) => { catalog.provider.update(ProviderV2.ID.make("custom-copilot"), () => {}) catalog.model.update(ProviderV2.ID.make("custom-copilot"), ModelV2.ID.make("gpt-5-chat-latest"), () => {}) }) diff --git a/packages/core/test/plugin/provider-gitlab.test.ts b/packages/core/test/plugin/provider-gitlab.test.ts index e785fbbb7f..f9efa294f5 100644 --- a/packages/core/test/plugin/provider-gitlab.test.ts +++ b/packages/core/test/plugin/provider-gitlab.test.ts @@ -5,9 +5,11 @@ import { Catalog } from "@opencode-ai/core/catalog" import { EventV2 } from "@opencode-ai/core/event" import { Location } from "@opencode-ai/core/location" import { PluginV2 } from "@opencode-ai/core/plugin" +import { Policy } from "@opencode-ai/core/policy" import { AccountPlugin } from "@opencode-ai/core/plugin/account" import { GitLabPlugin } from "@opencode-ai/core/plugin/provider/gitlab" import { ProviderV2 } from "@opencode-ai/core/provider" +import { AbsolutePath } from "@opencode-ai/core/schema" import { testEffect } from "../lib/effect" import { it, model, npmLayer, withEnv } from "./provider-helper" @@ -27,12 +29,15 @@ void mock.module("gitlab-ai-provider", () => ({ })) const itWithAccount = testEffect( - Catalog.layer.pipe( - Layer.provideMerge(PluginV2.defaultLayer), - Layer.provideMerge(AccountV2.defaultLayer), - Layer.provideMerge(EventV2.defaultLayer), - Layer.provideMerge(Layer.succeed(Location.Service, Location.Service.of({ directory: "test" }))), - Layer.provideMerge(npmLayer), + Layer.mergeAll( + Catalog.defaultLayer, + PluginV2.defaultLayer, + AccountV2.defaultLayer, + EventV2.defaultLayer, + npmLayer, + ).pipe( + Layer.provide(Policy.defaultLayer), + Layer.provide(Location.defaultLayer({ directory: AbsolutePath.make("/") })), ), ) @@ -179,8 +184,8 @@ describe("GitLabPlugin", () => { ), }) yield* plugin.add(GitLabPlugin) - const load = yield* catalog.loader() - yield* load((catalog) => catalog.provider.update(ProviderV2.ID.make("gitlab"), () => {})) + const transform = yield* catalog.transform() + yield* transform((catalog) => catalog.provider.update(ProviderV2.ID.make("gitlab"), () => {})) const provider = yield* catalog.provider.get(ProviderV2.ID.make("gitlab")) yield* plugin.trigger( "aisdk.sdk", @@ -227,8 +232,8 @@ describe("GitLabPlugin", () => { ), }) yield* plugin.add(GitLabPlugin) - const load = yield* catalog.loader() - yield* load((catalog) => catalog.provider.update(ProviderV2.ID.make("gitlab"), () => {})) + const transform = yield* catalog.transform() + yield* transform((catalog) => catalog.provider.update(ProviderV2.ID.make("gitlab"), () => {})) const provider = yield* catalog.provider.get(ProviderV2.ID.make("gitlab")) yield* plugin.trigger( "aisdk.sdk", diff --git a/packages/core/test/plugin/provider-google-vertex-anthropic.test.ts b/packages/core/test/plugin/provider-google-vertex-anthropic.test.ts index 7cb2b24ffd..9d23dfefb3 100644 --- a/packages/core/test/plugin/provider-google-vertex-anthropic.test.ts +++ b/packages/core/test/plugin/provider-google-vertex-anthropic.test.ts @@ -22,8 +22,8 @@ describe("GoogleVertexAnthropicPlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(GoogleVertexAnthropicPlugin) - const load = yield* catalog.loader() - yield* load((catalog) => + const transform = yield* catalog.transform() + yield* transform((catalog) => catalog.provider.update(ProviderV2.ID.make("google-vertex-anthropic"), (provider) => { provider.endpoint = { type: "aisdk", package: "@ai-sdk/google-vertex/anthropic" } }), @@ -41,8 +41,8 @@ describe("GoogleVertexAnthropicPlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(GoogleVertexAnthropicPlugin) - const load = yield* catalog.loader() - yield* load((catalog) => + const transform = yield* catalog.transform() + yield* transform((catalog) => catalog.provider.update(ProviderV2.ID.make("google-vertex-anthropic"), (provider) => { provider.endpoint = { type: "aisdk", package: "@ai-sdk/google-vertex/anthropic" } provider.options.aisdk.provider.project = "configured-project" diff --git a/packages/core/test/plugin/provider-google-vertex.test.ts b/packages/core/test/plugin/provider-google-vertex.test.ts index 63c03c28a7..2abb342cc3 100644 --- a/packages/core/test/plugin/provider-google-vertex.test.ts +++ b/packages/core/test/plugin/provider-google-vertex.test.ts @@ -50,8 +50,8 @@ describe("GoogleVertexPlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(GoogleVertexPlugin) - const load = yield* catalog.loader() - yield* load((catalog) => + const transform = yield* catalog.transform() + yield* transform((catalog) => catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => { provider.endpoint = { type: "aisdk", @@ -89,8 +89,8 @@ describe("GoogleVertexPlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(GoogleVertexPlugin) - const load = yield* catalog.loader() - yield* load((catalog) => + const transform = yield* catalog.transform() + yield* transform((catalog) => catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => { provider.endpoint = { type: "aisdk", @@ -139,8 +139,8 @@ describe("GoogleVertexPlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(GoogleVertexPlugin) - const load = yield* catalog.loader() - yield* load((catalog) => + const transform = yield* catalog.transform() + yield* transform((catalog) => catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => { provider.endpoint = { type: "aisdk", @@ -168,8 +168,8 @@ describe("GoogleVertexPlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(GoogleVertexPlugin) - const load = yield* catalog.loader() - yield* load((catalog) => + const transform = yield* catalog.transform() + yield* transform((catalog) => catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => { provider.endpoint = { type: "aisdk", @@ -204,8 +204,8 @@ describe("GoogleVertexPlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(GoogleVertexPlugin) - const load = yield* catalog.loader() - yield* load((catalog) => + const transform = yield* catalog.transform() + yield* transform((catalog) => catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => { provider.endpoint = { type: "aisdk", package: "@ai-sdk/google-vertex" } provider.options.aisdk.provider.project = "config-project" diff --git a/packages/core/test/plugin/provider-helper.ts b/packages/core/test/plugin/provider-helper.ts index 1b8f1c65a0..a6d25ac187 100644 --- a/packages/core/test/plugin/provider-helper.ts +++ b/packages/core/test/plugin/provider-helper.ts @@ -7,11 +7,17 @@ import { EventV2 } from "@opencode-ai/core/event" import { Location } from "@opencode-ai/core/location" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { Policy } from "@opencode-ai/core/policy" import { ProviderV2 } from "@opencode-ai/core/provider" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { location } from "../fixture/location" import { testEffect } from "../lib/effect" export const fixtureProvider = new URL("./fixtures/provider-factory.ts", import.meta.url).href -const locationLayer = Layer.succeed(Location.Service, Location.Service.of({ directory: "test" })) +const locationLayer = Layer.succeed( + Location.Service, + Location.Service.of(location({ directory: AbsolutePath.make("test") })), +) export const npmLayer = Layer.succeed( Npm.Service, @@ -25,7 +31,7 @@ export const npmLayer = Layer.succeed( export const catalogLayer = Layer.succeed( Catalog.Service, Catalog.Service.of({ - loader: () => Effect.die("unexpected catalog.loader"), + transform: () => Effect.die("unexpected catalog.transform"), provider: { get: () => Effect.die("unexpected provider.get"), all: () => Effect.succeed([]), @@ -36,7 +42,6 @@ export const catalogLayer = Layer.succeed( all: () => Effect.succeed([]), available: () => Effect.succeed([]), default: () => Effect.succeed(Option.none()), - setDefault: () => Effect.die("unexpected model.setDefault"), small: () => Effect.succeed(Option.none()), }, }), @@ -46,6 +51,7 @@ export const it = testEffect( Catalog.layer.pipe( Layer.provideMerge(PluginV2.defaultLayer), Layer.provideMerge(EventV2.defaultLayer), + Layer.provide(Policy.defaultLayer), Layer.provideMerge(locationLayer), Layer.provideMerge(npmLayer), ), diff --git a/packages/core/test/plugin/provider-kilo.test.ts b/packages/core/test/plugin/provider-kilo.test.ts index cafe4c10f6..ac3cc172a2 100644 --- a/packages/core/test/plugin/provider-kilo.test.ts +++ b/packages/core/test/plugin/provider-kilo.test.ts @@ -22,8 +22,8 @@ describe("KiloPlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(KiloPlugin) - const load = yield* catalog.loader() - yield* load((catalog) => { + const transform = yield* catalog.transform() + yield* transform((catalog) => { const kilo = provider("kilo", { endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.kilo.ai/api/gateway" }, options: { headers: { Existing: "value" }, body: {}, aisdk: { provider: {}, request: {} } }, @@ -48,8 +48,8 @@ describe("KiloPlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(KiloPlugin) - const load = yield* catalog.loader() - yield* load((catalog) => { + const transform = yield* catalog.transform() + yield* transform((catalog) => { const item = provider("kilo", { endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.kilo.ai/api/gateway" }, }) @@ -74,8 +74,8 @@ describe("KiloPlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(KiloPlugin) - const load = yield* catalog.loader() - yield* load((catalog) => { + const transform = yield* catalog.transform() + yield* transform((catalog) => { const kilo = provider("kilo", { endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.kilo.ai/api/gateway" }, }) diff --git a/packages/core/test/plugin/provider-llmgateway.test.ts b/packages/core/test/plugin/provider-llmgateway.test.ts index f34b7b3fcc..980abe2258 100644 --- a/packages/core/test/plugin/provider-llmgateway.test.ts +++ b/packages/core/test/plugin/provider-llmgateway.test.ts @@ -22,8 +22,8 @@ describe("LLMGatewayPlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(LLMGatewayPlugin) - const load = yield* catalog.loader() - yield* load((catalog) => { + const transform = yield* catalog.transform() + yield* transform((catalog) => { const llmgateway = provider("llmgateway", { enabled: { via: "env", name: "LLMGATEWAY_API_KEY" }, endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.llmgateway.io/v1" }, @@ -56,8 +56,8 @@ describe("LLMGatewayPlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(LLMGatewayPlugin) - const load = yield* catalog.loader() - yield* load((catalog) => { + const transform = yield* catalog.transform() + yield* transform((catalog) => { const item = provider("llmgateway", { endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.llmgateway.io/v1" }, }) diff --git a/packages/core/test/plugin/provider-nvidia.test.ts b/packages/core/test/plugin/provider-nvidia.test.ts index 8bd9051bd8..dea0000717 100644 --- a/packages/core/test/plugin/provider-nvidia.test.ts +++ b/packages/core/test/plugin/provider-nvidia.test.ts @@ -22,8 +22,8 @@ describe("NvidiaPlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(NvidiaPlugin) - const load = yield* catalog.loader() - yield* load((catalog) => { + const transform = yield* catalog.transform() + yield* transform((catalog) => { const nvidia = provider("nvidia", { endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://integrate.api.nvidia.com/v1" }, options: { headers: { Existing: "value" }, body: {}, aisdk: { provider: {}, request: {} } }, @@ -49,8 +49,8 @@ describe("NvidiaPlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(NvidiaPlugin) - const load = yield* catalog.loader() - yield* load((catalog) => { + const transform = yield* catalog.transform() + yield* transform((catalog) => { const item = provider("nvidia", { endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://integrate.api.nvidia.com/v1" }, options: { headers: {}, body: {}, aisdk: { provider: {}, request: {} } }, @@ -74,8 +74,8 @@ describe("NvidiaPlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(NvidiaPlugin) - const load = yield* catalog.loader() - yield* load((catalog) => { + const transform = yield* catalog.transform() + yield* transform((catalog) => { const item = provider("nvidia", { endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://integrate.api.nvidia.com/v1" }, options: { diff --git a/packages/core/test/plugin/provider-openai.test.ts b/packages/core/test/plugin/provider-openai.test.ts index 93e451e018..b65beb3c71 100644 --- a/packages/core/test/plugin/provider-openai.test.ts +++ b/packages/core/test/plugin/provider-openai.test.ts @@ -77,8 +77,8 @@ describe("OpenAIPlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(OpenAIPlugin) - const load = yield* catalog.loader() - yield* load((catalog) => { + const transform = yield* catalog.transform() + yield* transform((catalog) => { const item = provider("openai", { endpoint: { type: "aisdk", package: "@ai-sdk/openai" } }) catalog.provider.update(item.id, (draft) => { draft.endpoint = item.endpoint @@ -96,8 +96,8 @@ describe("OpenAIPlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(OpenAIPlugin) - const load = yield* catalog.loader() - yield* load((catalog) => { + const transform = yield* catalog.transform() + yield* transform((catalog) => { const item = provider("custom-openai") catalog.provider.update(item.id, () => {}) catalog.model.update(item.id, ModelV2.ID.make("gpt-5-chat-latest"), () => {}) diff --git a/packages/core/test/plugin/provider-opencode.test.ts b/packages/core/test/plugin/provider-opencode.test.ts index 3f59a34977..4f4dd6abef 100644 --- a/packages/core/test/plugin/provider-opencode.test.ts +++ b/packages/core/test/plugin/provider-opencode.test.ts @@ -5,11 +5,17 @@ import { Location } from "@opencode-ai/core/location" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" import { OpencodePlugin } from "@opencode-ai/core/plugin/provider/opencode" +import { Policy } from "@opencode-ai/core/policy" import { ProviderV2 } from "@opencode-ai/core/provider" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { location } from "../fixture/location" import { it, model, provider, withEnv } from "./provider-helper" const cost = (input: number, output = 0) => [{ input, output, cache: { read: 0, write: 0 } }] -const locationLayer = Layer.succeed(Location.Service, Location.Service.of({ directory: "test" })) +const locationLayer = Layer.succeed( + Location.Service, + Location.Service.of(location({ directory: AbsolutePath.make("test") })), +) describe("OpencodePlugin", () => { it.effect("uses a public key and disables paid models without credentials", () => @@ -18,8 +24,8 @@ describe("OpencodePlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(OpencodePlugin) - const load = yield* catalog.loader() - yield* load((catalog) => { + const transform = yield* catalog.transform() + yield* transform((catalog) => { const item = provider("opencode") catalog.provider.update(item.id, () => {}) const paid = model("opencode", "paid", { cost: cost(1) }) @@ -39,8 +45,8 @@ describe("OpencodePlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(OpencodePlugin) - const load = yield* catalog.loader() - yield* load((catalog) => { + const transform = yield* catalog.transform() + yield* transform((catalog) => { const item = provider("opencode") catalog.provider.update(item.id, () => {}) const free = model("opencode", "free", { cost: cost(0) }) @@ -60,8 +66,8 @@ describe("OpencodePlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(OpencodePlugin) - const load = yield* catalog.loader() - yield* load((catalog) => { + const transform = yield* catalog.transform() + yield* transform((catalog) => { const item = provider("opencode") catalog.provider.update(item.id, () => {}) const outputOnly = model("opencode", "output-only", { cost: cost(0, 1) }) @@ -81,8 +87,8 @@ describe("OpencodePlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(OpencodePlugin) - const load = yield* catalog.loader() - yield* load((catalog) => { + const transform = yield* catalog.transform() + yield* transform((catalog) => { const item = provider("opencode") catalog.provider.update(item.id, () => {}) const paid = model("opencode", "paid", { cost: cost(1) }) @@ -102,8 +108,8 @@ describe("OpencodePlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(OpencodePlugin) - const load = yield* catalog.loader() - yield* load((catalog) => { + const transform = yield* catalog.transform() + yield* transform((catalog) => { const item = provider("opencode", { env: ["CUSTOM_OPENCODE_API_KEY"] }) catalog.provider.update(item.id, (draft) => { draft.env = [...item.env] @@ -125,8 +131,8 @@ describe("OpencodePlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(OpencodePlugin) - const load = yield* catalog.loader() - yield* load((catalog) => { + const transform = yield* catalog.transform() + yield* transform((catalog) => { const item = provider("opencode", { options: { headers: {}, @@ -157,8 +163,8 @@ describe("OpencodePlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(OpencodePlugin) - const load = yield* catalog.loader() - yield* load((catalog) => { + const transform = yield* catalog.transform() + yield* transform((catalog) => { const item = provider("opencode", { enabled: { via: "account", service: "opencode" } }) catalog.provider.update(item.id, (draft) => { draft.enabled = item.enabled @@ -180,8 +186,8 @@ describe("OpencodePlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(OpencodePlugin) - const load = yield* catalog.loader() - yield* load((catalog) => { + const transform = yield* catalog.transform() + yield* transform((catalog) => { const item = provider("openai") catalog.provider.update(item.id, () => {}) const paid = model("openai", "paid", { cost: cost(1) }) @@ -200,8 +206,8 @@ describe("OpencodePlugin", () => { const catalog = yield* Catalog.Service const providerID = ProviderV2.ID.opencode - const load = yield* catalog.loader() - yield* load((catalog) => { + const transform = yield* catalog.transform() + yield* transform((catalog) => { catalog.provider.update(providerID, () => {}) catalog.model.update(providerID, ModelV2.ID.make("cheap-mini"), (model) => { model.capabilities.input = ["text"] @@ -220,6 +226,8 @@ describe("OpencodePlugin", () => { const selected = yield* catalog.model.small(providerID) expect(Option.getOrUndefined(selected)?.id).toBe(ModelV2.ID.make("gpt-5-nano")) - }).pipe(Effect.provide(Catalog.defaultLayer.pipe(Layer.provide(locationLayer)))), + }).pipe( + Effect.provide(Catalog.defaultLayer.pipe(Layer.provide(Policy.defaultLayer), Layer.provide(locationLayer))), + ), ) }) diff --git a/packages/core/test/plugin/provider-openrouter.test.ts b/packages/core/test/plugin/provider-openrouter.test.ts index a60323b8d3..d19575a9bb 100644 --- a/packages/core/test/plugin/provider-openrouter.test.ts +++ b/packages/core/test/plugin/provider-openrouter.test.ts @@ -23,8 +23,8 @@ describe("OpenRouterPlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(OpenRouterPlugin) - const load = yield* catalog.loader() - yield* load((catalog) => { + const transform = yield* catalog.transform() + yield* transform((catalog) => { const openrouter = provider("openrouter", { endpoint: { type: "aisdk", package: "@openrouter/ai-sdk-provider" }, options: { headers: { Existing: "value" }, body: {}, aisdk: { provider: {}, request: {} } }, @@ -75,8 +75,8 @@ describe("OpenRouterPlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(OpenRouterPlugin) - const load = yield* catalog.loader() - yield* load((catalog) => { + const transform = yield* catalog.transform() + yield* transform((catalog) => { const openrouter = provider("openrouter", { endpoint: { type: "aisdk", package: "@openrouter/ai-sdk-provider" }, }) @@ -108,8 +108,8 @@ describe("OpenRouterPlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(OpenRouterPlugin) - const load = yield* catalog.loader() - yield* load((catalog) => { + const transform = yield* catalog.transform() + yield* transform((catalog) => { catalog.provider.update(ProviderV2.ID.make("custom-openrouter"), () => {}) catalog.model.update(ProviderV2.ID.make("custom-openrouter"), ModelV2.ID.make("gpt-5-chat-latest"), () => {}) }) diff --git a/packages/core/test/plugin/provider-vercel.test.ts b/packages/core/test/plugin/provider-vercel.test.ts index f6d9efd192..ba34281196 100644 --- a/packages/core/test/plugin/provider-vercel.test.ts +++ b/packages/core/test/plugin/provider-vercel.test.ts @@ -12,8 +12,8 @@ describe("VercelPlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(VercelPlugin) - const load = yield* catalog.loader() - yield* load((catalog) => { + const transform = yield* catalog.transform() + yield* transform((catalog) => { const item = provider("vercel", { endpoint: { type: "aisdk", package: "@ai-sdk/vercel" }, options: { headers: { Existing: "1" }, body: {}, aisdk: { provider: {}, request: {} } }, @@ -36,8 +36,8 @@ describe("VercelPlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(VercelPlugin) - const load = yield* catalog.loader() - yield* load((catalog) => { + const transform = yield* catalog.transform() + yield* transform((catalog) => { const item = provider("vercel", { endpoint: { type: "aisdk", package: "@ai-sdk/vercel" } }) catalog.provider.update(item.id, (draft) => { draft.endpoint = item.endpoint @@ -69,8 +69,8 @@ describe("VercelPlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(VercelPlugin) - const load = yield* catalog.loader() - yield* load((catalog) => catalog.provider.update(provider("gateway").id, () => {})) + const transform = yield* catalog.transform() + yield* transform((catalog) => catalog.provider.update(provider("gateway").id, () => {})) expect((yield* catalog.provider.get(ProviderV2.ID.make("gateway"))).options.headers).toEqual({}) }), ) diff --git a/packages/core/test/plugin/provider-zenmux.test.ts b/packages/core/test/plugin/provider-zenmux.test.ts index 71067a5a1e..c5899627cf 100644 --- a/packages/core/test/plugin/provider-zenmux.test.ts +++ b/packages/core/test/plugin/provider-zenmux.test.ts @@ -22,8 +22,8 @@ describe("ZenmuxPlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(ZenmuxPlugin) - const load = yield* catalog.loader() - yield* load((catalog) => { + const transform = yield* catalog.transform() + yield* transform((catalog) => { const item = provider("zenmux", { endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://zenmux.ai/api/v1" }, }) @@ -42,8 +42,8 @@ describe("ZenmuxPlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(ZenmuxPlugin) - const load = yield* catalog.loader() - yield* load((catalog) => { + const transform = yield* catalog.transform() + yield* transform((catalog) => { const item = provider("zenmux", { endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://zenmux.ai/api/v1" }, options: { headers: { Existing: "value" }, body: {}, aisdk: { provider: {}, request: {} } }, @@ -67,8 +67,8 @@ describe("ZenmuxPlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(ZenmuxPlugin) - const load = yield* catalog.loader() - yield* load((catalog) => { + const transform = yield* catalog.transform() + yield* transform((catalog) => { const item = provider("zenmux", { endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://zenmux.ai/api/v1" }, options: { @@ -95,8 +95,8 @@ describe("ZenmuxPlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(ZenmuxPlugin) - const load = yield* catalog.loader() - yield* load((catalog) => { + const transform = yield* catalog.transform() + yield* transform((catalog) => { const item = provider("openrouter", { options: { headers: { "HTTP-Referer": "https://example.com/", "X-Title": "custom-title" }, diff --git a/packages/core/test/policy.test.ts b/packages/core/test/policy.test.ts new file mode 100644 index 0000000000..c331b5585f --- /dev/null +++ b/packages/core/test/policy.test.ts @@ -0,0 +1,83 @@ +import { describe, expect } from "bun:test" +import { Effect, Layer } from "effect" +import { Location } from "@opencode-ai/core/location" +import { Policy } from "@opencode-ai/core/policy" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { location } from "./fixture/location" +import { testEffect } from "./lib/effect" + +const it = testEffect( + Policy.defaultLayer.pipe( + Layer.provide( + Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("test") }))), + ), + ), +) + +describe("Policy", () => { + it.effect("returns the caller's fallback when no statement matches", () => + Effect.gen(function* () { + const policy = yield* Policy.Service + + expect(yield* policy.evaluate("provider.use", "anthropic", "allow")).toBe("allow") + expect(yield* policy.evaluate("provider.use", "anthropic", "deny")).toBe("deny") + }), + ) + + it.effect("evaluates wildcard provider rules in written order", () => + Effect.gen(function* () { + const policy = yield* Policy.Service + yield* policy.load([ + new Policy.Info({ + effect: "deny", + action: "provider.*", + resource: "*", + }), + new Policy.Info({ + effect: "allow", + action: "provider.use", + resource: "anthropic", + }), + ]) + + expect(yield* policy.evaluate("provider.use", "anthropic", "allow")).toBe("allow") + expect(yield* policy.evaluate("provider.use", "openai", "allow")).toBe("deny") + }), + ) + + it.effect("matches action and resource independently", () => + Effect.gen(function* () { + const policy = yield* Policy.Service + yield* policy.load([ + new Policy.Info({ + effect: "deny", + action: "provider.*", + resource: "company-*", + }), + ]) + + expect(yield* policy.evaluate("provider.use", "company-stable", "allow")).toBe("deny") + expect(yield* policy.evaluate("plugin.load", "company-stable", "allow")).toBe("allow") + }), + ) + + it.effect("uses the last matching loaded statement", () => + Effect.gen(function* () { + const policy = yield* Policy.Service + yield* policy.load([ + new Policy.Info({ + effect: "allow", + action: "provider.use", + resource: "openai", + }), + new Policy.Info({ + effect: "deny", + action: "provider.use", + resource: "openai", + }), + ]) + + expect(yield* policy.evaluate("provider.use", "openai", "allow")).toBe("deny") + }), + ) +}) diff --git a/packages/core/test/project.test.ts b/packages/core/test/project.test.ts index c5b96b6389..b1ae5b45c3 100644 --- a/packages/core/test/project.test.ts +++ b/packages/core/test/project.test.ts @@ -49,7 +49,7 @@ describe("ProjectV2.resolve", () => { const result = yield* project.resolve(abs(tmp.path)) expect(result.id).toBe(Project.ID.make("global")) - expect(path.resolve(result.directory)).toBe(path.resolve(tmp.path)) + expect(path.resolve(result.directory)).toBe(path.parse(tmp.path).root) expect(result.previous).toBeUndefined() expect(result.vcs).toBeUndefined() }), diff --git a/packages/opencode/src/cli/cmd/debug/v2.ts b/packages/opencode/src/cli/cmd/debug/v2.ts index 56866a0e02..aab7018982 100644 --- a/packages/opencode/src/cli/cmd/debug/v2.ts +++ b/packages/opencode/src/cli/cmd/debug/v2.ts @@ -3,6 +3,7 @@ import { Effect, Option } from "effect" import { Catalog } from "@opencode-ai/core/catalog" import { LocationServiceMap } from "@opencode-ai/core/location-layer" import { PluginBoot } from "@opencode-ai/core/plugin/boot" +import { AbsolutePath } from "@opencode-ai/core/schema" import { effectCmd } from "../../effect-cmd" export const V2Command = effectCmd({ @@ -37,7 +38,7 @@ export const V2Command = effectCmd({ Effect.withSpan("Cli.debug.v2"), Effect.provide( LocationServiceMap.get({ - directory: process.cwd(), + directory: AbsolutePath.make(process.cwd()), }), ), Effect.provide(LocationServiceMap.layer), diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 456d6c3ee3..8dc8c6ee54 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -43,6 +43,7 @@ import { ConfigSkills } from "./skills" import { ConfigVariable } from "./variable" import { Npm } from "@opencode-ai/core/npm" import { withTransientReadRetry } from "@/util/effect-http-client" +import { ConfigExperimental } from "@opencode-ai/core/config/experimental" const log = Log.create({ service: "config" }) @@ -301,6 +302,9 @@ export const Info = Schema.Struct({ mcp_timeout: Schema.optional(PositiveInt).annotate({ description: "Timeout in milliseconds for model context protocol (MCP) requests", }), + policies: Schema.optional(Schema.mutable(Schema.Array(ConfigExperimental.Policy))).annotate({ + description: "Policy statements applied to supported resources, such as provider access", + }), }), ), }).annotate({ identifier: "Config" }) diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/location.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/location.ts index f2a9a33557..c9b21b5adf 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/location.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/location.ts @@ -1,6 +1,7 @@ import { Catalog } from "@opencode-ai/core/catalog" import { Location } from "@opencode-ai/core/location" import { LocationServiceMap } from "@opencode-ai/core/location-layer" +import { AbsolutePath } from "@opencode-ai/core/schema" import { PluginBoot } from "@opencode-ai/core/plugin/boot" import { Effect, Layer, Schema } from "effect" import { HttpServerRequest } from "effect/unstable/http" @@ -40,7 +41,9 @@ export class V2LocationMiddleware extends HttpApiMiddleware.Service< function ref(request: HttpServerRequest.HttpServerRequest): Location.Ref { const query = new URL(request.url, "http://localhost").searchParams return { - directory: query.get("location[directory]") || request.headers["x-opencode-directory"] || process.cwd(), + directory: AbsolutePath.make( + query.get("location[directory]") || request.headers["x-opencode-directory"] || process.cwd(), + ), workspaceID: query.get("location[workspace]") || request.headers["x-opencode-workspace"], } } diff --git a/packages/opencode/src/v2/session.ts b/packages/opencode/src/v2/session.ts index 5e477cc8a3..551f030ffc 100644 --- a/packages/opencode/src/v2/session.ts +++ b/packages/opencode/src/v2/session.ts @@ -166,7 +166,7 @@ export const layer = Layer.effect( ? { id: ModelV2.ID.make(row.model.id), providerID: ProviderV2.ID.make(row.model.providerID), - variant: ModelV2.VariantID.make(row.model.variant ?? "default"), + variant: row.model.variant ? ModelV2.VariantID.make(row.model.variant) : undefined, } : undefined, cost: row.cost, diff --git a/packages/opencode/test/provider/header-timeout.test.ts b/packages/opencode/test/provider/header-timeout.test.ts index 543bb5f30d..8860c39c32 100644 --- a/packages/opencode/test/provider/header-timeout.test.ts +++ b/packages/opencode/test/provider/header-timeout.test.ts @@ -3,9 +3,8 @@ import { createServer, type Server } from "node:http" import { streamText } from "ai" import { Effect, Layer } from "effect" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" -import { disposeAllInstances, provideTmpdirInstance, provideTmpdirServer } from "../fixture/fixture" +import { disposeAllInstances, provideTmpdirInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" -import { reply, TestLLMServer } from "../lib/llm-server" import { testProviderConfig } from "../lib/test-provider" import { Env } from "@/env" import { Plugin } from "@/plugin" @@ -22,70 +21,66 @@ const it = testEffect( Provider.defaultLayer, Env.defaultLayer, Plugin.defaultLayer, - TestLLMServer.layer, CrossSpawnSpawner.defaultLayer, ), ) it.live("headerTimeout does not abort delayed SSE body after headers arrive", () => - provideTmpdirServer( - ({ llm }) => - Effect.gen(function* () { - yield* llm.push(reply().wait(Bun.sleep(250)).text("late").stop()) + Effect.gen(function* () { + const server = yield* Effect.acquireRelease( + Effect.promise(() => delayedBodyServer(250)), + (server) => Effect.sync(() => server.server.close()), + ) - const provider = yield* Provider.Service - const model = yield* provider.getModel(ProviderID.make("test"), ModelID.make("test-model")) - const result = streamText({ - model: yield* provider.getLanguage(model), - messages: [{ role: "user", content: "hello" }], - }) + yield* provideTmpdirInstance( + () => + Effect.gen(function* () { + const provider = yield* Provider.Service + const model = yield* provider.getModel(ProviderID.make("test"), ModelID.make("test-model")) + const result = streamText({ + model: yield* provider.getLanguage(model), + messages: [{ role: "user", content: "hello" }], + }) - expect(yield* Effect.promise(() => result.text)).toBe("late") - }), - { - config: (url) => { - const config = testProviderConfig(url) - return { - ...config, - provider: { - test: { - ...config.provider.test, - options: { ...config.provider.test.options, headerTimeout: 50 }, - }, - }, - } - }, - }, - ), + expect(yield* Effect.promise(() => result.text)).toBe("late") + }), + { config: providerConfig(server.url, { headerTimeout: 50 }) }, + ) + }), ) it.live("chunkTimeout raises a response stream error when SSE body stalls", () => - provideTmpdirServer( - ({ llm }) => - Effect.gen(function* () { - yield* llm.push(reply().wait(Bun.sleep(250)).text("late").stop()) + Effect.gen(function* () { + const server = yield* Effect.acquireRelease( + Effect.promise(() => delayedBodyServer(250)), + (server) => Effect.sync(() => server.server.close()), + ) - const provider = yield* Provider.Service - const model = yield* provider.getModel(ProviderID.make("test"), ModelID.make("test-model")) - const result = streamText({ - model: yield* provider.getLanguage(model), - onError() {}, - messages: [{ role: "user", content: "hello" }], - }) + yield* provideTmpdirInstance( + () => + Effect.gen(function* () { + const provider = yield* Provider.Service + const model = yield* provider.getModel(ProviderID.make("test"), ModelID.make("test-model")) + const result = streamText({ + model: yield* provider.getLanguage(model), + onError() {}, + messages: [{ role: "user", content: "hello" }], + }) - const error = yield* Effect.promise(async () => { - try { - for await (const part of result.fullStream) { - if (part.type === "error") return part.error + const error = yield* Effect.promise(async () => { + try { + for await (const part of result.fullStream) { + if (part.type === "error") return part.error + } + } catch (error) { + return error } - } catch (error) { - return error - } - }) - expect(error).toBeInstanceOf(ProviderError.ResponseStreamError) - }), - { config: (url) => providerConfig(url, { chunkTimeout: 50 }) }, - ), + }) + expect(error).toBeInstanceOf(ProviderError.ResponseStreamError) + }), + { config: providerConfig(server.url, { chunkTimeout: 50 }) }, + ) + }), ) it.live("headerTimeout aborts when response headers do not arrive", () => @@ -205,6 +200,20 @@ async function delayedHeaderServer(delay: number): Promise<{ server: Server; url return { server, url: `http://127.0.0.1:${address.port}` } } +async function delayedBodyServer(delay: number): Promise<{ server: Server; url: string }> { + const server = createServer((_, res) => { + res.writeHead(200, { "content-type": "text/event-stream" }) + res.flushHeaders() + setTimeout(() => { + res.end('data: {"choices":[{"delta":{"content":"late"}}]}\n\ndata: [DONE]\n\n') + }, delay) + }) + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)) + const address = server.address() + if (!address || typeof address === "string") throw new Error("server did not bind to a TCP port") + return { server, url: `http://127.0.0.1:${address.port}` } +} + function withAuthContent(self: Effect.Effect, value: Record = defaultAuthContent()) { return Effect.acquireUseRelease( Effect.sync(() => { diff --git a/packages/opencode/test/session/llm-native-recorded.test.ts b/packages/opencode/test/session/llm-native-recorded.test.ts index 19d8f6f42c..33e96d1217 100644 --- a/packages/opencode/test/session/llm-native-recorded.test.ts +++ b/packages/opencode/test/session/llm-native-recorded.test.ts @@ -1,6 +1,7 @@ import { NodeFileSystem } from "@effect/platform-node" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { ModelsDev } from "@opencode-ai/core/models-dev" +import { LocationServiceMap } from "@opencode-ai/core/location-layer" import { HttpRecorder, Redactor } from "@opencode-ai/http-recorder" import { describe, expect, test } from "bun:test" import { tool, type ModelMessage, type JSONValue } from "ai" @@ -276,6 +277,7 @@ function recordedNativeLLMLayer(scenario: RecordedScenario) { Layer.provide(Plugin.defaultLayer), Layer.provide(ModelsDev.defaultLayer), Layer.provide(RuntimeFlags.defaultLayer), + Layer.provide(LocationServiceMap.layer), ) // Only the HTTP client is recorded; RequestExecutor and the opencode LLM stack remain real. const recordedClient = LLMClient.layer.pipe( diff --git a/packages/web/astro.config.mjs b/packages/web/astro.config.mjs index 110c8ce919..78b3587817 100644 --- a/packages/web/astro.config.mjs +++ b/packages/web/astro.config.mjs @@ -259,6 +259,7 @@ export default defineConfig({ "commands", "formatters", "permissions", + "policies", "lsp", "mcp-servers", "acp", diff --git a/packages/web/src/content/docs/config.mdx b/packages/web/src/content/docs/config.mdx index b5b492f76d..346ce63805 100644 --- a/packages/web/src/content/docs/config.mdx +++ b/packages/web/src/content/docs/config.mdx @@ -393,6 +393,29 @@ You can also configure [local models](/docs/models#local). [Learn more](/docs/mo --- +### Policies + +Use the `experimental.policies` option to allow or deny OpenCode actions on configured resources. Currently, policies can control which providers OpenCode may use. + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "experimental": { + "policies": [ + { + "effect": "deny", + "action": "provider.use", + "resource": "openai" + } + ] + } +} +``` + +[Learn more about policies here](/docs/policies). + +--- + ### Image attachments OpenCode normalizes image attachments before sending them to the model. By default, images are resized when they exceed `2000x2000` pixels or `5242880` base64 bytes. diff --git a/packages/web/src/content/docs/policies.mdx b/packages/web/src/content/docs/policies.mdx new file mode 100644 index 0000000000..0f059562e2 --- /dev/null +++ b/packages/web/src/content/docs/policies.mdx @@ -0,0 +1,137 @@ +--- +title: Policies +description: Control which configured resources OpenCode may use. +--- + +Policies control whether OpenCode may perform an action on a named resource. This feature is experimental and is configured with the `experimental.policies` array in `opencode.json`. + +Policies are separate from [permissions](/docs/permissions). Permissions control what tools can do during a session, while policies control whether OpenCode may use a resource such as an LLM provider. + +--- + +## Configuration + +Each policy statement has three fields: + +- `effect` - Either `"allow"` or `"deny"`. +- `action` - The operation being controlled. +- `resource` - The resource ID or wildcard pattern the statement applies to. + +For example, deny use of the `openai` provider: + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "experimental": { + "policies": [ + { + "effect": "deny", + "action": "provider.use", + "resource": "openai" + } + ] + } +} +``` + +A provider denied by policy is not available for model selection or model use, even if it has credentials or is otherwise configured correctly. + +--- + +## Available Policies + +OpenCode currently supports one policy action: + +| Action | Resource | Description | +| -------------- | ------------------------------ | ------------------------------------------ | +| `provider.use` | Provider ID, such as `openai` | Allow or deny use of an LLM provider. | + +More policy actions may be added in the future. + +--- + +## Matching + +The `resource` field supports wildcard matching. Use `*` to match zero or more characters and `?` to match one character. + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "experimental": { + "policies": [ + { + "effect": "deny", + "action": "provider.use", + "resource": "company-*" + } + ] + } +} +``` + +This denies providers such as `company-us` and `company-eu`. + +--- + +## Rule Order + +When multiple statements match, the last matching statement wins. Put broad rules first, then more specific exceptions after them. + +For example, allow only Anthropic: + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "experimental": { + "policies": [ + { + "effect": "deny", + "action": "provider.use", + "resource": "*" + }, + { + "effect": "allow", + "action": "provider.use", + "resource": "anthropic" + } + ] + } +} +``` + +If no policy matches a provider, provider use is allowed by default. + +Policies may be set in both your global config and project config. If policies from both locations match the same provider, your global policy takes priority over the project policy. This prevents a repository from re-enabling a provider that you deny globally. + +--- + +## Provider Lists + +Use policies instead of the older `disabled_providers` and `enabled_providers` settings when controlling provider access. + +To replace `disabled_providers`: + +```json title="opencode.json" +{ + "experimental": { + "policies": [ + { "effect": "deny", "action": "provider.use", "resource": "openai" }, + { "effect": "deny", "action": "provider.use", "resource": "google" } + ] + } +} +``` + +To replace `enabled_providers`, deny all providers first and allow the selected providers after it: + +```json title="opencode.json" +{ + "experimental": { + "policies": [ + { "effect": "deny", "action": "provider.use", "resource": "*" }, + { "effect": "allow", "action": "provider.use", "resource": "anthropic" }, + { "effect": "allow", "action": "provider.use", "resource": "openai" } + ] + } +} +``` diff --git a/specs/v2/catalog-config-plugin-lifecycle.md b/specs/v2/catalog-config-plugin-lifecycle.md new file mode 100644 index 0000000000..0781283657 --- /dev/null +++ b/specs/v2/catalog-config-plugin-lifecycle.md @@ -0,0 +1,326 @@ +# Catalog / Config / Plugin Lifecycle Options + +We need to choose where provider/model inputs live and how visible catalog state changes after boot. The designs below compare config, models.dev, auth, plugin activation/disablement, config edits, and policy changes under each option. + +## Scenarios + +- Initial load: a location opens, built-in/configured plugins activate, and the first visible catalog is constructed. +- Config: authored provider/model definitions and overrides. +- models.dev: remote provider/model data refreshed on a timer. +- Auth: active credentials enable/configure providers and can later disappear. +- Plugin activation: a plugin starts contributing while the location is open. +- Plugin disablement: a plugin stops contributing and its influence must disappear. +- Config edit: authored configuration changes while the location is open. +- Policy: allowed/denied provider selection changes after providers exist. + +## A. Config Transforms, Service Reload + +`Config` merges its ordered documents and then runs ordered, replayable plugin transforms. Each transform is a callback receiving `Draft` and may mutate any config field. + +```ts +type ConfigTransform = (config: Draft) => void + +const transform = yield * Config.transform() + +yield * + transform((config) => { + config.providers ??= {} + config.providers.acme = { + /* ... */ + } + config.model = "acme/code" + config.permissions = [ + /* ... */ + ] + }) +``` + +Because a transform can mutate any part of config, a transform change cannot safely trigger only `Catalog.reload()` or any other granular subset. Every service derived from config must reload in place from the newly transformed config. + +```ts +const transform = yield* Config.transform() +yield* transform((draft) => mutateAnyConfigField(draft)) + → Reload.all() + → Policy.reload() + → Catalog.reload() + → Agent.reload() + → MCP.reload() + → other config-consuming services reload +``` + +### Initial Load + +Configured plugin installation/updates should not block location readiness. Build an initial snapshot from authored config and fast built-ins, then activate slow plugins in the background and coalesce their resulting reload requests. + +```ts +LocationServiceMap.get(ref) + → build location layer + → Config.layer reads authored documents + → merge authored documents + → run currently active Config transforms + → Policy.layer reads transformed Config + → Catalog.layer reads transformed Config + → materialize baseline provider/model catalog + → PluginBoot baseline ready + → Frontend.fetchCatalog() + +PluginBoot background fiber + → install/update plugin packages concurrently + → activate completed plugins + → Config.transform() + → transform(updateConfig) + → ReloadScheduler.request() + → debounce short burst of completed activations + → Reload.all() + → Config.get() + → run newly active Config transforms + → Catalog.reload() + → Catalog.Event.Updated + → Frontend.refetchCatalog() +``` + +The initial layer build is not a reload. `Reload.all()` only runs after the live location changes, such as a background plugin becoming active or a config source changing. Debouncing reduces repeated full-service reloads when multiple plugins complete near each other; each batch still reloads every config-consuming service because a config transform may mutate any field. + +### Config + +```ts +config file loaded + → config source/watch trigger records new documents + → Reload.all() + → Policy.reload() + → Catalog.reload() + → Catalog.Event.Updated + → Frontend.refetchCatalog() +``` + +### models.dev + +```ts +timer fires + → ModelsDevPlugin.refresh() + → ModelsDev.get() + → transform(applyModelsDevToConfig) + → Reload.all() + → Policy.reload() + → Catalog.reload() + → Catalog.Event.Updated + → Frontend.refetchCatalog() +``` + +`Catalog` does not know about `ModelsDev`; the plugin transforms config before catalog reads it. + +### Auth + +```ts +Account.switched(providerID) + → AuthPlugin.refresh(providerID) + → Account.active(providerID) + → transform(applyAuthToConfig) + → Reload.all() + → Policy.reload() + → Catalog.reload() + → Catalog.Event.Updated + → Frontend.refetchCatalog() +``` + +### Plugin Activation + +```ts +Plugin.activate("acme-models") + → Config.transform() + → transform(applyAcmeConfig) + → Reload.all() + → Policy.reload() + → Catalog.reload() + → Catalog.Event.Updated + → Frontend.refetchCatalog() +``` + +### Plugin Disablement + +```ts +Plugin.disable("company-naming") + → close plugin scope + → Config internally unregisters transform in finalizer + → Reload.all() + → Policy.reload() + → Catalog.reload() + → sonnet.name = "Sonnet" + → Catalog.Event.Updated + → Frontend.refetchCatalog() +``` + +### Config Edit + +```ts +file watcher sees edit + → config source/watch trigger records updated documents + → Reload.all() + → Policy.reload() + → Catalog.reload() + → Catalog.Event.Updated + → Frontend.refetchCatalog() +``` + +### Policy + +```ts +policy config changes + → config source/watch trigger records updated documents + → Reload.all() + → Policy.reload() + → Catalog.reload() + → apply updated policy + → Catalog.Event.Updated + → Frontend.refetchCatalog() +``` + +### Tradeoffs + +- A plugin receives `Draft`, can inspect preceding config state, and can mutate arbitrary config fields through a replayable transform. +- Plugin disablement removes its config transform and lets services rematerialize without manual undo. +- models.dev and auth become config transforms rather than catalog dependencies. +- `Config` owns merge/order semantics for fields visible to transforms. +- Granular service reload is not safe because a config transform can mutate anything; every config-consuming service reloads after any transform change. +- `Catalog` depends on provider/model config semantics and is part of that full service reload. +- One reload produces at most one `Catalog.Event.Updated` notification. +- Deferred plugin activation avoids blocking readiness, but plugin completions may cause repeated full-service reload batches during startup. + +## B. Catalog Transforms + +Plugins register replayable catalog transforms. Each transform receives a `Catalog.Editor` whose helper methods mutate a private catalog draft; `Catalog` rematerializes visible records from its active transforms. + +```ts +interface Catalog { + transform(): Effect.Effect< + (update: (catalog: Catalog.Editor) => void) => Effect.Effect, + never, + Scope.Scope + > +} +``` + +```ts +const transform = yield* Catalog.transform() +yield* transform(update) + → replace this transform callback + → apply active transforms in registration order + → apply policy + → commit diff + → Event.publish(Catalog.Event.Updated) + → Frontend.refetchCatalog() +``` + +### Initial Load + +Configured plugin installation/updates should not block location readiness. Build an initial catalog from immediately available sources, then activate slow plugins in the background and coalesce refresh requests. + +```ts +LocationServiceMap.get(ref) + → build location layer + → Catalog.layer creates empty catalog state + → PluginBoot.layer activates immediately available plugins + → ConfigProviderPlugin installs Catalog.transform() + → ModelsDevPlugin installs Catalog.transform() + → AuthPlugin installs Catalog.transform() + → Catalog.layer applies active transforms during boot + → apply policy + → materialize baseline provider/model catalog + → PluginBoot baseline ready + → Frontend.fetchCatalog() + +PluginBoot background fiber + → install/update plugin packages concurrently + → activate completed plugins + → Catalog.transform() + → transform(updateCatalog) + → Catalog internally rebuilds + → Catalog.Event.Updated + → Frontend.refetchCatalog() +``` + +Each completed plugin activation rebuilds catalog when it calls its transform. Debouncing plugin completions would require adding an explicit batch/suspend-rebuild mechanism; it does not arise from the transform interface itself. + +### Config + +```ts +config file loaded + → ConfigProviderAdapter.load() + → transform(applyConfigToCatalog) + → Catalog internally rebuilds +``` + +### models.dev + +```ts +timer fires + → ModelsDevPlugin.refresh() + → ModelsDev.get() + → transform(applyModelsDevToCatalog) + → Catalog internally rebuilds + → commit diff +``` + +### Auth + +```ts +Account.switched(providerID) + → AuthPlugin.refresh() + → transform(applyAuthToCatalog) + → Catalog internally rebuilds + → replay active transforms including current auth + → apply policy + → commit diff +``` + +### Plugin Activation + +```ts +Plugin.activate("acme-models") + → Catalog.transform() + → transform(applyAcmeToCatalog) + → Catalog internally rebuilds + → commit diff +``` + +### Plugin Disablement + +```ts +Plugin.disable("company-naming") + → close plugin scope + → Catalog internally unregisters transform in finalizer + → Catalog internally rebuilds + → sonnet.name = "Sonnet" + → commit diff +``` + +### Config Edit + +```ts +file watcher sees edit + → ConfigProviderAdapter.load() + → transform(applyUpdatedConfigToCatalog) + → Catalog internally rebuilds +``` + +### Policy + +```ts +policy changes + → Catalog rebuild trigger + → replay all active transforms + → apply updated policy last + → commit diff +``` + +### Tradeoffs + +- Disablement, source refresh, and policy re-evaluation are transform replay operations. +- Auth does not need to be represented as config. +- Config remains one catalog source rather than a catalog dependency. +- The API shape matches A, but the mutable draft is catalog state instead of configuration state. +- Catalog needs transform ordering and internal rebuild behavior in addition to reads. +- Recompute ordering, serialization, and diff events must be specified. +- One internal rebuild produces at most one `Catalog.Event.Updated` notification. +- Deferred plugin activation avoids blocking readiness and only rebuilds catalog for catalog transform changes. +- Debouncing those rebuilds needs an additional batching interface or an activation coordinator that installs multiple transforms before exposing updates. diff --git a/specs/v2/config.md b/specs/v2/config.md new file mode 100644 index 0000000000..55caf2e668 --- /dev/null +++ b/specs/v2/config.md @@ -0,0 +1,394 @@ +# V2 Config Review + +This document breaks the legacy configuration schema into small review groups. Work through one group at a time and decide whether each field should be ported as-is, removed, or redesigned for v2. + +## Status Labels + +- `pending`: not discussed yet +- `keep`: port with substantially the existing meaning +- `remove`: do not carry forward +- `redesign`: keep the capability with a different shape, scope, or owning module + +## Schema Scope + +Use one v2 config schema for now. Some fields, such as `autoupdate`, are intended for global/user configuration, but there is not yet enough benefit to enforce that with separate global and location schemas. Revisit this if more scope-sensitive fields survive the review. + +## Group 1: File Metadata + +Small fields describing the config file itself rather than application behavior. + +| Field | Current Purpose | Status | Notes | +| --------- | ---------------------------------------------------------- | ------ | ------------------------------------------------------------------------------------- | +| `$schema` | JSON schema reference for editor validation and completion | keep | Keep as read-only metadata; loading config must not insert it or create files for it. | + +## Group 2: Process And Server Settings + +Settings that affect process startup, shell execution, or network serving. Review global-only versus location-specific scope carefully. + +| Field | Current Purpose | Status | Notes | +| ------------ | --------------------------------------------------- | ------ | ------------------------------------------------------------------------------ | +| `shell` | Default shell for terminal and shell tool execution | keep | Port as effective config; shared shell choice is used throughout opencode. | +| `logLevel` | Intended logging level configuration | remove | Do not port: no config consumer exists and logging initializes from CLI input. | +| `server` | Hostname, port, mDNS, and CORS settings | remove | Do not port: location config is loaded after the server is already running. | +| `autoupdate` | Automatic update or notification behavior | keep | Global-only user preference; keep `true`, `false`, and `"notify"`. | + +## Group 3: Commands And Project Resources + +Configuration that introduces location-scoped project resources or discoverable content. + +| Field | Current Purpose | Status | Notes | +| -------------- | --------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------- | +| `command` | User-defined commands | remove | Do not port as v2 config; named reusable user workflows belong to skills. | +| `skills` | Additional skill locations | redesign | Replace `{ paths?, urls? }` with a single array of local path or remote URL discovery sources. | +| `reference` | Named git or local directory references | redesign | Rename to plural `references`; retain named local path and Git repository external-context entries. | +| `instructions` | Additional ambient instruction sources | keep | Keep as one array of local paths, glob patterns, or remote URLs supplying automatically included context. | + +V2 does not expose separate user-authored command configuration. Skills should cover named reusable prompt workflows, whether invoked directly by the user or loaded by an agent. Internal command routing and built-in commands may remain runtime concerns without creating a `command` or `commands` config field. + +This intentionally does not port legacy command-only behavior such as per-command `model`, `agent`, `subtask`, prompt shell expansion, or positional/template substitution. If a related capability is needed in v2, it should be designed in the owning domain rather than preserved through a second workflow definition system. + +Keep `skills` as discovery-source configuration rather than inline workflow definitions. Skill content remains owned by `SKILL.md`; each `skills` entry is either a local search root or a remote discovery URL. Direct invocation behavior can be designed separately without expanding the config shape. + +```jsonc +{ + "skills": ["./team-skills", "~/shared-skills", "https://example.com/.well-known/skills/"], +} +``` + +Keep ambient instructions separate from skills. Instructions are automatically included as model context, while skills are loaded or invoked intentionally. Each source is unambiguously either a local path/glob or a URL, so v2 keeps the simple array shape: + +```jsonc +{ + "instructions": ["CONTRIBUTING.md", "docs/guidelines.md", ".cursor/rules/*.md", "https://example.com/shared-rules.md"], +} +``` + +Keep named external context references as a v2 configuration capability, renamed to plural `references` because it is a collection keyed by alias. References declare local directories or Git repositories that can later be addressed as `@alias` or `@alias/path` when the v2 runtime implements this behavior. + +```jsonc +{ + "references": { + "design-system": { "path": "../ui-library" }, + "sdk": { "repository": "github.com/example/sdk", "branch": "main" }, + }, +} +``` + +Retain the compact string entry form as well: values starting with `.`, `/`, or `~` represent local paths, and other strings represent Git repositories. + +## Group 4: Plugins + +Plugin loading has source-path and scope-sensitive behavior, so it should be reviewed separately from other project resources. + +| Field | Current Purpose | Status | Notes | +| -------- | ----------------------------- | -------- | --------------------------------------------------------------------------------------------------------------------- | +| `plugin` | User-specified plugin modules | redesign | Rename to plural `plugins`; retain ordered loading with package strings or `{ package, options? }` entries. | + +Plugin order remains part of the v2 configuration contract because hook registration and execution can depend on load order. Replace legacy option tuples with readable object entries: + +```jsonc +{ + "plugins": [ + "opencode-helicone-session", + { + "package": "@my-org/audit-plugin", + "options": { + "endpoint": "https://audit.example.com", + }, + }, + ], +} +``` + +The configured `plugins` list represents package-loaded plugins only. Local plugin code remains discovered from plugin directories such as `.opencode/plugins/`; v2 does not port arbitrary configured local paths or file URLs into this field. + +## Group 5: Filesystem And Tool Runtime + +Settings controlling local file observation, snapshots, language tooling, and tool output behavior. + +| Field | Current Purpose | Status | Notes | +| ------------- | --------------------------------------- | ------- | ----- | +| `watcher` | Ignore patterns for filesystem watching | keep | Keep `{ ignore?: string[] }`; this configures the filesystem watcher subsystem. | +| `snapshot` | Enable filesystem snapshot tracking | redesign | Rename to plural `snapshots`; controls creation of snapshots used for undo and revert behavior. | +| `formatter` | Configure formatters | keep | Keep singular `boolean \| Record` shape; it configures built-in enablement and named formatter overrides. | +| `lsp` | Configure language servers | keep | Keep singular `boolean \| Record` shape; custom servers need commands and file extensions. | +| `attachment` | Configure attachment/image processing | redesign | Rename to plural `attachments`; retain `{ image?: { auto_resize?, max_width?, max_height?, max_base64_bytes? } }` for input normalization limits. | +| `tool_output` | Configure tool output truncation limits | keep | Keep `{ max_lines?, max_bytes? }`; both positive thresholds apply to saved-preview truncation behavior. | + +`formatter` and `lsp` configure one project tooling subsystem each, so their singular names remain appropriate. `true` enables the built-in registrations, `false` disables them, and a keyed object enables built-ins while applying named overrides or custom registrations. Custom language servers must declare `extensions` so runtime file attachment is deterministic; validation of known built-in server IDs belongs with the eventual v2 LSP integration rather than the aggregate core config schema. + +Rename legacy `attachment` to `attachments` in v2. This setting controls processing for the attachment domain and may expand beyond image handling, while singular `attachment` is already used as a model capability flag indicating whether one model accepts attachments. + +```jsonc +{ + "formatter": { + "prettier": { "disabled": true }, + "project": { "command": ["./scripts/format", "$FILE"], "extensions": [".foo"] }, + }, + "lsp": { + "typescript": { "disabled": true }, + "project": { "command": ["project-language-server", "--stdio"], "extensions": [".foo"] }, + }, + "attachments": { + "image": { "auto_resize": true, "max_width": 2000, "max_height": 2000 }, + }, + "tool_output": { "max_lines": 2000, "max_bytes": 51200 }, +} +``` + +## Group 6: Sharing And Identity + +Settings affecting sharing behavior or user/account identity rather than model execution. + +| Field | Current Purpose | Status | Notes | +| ------------ | ----------------------------------------------- | ------- | ------------------------------- | +| `share` | Session sharing behavior | keep | Keep `"manual" \| "auto" \| "disabled"`; it controls manual sharing permission and automatic sharing of new sessions. | +| `autoshare` | Legacy automatic sharing flag | remove | Do not port deprecated alias; use `share: "auto"`. | +| `enterprise` | Enterprise URL configuration | keep | Keep `{ url?: string }`; currently selects the legacy sharing service endpoint when no organization account is active. | +| `username` | Display username in conversations and telemetry | keep | Keep string identity override; runtime may otherwise resolve an operating-system username. | + +Retain `share` as the single session-sharing setting. `"manual"` permits explicit sharing, `"auto"` shares newly created top-level sessions, and `"disabled"` prevents sharing. Legacy `autoshare: true` is only an alias for `share: "auto"`, so v2 does not expose it. + +Retain `enterprise.url` for legacy enterprise share hosting selection and `username` as a user-facing identity override. These remain separate from server authentication credentials; `username` identifies the user in conversation and telemetry behavior rather than HTTP basic-auth configuration. + +```jsonc +{ + "share": "disabled", + "enterprise": { "url": "https://share.example.com" }, + "username": "developer", +} +``` + +## Group 7: Providers And Model Selection + +Provider catalog customization and model-choice configuration. The new core work has started here. + +| Field | Current Purpose | Status | Notes | +| -------------------- | ------------------------------------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------- | +| `provider` | Custom provider configuration and model overrides | redesign | Rename to plural `providers` in v2; do not preserve the legacy singular key. Review nested provider/model fields separately. | +| `disabled_providers` | Disable automatically loaded providers | redesign | Replace with `experimental.policies: [{ effect: "deny", action: "provider.use", resource: "..." }]`. | +| `enabled_providers` | Restrict enabled providers to an allowlist | redesign | Replace with ordered `provider.use` allow/deny statements and wildcard resources. | +| `model` | Default model selection | keep | Keep as the fallback model when an active session or agent does not specify a model. | +| `small_model` | Small/utility model selection | remove | Do not port; its only runtime consumer is title generation, which can use an explicit `title` agent model override. | + +Provider selection rules belong in `experimental.policies` rather than provider entries or repeated top-level provider fields. Initial proposed shape: + +```jsonc +{ + "experimental": { + "policies": [ + { + "effect": "deny", + "action": "provider.use", + "resource": "*", + }, + { + "effect": "allow", + "action": "provider.use", + "resource": "anthropic", + }, + ], + }, +} +``` + +See [provider-policy.md](./provider-policy.md) for the provider policy semantics and precedence rules. + +Policy evaluation will consume authored config documents in reverse order while preserving statement order inside each document. The precedence of `.opencode` policy sources remains open until `.opencode` configuration is reviewed. + +Provider configuration uses the plural `providers` key in v2. This intentionally differs from the legacy singular `provider` key; v2 does not add a compatibility alias while its configuration surface is still being defined. + +Keep `model` as the default model fallback. It is application-wide behavior used when an active session or agent has no explicit model selection, so it does not belong inside any individual provider configuration. + +Do not port `small_model`. In the current runtime it is only consulted while generating a session title: the `title` agent model wins first, then `small_model`, then automatic/current-model fallback. In v2, users who need a specific title model should configure the `title` agent directly rather than use a separate top-level model setting. + +Provider, model, variant, and provisional agent `options` are authored as partial patches rather than fully materialized runtime option records. Users should be able to set only the override they need, such as a header or an AI SDK request option; catalog state supplies empty defaults and merges patches in configuration order. + +Keep provider `env` as an authored list of recognized credential environment variable names. Built-in catalog providers already carry this metadata for automatic environment-backed availability, and configured providers may need to declare the same source. For a configured provider this is additive metadata, not a requirement that one of the variables exists: the provider may instead be usable through configured options, a stored account, or an endpoint that needs no credential. + +Within configured models, rename legacy upstream model identifier `id` to `api_id` rather than exposing camelCase runtime `apiID`. Model `limit` is an authored patch, so an override may change only `context`, `input`, or `output`. Model `cost` accepts one simple pricing object or an array of tiered pricing entries; omitted cache prices default to zero. + +Do not port legacy provider model `reasoning`, `temperature`, or `interleaved` flags as first-class config fields; provider/request behavior belongs in structured `options` or model variants. Do not port `release_date`, `status`, `experimental`, `whitelist`, or `blacklist` in this v2 surface. + +```jsonc +{ + "providers": { + "internal": { + "env": ["INTERNAL_LLM_API_KEY"], + "options": { "headers": { "Authorization": "Bearer {env:API_KEY}" } }, + "models": { + "chat": { + "api_id": "upstream-chat-model", + "limit": { "output": 32768 }, + "cost": { "input": 1.25, "output": 10 }, + "variants": [ + { "id": "high", "aisdk": { "request": { "reasoningEffort": "high" } } }, + ], + }, + }, + }, + }, +} +``` + +## Group 8: Agents And Permissions + +Agent behavior and tool-access policy. Review together because agent configuration can contain permissions and model choices. + +| Field | Current Purpose | Status | Notes | +| --------------- | --------------------------------------------------- | ------- | ------------------------------------------- | +| `default_agent` | Choose default primary agent | remove | Do not retain a separate top-level selector; default choice should be designed with the v2 agent configuration model. | +| `mode` | Legacy agent configuration alias | remove | Do not port deprecated alias; configure agents through the v2 agent surface only. | +| `agent` | Configure primary, subagent, and specialized agents | redesign | Rename to plural `agents`; retain a named map of built-in overrides and custom agent definitions. | +| `permission` | Tool permission rules | redesign | Rename to plural `permissions`; replace legacy map shorthand with an ordered array of `{ permission, pattern, action }` rules. | +| `tools` | Legacy tool enable/disable map | remove | Do not port boolean enable/disable alias; express tool access through permissions. | + +Do not port `default_agent` ahead of the v2 agent design. The legacy runtime uses it to choose a visible, non-subagent fallback instead of `build`, but exposing that selection as an isolated top-level field would pre-commit v2 to the legacy agent model before agents and their policy surface are defined together. + +Do not port `mode`. The legacy loader already merges this deprecated alias into `agent`, and v2 should expose only one authoring surface for agent definitions. + +Rename legacy `agent` to `agents` because the setting is a collection keyed by agent name. It should continue to support overriding built-in agents such as `build`, `plan`, and `title`, as well as declaring named custom agents. The nested entry schema remains open until agent-local `permission` and deprecated `tools` behavior are decided. + +Keep nested `agents..mode` with values `"primary"`, `"subagent"`, or `"all"`. This identifies an agent's runtime role and is separate from the removed top-level legacy `mode` alias, which was an alternate container for agent definitions. + +For named configurable entries across v2, use `disabled?: boolean` consistently when an entry should remain configured but inactive. Agent definitions should therefore redesign legacy `disable` as `disabled`; this matches formatters, language servers, future MCP server definitions, and configured model overrides. Runtime catalog state may still track active availability as `enabled`; that is not user-authored config. + +Keep separate `model` and `variant` fields on agent definitions. A model reference uses `provider/model-id`, but model IDs may themselves contain slash-delimited segments, such as `openrouter/openai/gpt-5`; appending a variant to that string would be ambiguous. + +Keep `color` on agent definitions. Agents are user-visible selectable entities, so a user-authored display color is appropriate metadata for the agent rather than an unrelated application presentation setting. Retain hex colors and named theme colors supported by the existing configuration. + +Keep agent-local `options` provisionally using the same structured provider options shape available on configured providers and models: headers, body, and AI SDK provider/request overrides. Its long-term ownership remains open for team review because reusable provider-specific presets can instead be modeled as variants. Do not retain dedicated agent `temperature` or `top_p` fields. + +Retain `description`, `hidden`, and `steps`; they define an agent's discoverability, visibility, and iteration budget rather than model request parameters. Rename legacy agent `prompt` to `system`, making clear that it supplies persistent system-level agent content without colliding with top-level ambient `instructions`. Remove deprecated `maxSteps` in favor of `steps`. + +```jsonc +{ + "agents": { + "reviewer": { + "model": "openrouter/openai/gpt-5", + "variant": "high", + "options": { + "headers": { "x-agent": "reviewer" }, + "body": {}, + "aisdk": { "provider": {}, "request": { "reasoningEffort": "high" } }, + }, + "description": "Review changes for correctness", + "system": "Find regressions and missing tests.", + "mode": "subagent", + "color": "warning", + "steps": 12, + "disabled": false, + "permissions": [ + { "permission": "edit", "pattern": "*", "action": "deny" }, + ], + }, + }, +} +``` + +Do not port `tools`, either as a top-level setting or as an agent-entry alias. The legacy loader already converts tool booleans into permission rules, including collapsing write-adjacent tool names into `edit`; v2 should avoid carrying that lossy compatibility input forward. + +Rename legacy `permission` to `permissions` and expose the normalized ordered ruleset already modeled by `PermissionV2.Ruleset`. Rules retain the interactive `"ask"` action in addition to `"allow"` and `"deny"`; this is distinct from `experimental.policies`, whose provider enforcement currently needs only allow/deny decisions. The same `permissions` ruleset shape should be used inside future `agents` entries. + +```jsonc +{ + "permissions": [ + { "permission": "bash", "pattern": "*", "action": "ask" }, + { "permission": "bash", "pattern": "git status", "action": "allow" }, + ], +} +``` + +## Group 9: Integrations + +External protocol and server integration configuration. + +| Field | Current Purpose | Status | Notes | +| ----- | ------------------------------------- | ------- | ----- | +| `mcp` | MCP server definitions and enablement | redesign | Keep opencode's explicit local/remote server entry format, nested under `mcp.servers`; use `disabled` for inactive entries and move timeout here. | + +Keep the opencode MCP server entry format instead of adopting the common `mcpServers` copy/paste shape. Local servers remain explicit `type: "local"` entries with command arrays and `environment`; remote servers remain explicit `type: "remote"` entries with `url`, `headers`, and optional `oauth`. Nest the server map under `mcp.servers` so protocol-wide settings such as default timeout can live under the same subsystem. + +```jsonc +{ + "mcp": { + "timeout": 5000, + "servers": { + "github": { + "type": "local", + "command": ["npx", "-y", "@github/github-mcp-server"], + "environment": { "GITHUB_TOKEN": "{env:GITHUB_TOKEN}" }, + "disabled": false, + "timeout": 10000, + }, + "docs": { + "type": "remote", + "url": "https://docs.example.com/mcp", + "headers": { "Authorization": "Bearer {env:DOCS_TOKEN}" }, + "oauth": { + "client_id": "{env:MCP_CLIENT_ID}", + "client_secret": "{env:MCP_CLIENT_SECRET}", + "scope": "read write", + "callback_port": 19876, + "redirect_uri": "http://127.0.0.1:19876/mcp/oauth/callback", + }, + "disabled": false, + }, + }, + }, +} +``` + +## Group 10: Conversation Lifecycle + +Behavior affecting long-running conversations and context management. + +| Field | Current Purpose | Status | Notes | +| ------------ | ----------------------------------------------------------- | ------- | ----- | +| `compaction` | Automatic compaction, pruning, and context reserve settings | redesign | Group retained verbatim history under `keep` and rename context headroom to `buffer`. | + +Retain the compaction capability but redesign the less clear limits. `keep.turns` is the maximum number of recent user turns to preserve verbatim after compaction, and `keep.tokens` is the token budget for those retained turns. `buffer` is the token headroom reserved so automatic compaction triggers before the input window is exhausted. + +```jsonc +{ + "compaction": { + "auto": true, + "prune": true, + "keep": { + "turns": 2, + "tokens": 2000, + }, + "buffer": 10000, + }, +} +``` + +## Group 11: Deprecated And Experimental Settings + +Fields that should not be ported by inertia; each needs an explicit justification. + +| Field | Current Purpose | Status | Notes | +| ------------------------------------ | --------------------------------------- | ------- | ------------------------------------------------------------------- | +| `layout` | Legacy layout selection | remove | Do not port deprecated option; stretch layout is always used. | +| `experimental.disable_paste_summary` | Disable pasted-content summary behavior | remove | Do not port; pasted-input presentation behavior belongs to the client/UI surface. | +| `experimental.batch_tool` | Enable batch tool | remove | Do not port; batch tool is no longer a supported feature. | +| `experimental.openTelemetry` | Enable AI SDK telemetry spans | remove | Do not port; observability is process-level and should use standard OpenTelemetry environment or declarative configuration. | +| `experimental.primary_tools` | Restrict tools to primary agents | remove | Do not port obsolete gating; agent tool access is configured through permissions. | +| `experimental.continue_loop_on_deny` | Continue loop after denied tool call | remove | Do not port legacy denied-tool loop behavior. | +| `experimental.mcp_timeout` | MCP request timeout | redesign | Move to `mcp.timeout` for the default and `mcp.servers..timeout` for per-server overrides. | + +## Review Order + +Work through the groups in this order unless a dependency between decisions becomes clear: + +1. File Metadata +2. Process And Server Settings +3. Providers And Model Selection +4. Commands And Project Resources +5. Plugins +6. Filesystem And Tool Runtime +7. Sharing And Identity +8. Agents And Permissions +9. Integrations +10. Conversation Lifecycle +11. Deprecated And Experimental Settings diff --git a/specs/v2/provider-policy.md b/specs/v2/provider-policy.md new file mode 100644 index 0000000000..6e3fc75ba1 --- /dev/null +++ b/specs/v2/provider-policy.md @@ -0,0 +1,291 @@ +# Policy + +## Purpose + +Policies control whether an operation on a named resource is allowed. They may be authored in configuration files, but policy evaluation is its own runtime concern. + +The first policy consumer is provider availability: + +```text +action: provider.use +resource: provider ID, such as openai or company-ai +``` + +Provider configuration and provider policy remain separate: + +- `providers` describes endpoints, options, and model overrides. +- `experimental.policies` determines whether an operation using a provider is allowed. + +A provider can be correctly configured and have valid credentials while policy still denies its use. + +## Goals + +- Replace legacy `enabled_providers` and `disabled_providers`. +- Keep the default experience unchanged when users specify no policy. +- Support wildcard matching for actions and resources. +- Provide one small policy vocabulary that can later cover operations such as `plugin.load` or `mcp.connect`. +- Let user policy override repository policy, and later allow organization-managed policy to override both. +- Keep evaluation simple: matching statements are applied in order and the last match wins. + +## Non-Goals + +- Policies do not configure endpoints, credentials, models, or provider options. +- Policies do not make unusable resources usable. +- Policies do not currently provide conditions, principals, approval prompts, or enforced configuration values. +- This spec does not define how organization-managed policies are delivered. + +## Statement Shape + +```jsonc +{ + "experimental": { + "policies": [ + { + "effect": "deny", + "action": "provider.use", + "resource": "openai", + }, + ], + }, +} +``` + +```ts +interface PolicyInfo { + effect: "allow" | "deny" + action: string + resource: string +} +``` + +The `Policy` module owns the shared `Policy.Info` interface, `Policy.Effect` type, and evaluator. Domains define their supported typed statement schemas; for example, `Catalog.ProviderPolicy` fixes `action` to `"provider.use"`. The config schema gathers those domain-defined statement schemas into the accepted `experimental.policies` union because config files are one place statements can be authored while the capability is experimental. + +## Matching + +Both `action` and `resource` use opencode's existing wildcard matching behavior. + +Examples: + +| Action | Resource | Matches | +| -------------- | ----------- | ---------------------------------------------------------------------------- | +| `provider.use` | `openai` | Only use of provider ID `openai` | +| `provider.use` | `company-*` | Use of provider IDs such as `company-us` and `company-eu` | +| `provider.*` | `*` | Any provider operation on any provider, if more actions are introduced later | + +No pattern-specific precedence exists. A specific resource does not automatically beat a wildcard resource. Written/evaluation order controls the result. + +## Evaluation + +To evaluate an operation and resource: + +1. Start with `allow`. +2. Consider every statement whose `action` and `resource` match the requested action and resource. +3. Each matching statement replaces the current decision with its `effect`. +4. The last matching statement determines the result. + +Conceptually: + +```ts +function evaluate(action: string, resource: string, fallback: Policy.Effect, statements: Policy.Info[]) { + return ( + statements.findLast( + (statement) => Wildcard.match(action, statement.action) && Wildcard.match(resource, statement.resource), + )?.effect ?? fallback + ) +} +``` + +Each caller supplies the default effect appropriate for its operation. Catalog provider use supplies `"allow"`, so no provider policy statements means normal behavior continues: otherwise usable providers are allowed. + +## Ordering Within One Config Document + +Statements remain in the order written by the user. + +To deny all providers except Anthropic: + +```jsonc +{ + "experimental": { + "policies": [ + { + "effect": "deny", + "action": "provider.use", + "resource": "*", + }, + { + "effect": "allow", + "action": "provider.use", + "resource": "anthropic", + }, + ], + }, +} +``` + +Result: + +```text +provider.use / anthropic -> allow +provider.use / openai -> deny +``` + +To allow internal providers except experimental ones: + +```jsonc +{ + "experimental": { + "policies": [ + { "effect": "deny", "action": "provider.use", "resource": "*" }, + { "effect": "allow", "action": "provider.use", "resource": "company-*" }, + { "effect": "deny", "action": "provider.use", "resource": "company-experimental-*" }, + ], + }, +} +``` + +Result: + +```text +company-stable: allowed +company-experimental-fast: denied +openai: denied +``` + +## Ordering Across Authored Config Documents + +Ordinary settings and policies have different precedence needs: + +- Ordinary settings are read forward, so location-specific settings override user-global settings. +- Policies are read by reversing authored config documents, so user-global policy can override repository policy. +- Statements inside each document keep their written order. + +At minimum, this means a repository cannot silently re-enable something the user denied globally. + +Project config: + +```jsonc +{ + "experimental": { + "policies": [{ "effect": "allow", "action": "provider.use", "resource": "openai" }], + }, +} +``` + +User-global config: + +```jsonc +{ + "experimental": { + "policies": [{ "effect": "deny", "action": "provider.use", "resource": "openai" }], + }, +} +``` + +Result: + +```text +provider.use / openai -> deny +``` + +The relative policy precedence of direct project files and `.opencode` files is intentionally deferred until `.opencode` configuration is reviewed. + +## Organization-Managed Policy + +Organization-managed policy is not ordinary authored config. When implemented, managed statements must be appended after the reversed authored statements so they have final authority. + +```text +repository policy -> user-global policy -> organization-managed policy +``` + +Plugins must not be allowed to add, remove, or override policy statements. Plugins can contribute functionality or configured providers; policy determines whether opencode permits an operation through its managed execution paths. + +Provider policy is not a full sandbox for executable plugins. A denied provider must not be usable through the normal provider/model path, but arbitrary plugin code requires separate governance if that becomes a compliance requirement. + +## Interaction With Provider Configuration + +```jsonc +{ + "providers": { + "company-ai": { + "endpoint": { + "type": "openai/responses", + "url": "https://ai.company.example/v1/responses", + }, + }, + }, + "experimental": { + "policies": [ + { "effect": "deny", "action": "provider.use", "resource": "*" }, + { "effect": "allow", "action": "provider.use", "resource": "company-ai" }, + ], + }, +} +``` + +The provider entry configures `company-ai`; the policy statements make it the only provider permitted for use. + +Provider policy applies regardless of how a provider becomes known or usable, including: + +- models.dev catalog data +- environment credentials +- saved accounts +- built-in provider plugins +- explicit provider configuration + +## Applying Provider Policy + +Provider records and model overrides should be assembled before checking provider policy. Otherwise later provider loading could recreate a provider that was already filtered. + +Intended flow: + +1. Build provider/model catalog entries. +2. Apply configured provider and model overrides. +3. Ask `Policy.Service` to evaluate `provider.use` for each provider ID. +4. Prevent denied providers from being selectable or used. + +Whether denied providers are removed entirely or retained as disabled records for diagnostics remains an implementation decision. + +## Legacy Migration + +Legacy deny list: + +```jsonc +{ + "disabled_providers": ["openai", "google"], +} +``` + +Equivalent v2 policy: + +```jsonc +{ + "experimental": { + "policies": [ + { "effect": "deny", "action": "provider.use", "resource": "openai" }, + { "effect": "deny", "action": "provider.use", "resource": "google" }, + ], + }, +} +``` + +Legacy allowlist: + +```jsonc +{ + "enabled_providers": ["anthropic", "openai"], +} +``` + +Equivalent v2 policy: + +```jsonc +{ + "experimental": { + "policies": [ + { "effect": "deny", "action": "provider.use", "resource": "*" }, + { "effect": "allow", "action": "provider.use", "resource": "anthropic" }, + { "effect": "allow", "action": "provider.use", "resource": "openai" }, + ], + }, +} +``` From e4d3b81b0e64442bedae267a78c374a6d8b0b7e5 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sat, 30 May 2026 04:07:26 +0000 Subject: [PATCH 003/840] chore: generate --- packages/core/src/config.ts | 21 ++-- packages/core/src/plugin/boot.ts | 8 +- packages/core/src/state.ts | 6 +- packages/core/test/agent.test.ts | 4 +- packages/core/test/config/config.test.ts | 17 ++- .../test/provider/header-timeout.test.ts | 7 +- packages/sdk/js/src/v2/gen/types.gen.ts | 33 ++++-- packages/sdk/openapi.json | 71 ++++++++++-- packages/web/src/content/docs/policies.mdx | 6 +- specs/v2/catalog-config-plugin-lifecycle.md | 6 +- specs/v2/config.md | 107 +++++++++--------- 11 files changed, 182 insertions(+), 104 deletions(-) diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index 88ff2ef91e..c9e1396739 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -32,17 +32,21 @@ export class Info extends Schema.Class("Config.Info")({ model: Schema.String.pipe(Schema.optional).annotate({ description: "Default model to use when no session or agent model is selected", }), - autoupdate: Schema.Union([Schema.Boolean, Schema.Literal("notify")]).pipe(Schema.optional).annotate({ - description: "Automatically update or notify when a new version is available", - }), + autoupdate: Schema.Union([Schema.Boolean, Schema.Literal("notify")]) + .pipe(Schema.optional) + .annotate({ + description: "Automatically update or notify when a new version is available", + }), share: Schema.Literals(["manual", "auto", "disabled"]).pipe(Schema.optional).annotate({ description: "Control whether sessions may be shared manually, automatically, or not at all", }), enterprise: Schema.Struct({ url: Schema.String.pipe(Schema.optional), - }).pipe(Schema.optional).annotate({ - description: "Enterprise sharing service configuration", - }), + }) + .pipe(Schema.optional) + .annotate({ + description: "Enterprise sharing service configuration", + }), username: Schema.String.pipe(Schema.optional).annotate({ description: "Username displayed in conversations and used for telemetry identity", }), @@ -196,7 +200,4 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer.pipe( - Layer.provide(AppFileSystem.defaultLayer), - Layer.provide(Global.defaultLayer), -) +export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Global.defaultLayer)) diff --git a/packages/core/src/plugin/boot.ts b/packages/core/src/plugin/boot.ts index 39faa62f8d..5b70e391d7 100644 --- a/packages/core/src/plugin/boot.ts +++ b/packages/core/src/plugin/boot.ts @@ -18,7 +18,13 @@ import { ProviderPlugins } from "./provider" type Plugin = { id: PluginV2.ID effect: PluginV2.Effect< - Catalog.Service | AccountV2.Service | AgentV2.Service | Npm.Service | EventV2.Service | PluginV2.Service | Config.Service + | Catalog.Service + | AccountV2.Service + | AgentV2.Service + | Npm.Service + | EventV2.Service + | PluginV2.Service + | Config.Service > } diff --git a/packages/core/src/state.ts b/packages/core/src/state.ts index 1d67acaa92..b764699e08 100644 --- a/packages/core/src/state.ts +++ b/packages/core/src/state.ts @@ -15,11 +15,7 @@ export interface Options { export interface Interface { readonly get: () => State - readonly transform: () => Effect.Effect< - (transform: Transform) => Effect.Effect, - never, - Scope.Scope - > + readonly transform: () => Effect.Effect<(transform: Transform) => Effect.Effect, never, Scope.Scope> readonly update: (update: (editor: Editor) => Effect.Effect, reason?: string) => Effect.Effect } diff --git a/packages/core/test/agent.test.ts b/packages/core/test/agent.test.ts index 2449a91a7c..f4b7867a81 100644 --- a/packages/core/test/agent.test.ts +++ b/packages/core/test/agent.test.ts @@ -94,9 +94,7 @@ describe("AgentV2", () => { const id = AgentV2.ID.make("custom") yield* agent.update((editor) => Effect.sync(() => editor.update(id, () => {}))) - expect(yield* agent.get(id)).toEqual( - AgentV2.Info.empty(id), - ) + expect(yield* agent.get(id)).toEqual(AgentV2.Info.empty(id)) yield* agent.update((editor) => Effect.sync(() => editor.remove(id))) expect(yield* agent.get(id)).toBeUndefined() diff --git a/packages/core/test/config/config.test.ts b/packages/core/test/config/config.test.ts index 0a39ed4b3e..21f9e3e3d1 100644 --- a/packages/core/test/config/config.test.ts +++ b/packages/core/test/config/config.test.ts @@ -194,9 +194,14 @@ describe("Config", () => { }, snapshots: false, watcher: { ignore: ["node_modules/**", "dist/**", ".git"] }, - formatter: { prettier: { disabled: true }, custom: { command: ["custom-fmt", "$FILE"], extensions: [".foo"] } }, + formatter: { + prettier: { disabled: true }, + custom: { command: ["custom-fmt", "$FILE"], extensions: [".foo"] }, + }, lsp: { typescript: { disabled: true }, custom: { command: ["custom-lsp"], extensions: [".foo"] } }, - attachments: { image: { auto_resize: false, max_width: 1200, max_height: 900, max_base64_bytes: 1048576 } }, + attachments: { + image: { auto_resize: false, max_width: 1200, max_height: 900, max_base64_bytes: 1048576 }, + }, tool_output: { max_lines: 1000, max_bytes: 32768 }, mcp: { timeout: 5000, @@ -370,11 +375,15 @@ describe("Config", () => { await fs.mkdir(global, { recursive: true }) await fs.writeFile( path.join(global, "opencode.json"), - JSON.stringify({ experimental: { policies: [{ effect: "deny", action: "provider.use", resource: "openai" }] } }), + JSON.stringify({ + experimental: { policies: [{ effect: "deny", action: "provider.use", resource: "openai" }] }, + }), ) await fs.writeFile( path.join(tmp.path, "opencode.json"), - JSON.stringify({ experimental: { policies: [{ effect: "allow", action: "provider.use", resource: "openai" }] } }), + JSON.stringify({ + experimental: { policies: [{ effect: "allow", action: "provider.use", resource: "openai" }] }, + }), ) }) diff --git a/packages/opencode/test/provider/header-timeout.test.ts b/packages/opencode/test/provider/header-timeout.test.ts index 8860c39c32..0763ed2010 100644 --- a/packages/opencode/test/provider/header-timeout.test.ts +++ b/packages/opencode/test/provider/header-timeout.test.ts @@ -17,12 +17,7 @@ afterEach(async () => { }) const it = testEffect( - Layer.mergeAll( - Provider.defaultLayer, - Env.defaultLayer, - Plugin.defaultLayer, - CrossSpawnSpawner.defaultLayer, - ), + Layer.mergeAll(Provider.defaultLayer, Env.defaultLayer, Plugin.defaultLayer, CrossSpawnSpawner.defaultLayer), ) it.live("headerTimeout does not abort delayed SSE body after headers arrive", () => diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index f393b94849..cdc2f7f574 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -77,6 +77,7 @@ export type Event = | EventSessionNextCompactionStarted | EventSessionNextCompactionDelta | EventSessionNextCompactionEnded + | EventPluginAdded | EventCatalogModelUpdated | EventModelsDevRefreshed | EventAccountAdded @@ -878,6 +879,7 @@ export type GlobalEvent = { | EventSessionNextCompactionStarted | EventSessionNextCompactionDelta | EventSessionNextCompactionEnded + | EventPluginAdded | EventCatalogModelUpdated | EventModelsDevRefreshed | EventAccountAdded @@ -1316,6 +1318,7 @@ export type Config = { primary_tools?: Array continue_loop_on_deny?: boolean mcp_timeout?: number + policies?: Array } } @@ -2137,7 +2140,7 @@ export type SyncEventSessionNextModelSwitched = { model: { id: string providerID: string - variant: string + variant?: string } } } @@ -2209,7 +2212,7 @@ export type SyncEventSessionNextStepStarted = { model: { id: string providerID: string - variant: string + variant?: string } snapshot?: string } @@ -2890,7 +2893,7 @@ export type EventSessionNextModelSwitched = { model: { id: string providerID: string - variant: string + variant?: string } } } @@ -2978,7 +2981,7 @@ export type EventSessionNextStepStarted = { model: { id: string providerID: string - variant: string + variant?: string } snapshot?: string } @@ -3252,6 +3255,14 @@ export type EventSessionNextCompactionEnded = { } } +export type EventPluginAdded = { + id: string + type: "plugin.added" + properties: { + id: string + } +} + export type ModelV2Info = { id: string apiID: string @@ -3416,6 +3427,14 @@ export type EventAccountSwitched = { } } +export type PolicyEffect = "allow" | "deny" + +export type ConfigV2ExperimentalPolicy = { + action: "provider.use" + effect: PolicyEffect + resource: string +} + export type SessionInfo = { id: string parentID?: string @@ -3426,7 +3445,7 @@ export type SessionInfo = { model?: { id: string providerID: string - variant: string + variant?: string } cost: number tokens: { @@ -3472,7 +3491,7 @@ export type SessionMessageModelSwitched = { model: { id: string providerID: string - variant: string + variant?: string } } @@ -3607,7 +3626,7 @@ export type SessionMessageAssistant = { model: { id: string providerID: string - variant: string + variant?: string } content: Array snapshot?: { diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 53b8966fa6..3f626dc3ff 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -10698,6 +10698,9 @@ { "$ref": "#/components/schemas/EventSessionNextCompactionEnded" }, + { + "$ref": "#/components/schemas/EventPluginAdded" + }, { "$ref": "#/components/schemas/EventCatalogModelUpdated" }, @@ -13124,6 +13127,9 @@ { "$ref": "#/components/schemas/EventSessionNextCompactionEnded" }, + { + "$ref": "#/components/schemas/EventPluginAdded" + }, { "$ref": "#/components/schemas/EventCatalogModelUpdated" }, @@ -14330,6 +14336,12 @@ "mcp_timeout": { "type": "integer", "exclusiveMinimum": 0 + }, + "policies": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ConfigV2ExperimentalPolicy" + } } }, "additionalProperties": false @@ -16969,7 +16981,7 @@ "type": "string" } }, - "required": ["id", "providerID", "variant"], + "required": ["id", "providerID"], "additionalProperties": false } }, @@ -17201,7 +17213,7 @@ "type": "string" } }, - "required": ["id", "providerID", "variant"], + "required": ["id", "providerID"], "additionalProperties": false }, "snapshot": { @@ -19307,7 +19319,7 @@ "type": "string" } }, - "required": ["id", "providerID", "variant"], + "required": ["id", "providerID"], "additionalProperties": false } }, @@ -19570,7 +19582,7 @@ "type": "string" } }, - "required": ["id", "providerID", "variant"], + "required": ["id", "providerID"], "additionalProperties": false }, "snapshot": { @@ -20386,6 +20398,30 @@ "required": ["id", "type", "properties"], "additionalProperties": false }, + "EventPluginAdded": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["plugin.added"] + }, + "properties": { + "type": "object", + "properties": { + "id": { + "type": "string" + } + }, + "required": ["id"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, "ModelV2Info": { "type": "object", "properties": { @@ -20900,6 +20936,27 @@ "required": ["id", "type", "properties"], "additionalProperties": false }, + "PolicyEffect": { + "type": "string", + "enum": ["allow", "deny"] + }, + "ConfigV2ExperimentalPolicy": { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": ["provider.use"] + }, + "effect": { + "$ref": "#/components/schemas/PolicyEffect" + }, + "resource": { + "type": "string" + } + }, + "required": ["action", "effect", "resource"], + "additionalProperties": false + }, "SessionInfo": { "type": "object", "properties": { @@ -20937,7 +20994,7 @@ "type": "string" } }, - "required": ["id", "providerID", "variant"], + "required": ["id", "providerID"], "additionalProperties": false }, "cost": { @@ -21065,7 +21122,7 @@ "type": "string" } }, - "required": ["id", "providerID", "variant"], + "required": ["id", "providerID"], "additionalProperties": false } }, @@ -21444,7 +21501,7 @@ "type": "string" } }, - "required": ["id", "providerID", "variant"], + "required": ["id", "providerID"], "additionalProperties": false }, "content": { diff --git a/packages/web/src/content/docs/policies.mdx b/packages/web/src/content/docs/policies.mdx index 0f059562e2..c2ada5d14b 100644 --- a/packages/web/src/content/docs/policies.mdx +++ b/packages/web/src/content/docs/policies.mdx @@ -42,9 +42,9 @@ A provider denied by policy is not available for model selection or model use, e OpenCode currently supports one policy action: -| Action | Resource | Description | -| -------------- | ------------------------------ | ------------------------------------------ | -| `provider.use` | Provider ID, such as `openai` | Allow or deny use of an LLM provider. | +| Action | Resource | Description | +| -------------- | ----------------------------- | ------------------------------------- | +| `provider.use` | Provider ID, such as `openai` | Allow or deny use of an LLM provider. | More policy actions may be added in the future. diff --git a/specs/v2/catalog-config-plugin-lifecycle.md b/specs/v2/catalog-config-plugin-lifecycle.md index 0781283657..986cc019a3 100644 --- a/specs/v2/catalog-config-plugin-lifecycle.md +++ b/specs/v2/catalog-config-plugin-lifecycle.md @@ -192,11 +192,7 @@ Plugins register replayable catalog transforms. Each transform receives a `Catal ```ts interface Catalog { - transform(): Effect.Effect< - (update: (catalog: Catalog.Editor) => void) => Effect.Effect, - never, - Scope.Scope - > + transform(): Effect.Effect<(update: (catalog: Catalog.Editor) => void) => Effect.Effect, never, Scope.Scope> } ``` diff --git a/specs/v2/config.md b/specs/v2/config.md index 55caf2e668..5edfc1e4dd 100644 --- a/specs/v2/config.md +++ b/specs/v2/config.md @@ -36,12 +36,12 @@ Settings that affect process startup, shell execution, or network serving. Revie Configuration that introduces location-scoped project resources or discoverable content. -| Field | Current Purpose | Status | Notes | -| -------------- | --------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------- | -| `command` | User-defined commands | remove | Do not port as v2 config; named reusable user workflows belong to skills. | -| `skills` | Additional skill locations | redesign | Replace `{ paths?, urls? }` with a single array of local path or remote URL discovery sources. | -| `reference` | Named git or local directory references | redesign | Rename to plural `references`; retain named local path and Git repository external-context entries. | -| `instructions` | Additional ambient instruction sources | keep | Keep as one array of local paths, glob patterns, or remote URLs supplying automatically included context. | +| Field | Current Purpose | Status | Notes | +| -------------- | --------------------------------------- | -------- | --------------------------------------------------------------------------------------------------------- | +| `command` | User-defined commands | remove | Do not port as v2 config; named reusable user workflows belong to skills. | +| `skills` | Additional skill locations | redesign | Replace `{ paths?, urls? }` with a single array of local path or remote URL discovery sources. | +| `reference` | Named git or local directory references | redesign | Rename to plural `references`; retain named local path and Git repository external-context entries. | +| `instructions` | Additional ambient instruction sources | keep | Keep as one array of local paths, glob patterns, or remote URLs supplying automatically included context. | V2 does not expose separate user-authored command configuration. Skills should cover named reusable prompt workflows, whether invoked directly by the user or loaded by an agent. Internal command routing and built-in commands may remain runtime concerns without creating a `command` or `commands` config field. @@ -59,7 +59,12 @@ Keep ambient instructions separate from skills. Instructions are automatically i ```jsonc { - "instructions": ["CONTRIBUTING.md", "docs/guidelines.md", ".cursor/rules/*.md", "https://example.com/shared-rules.md"], + "instructions": [ + "CONTRIBUTING.md", + "docs/guidelines.md", + ".cursor/rules/*.md", + "https://example.com/shared-rules.md", + ], } ``` @@ -80,9 +85,9 @@ Retain the compact string entry form as well: values starting with `.`, `/`, or Plugin loading has source-path and scope-sensitive behavior, so it should be reviewed separately from other project resources. -| Field | Current Purpose | Status | Notes | -| -------- | ----------------------------- | -------- | --------------------------------------------------------------------------------------------------------------------- | -| `plugin` | User-specified plugin modules | redesign | Rename to plural `plugins`; retain ordered loading with package strings or `{ package, options? }` entries. | +| Field | Current Purpose | Status | Notes | +| -------- | ----------------------------- | -------- | ----------------------------------------------------------------------------------------------------------- | +| `plugin` | User-specified plugin modules | redesign | Rename to plural `plugins`; retain ordered loading with package strings or `{ package, options? }` entries. | Plugin order remains part of the v2 configuration contract because hook registration and execution can depend on load order. Replace legacy option tuples with readable object entries: @@ -106,14 +111,14 @@ The configured `plugins` list represents package-loaded plugins only. Local plug Settings controlling local file observation, snapshots, language tooling, and tool output behavior. -| Field | Current Purpose | Status | Notes | -| ------------- | --------------------------------------- | ------- | ----- | -| `watcher` | Ignore patterns for filesystem watching | keep | Keep `{ ignore?: string[] }`; this configures the filesystem watcher subsystem. | -| `snapshot` | Enable filesystem snapshot tracking | redesign | Rename to plural `snapshots`; controls creation of snapshots used for undo and revert behavior. | -| `formatter` | Configure formatters | keep | Keep singular `boolean \| Record` shape; it configures built-in enablement and named formatter overrides. | -| `lsp` | Configure language servers | keep | Keep singular `boolean \| Record` shape; custom servers need commands and file extensions. | +| Field | Current Purpose | Status | Notes | +| ------------- | --------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| `watcher` | Ignore patterns for filesystem watching | keep | Keep `{ ignore?: string[] }`; this configures the filesystem watcher subsystem. | +| `snapshot` | Enable filesystem snapshot tracking | redesign | Rename to plural `snapshots`; controls creation of snapshots used for undo and revert behavior. | +| `formatter` | Configure formatters | keep | Keep singular `boolean \| Record` shape; it configures built-in enablement and named formatter overrides. | +| `lsp` | Configure language servers | keep | Keep singular `boolean \| Record` shape; custom servers need commands and file extensions. | | `attachment` | Configure attachment/image processing | redesign | Rename to plural `attachments`; retain `{ image?: { auto_resize?, max_width?, max_height?, max_base64_bytes? } }` for input normalization limits. | -| `tool_output` | Configure tool output truncation limits | keep | Keep `{ max_lines?, max_bytes? }`; both positive thresholds apply to saved-preview truncation behavior. | +| `tool_output` | Configure tool output truncation limits | keep | Keep `{ max_lines?, max_bytes? }`; both positive thresholds apply to saved-preview truncation behavior. | `formatter` and `lsp` configure one project tooling subsystem each, so their singular names remain appropriate. `true` enables the built-in registrations, `false` disables them, and a keyed object enables built-ins while applying named overrides or custom registrations. Custom language servers must declare `extensions` so runtime file attachment is deterministic; validation of known built-in server IDs belongs with the eventual v2 LSP integration rather than the aggregate core config schema. @@ -140,12 +145,12 @@ Rename legacy `attachment` to `attachments` in v2. This setting controls process Settings affecting sharing behavior or user/account identity rather than model execution. -| Field | Current Purpose | Status | Notes | -| ------------ | ----------------------------------------------- | ------- | ------------------------------- | -| `share` | Session sharing behavior | keep | Keep `"manual" \| "auto" \| "disabled"`; it controls manual sharing permission and automatic sharing of new sessions. | -| `autoshare` | Legacy automatic sharing flag | remove | Do not port deprecated alias; use `share: "auto"`. | +| Field | Current Purpose | Status | Notes | +| ------------ | ----------------------------------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------- | +| `share` | Session sharing behavior | keep | Keep `"manual" \| "auto" \| "disabled"`; it controls manual sharing permission and automatic sharing of new sessions. | +| `autoshare` | Legacy automatic sharing flag | remove | Do not port deprecated alias; use `share: "auto"`. | | `enterprise` | Enterprise URL configuration | keep | Keep `{ url?: string }`; currently selects the legacy sharing service endpoint when no organization account is active. | -| `username` | Display username in conversations and telemetry | keep | Keep string identity override; runtime may otherwise resolve an operating-system username. | +| `username` | Display username in conversations and telemetry | keep | Keep string identity override; runtime may otherwise resolve an operating-system username. | Retain `share` as the single session-sharing setting. `"manual"` permits explicit sharing, `"auto"` shares newly created top-level sessions, and `"disabled"` prevents sharing. Legacy `autoshare: true` is only an alias for `share: "auto"`, so v2 does not expose it. @@ -163,13 +168,13 @@ Retain `enterprise.url` for legacy enterprise share hosting selection and `usern Provider catalog customization and model-choice configuration. The new core work has started here. -| Field | Current Purpose | Status | Notes | -| -------------------- | ------------------------------------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------- | +| Field | Current Purpose | Status | Notes | +| -------------------- | ------------------------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------- | | `provider` | Custom provider configuration and model overrides | redesign | Rename to plural `providers` in v2; do not preserve the legacy singular key. Review nested provider/model fields separately. | -| `disabled_providers` | Disable automatically loaded providers | redesign | Replace with `experimental.policies: [{ effect: "deny", action: "provider.use", resource: "..." }]`. | -| `enabled_providers` | Restrict enabled providers to an allowlist | redesign | Replace with ordered `provider.use` allow/deny statements and wildcard resources. | -| `model` | Default model selection | keep | Keep as the fallback model when an active session or agent does not specify a model. | -| `small_model` | Small/utility model selection | remove | Do not port; its only runtime consumer is title generation, which can use an explicit `title` agent model override. | +| `disabled_providers` | Disable automatically loaded providers | redesign | Replace with `experimental.policies: [{ effect: "deny", action: "provider.use", resource: "..." }]`. | +| `enabled_providers` | Restrict enabled providers to an allowlist | redesign | Replace with ordered `provider.use` allow/deny statements and wildcard resources. | +| `model` | Default model selection | keep | Keep as the fallback model when an active session or agent does not specify a model. | +| `small_model` | Small/utility model selection | remove | Do not port; its only runtime consumer is title generation, which can use an explicit `title` agent model override. | Provider selection rules belong in `experimental.policies` rather than provider entries or repeated top-level provider fields. Initial proposed shape: @@ -221,9 +226,7 @@ Do not port legacy provider model `reasoning`, `temperature`, or `interleaved` f "api_id": "upstream-chat-model", "limit": { "output": 32768 }, "cost": { "input": 1.25, "output": 10 }, - "variants": [ - { "id": "high", "aisdk": { "request": { "reasoningEffort": "high" } } }, - ], + "variants": [{ "id": "high", "aisdk": { "request": { "reasoningEffort": "high" } } }], }, }, }, @@ -235,13 +238,13 @@ Do not port legacy provider model `reasoning`, `temperature`, or `interleaved` f Agent behavior and tool-access policy. Review together because agent configuration can contain permissions and model choices. -| Field | Current Purpose | Status | Notes | -| --------------- | --------------------------------------------------- | ------- | ------------------------------------------- | -| `default_agent` | Choose default primary agent | remove | Do not retain a separate top-level selector; default choice should be designed with the v2 agent configuration model. | -| `mode` | Legacy agent configuration alias | remove | Do not port deprecated alias; configure agents through the v2 agent surface only. | -| `agent` | Configure primary, subagent, and specialized agents | redesign | Rename to plural `agents`; retain a named map of built-in overrides and custom agent definitions. | +| Field | Current Purpose | Status | Notes | +| --------------- | --------------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------ | +| `default_agent` | Choose default primary agent | remove | Do not retain a separate top-level selector; default choice should be designed with the v2 agent configuration model. | +| `mode` | Legacy agent configuration alias | remove | Do not port deprecated alias; configure agents through the v2 agent surface only. | +| `agent` | Configure primary, subagent, and specialized agents | redesign | Rename to plural `agents`; retain a named map of built-in overrides and custom agent definitions. | | `permission` | Tool permission rules | redesign | Rename to plural `permissions`; replace legacy map shorthand with an ordered array of `{ permission, pattern, action }` rules. | -| `tools` | Legacy tool enable/disable map | remove | Do not port boolean enable/disable alias; express tool access through permissions. | +| `tools` | Legacy tool enable/disable map | remove | Do not port boolean enable/disable alias; express tool access through permissions. | Do not port `default_agent` ahead of the v2 agent design. The legacy runtime uses it to choose a visible, non-subagent fallback instead of `build`, but exposing that selection as an isolated top-level field would pre-commit v2 to the legacy agent model before agents and their policy surface are defined together. @@ -278,9 +281,7 @@ Retain `description`, `hidden`, and `steps`; they define an agent's discoverabil "color": "warning", "steps": 12, "disabled": false, - "permissions": [ - { "permission": "edit", "pattern": "*", "action": "deny" }, - ], + "permissions": [{ "permission": "edit", "pattern": "*", "action": "deny" }], }, }, } @@ -303,8 +304,8 @@ Rename legacy `permission` to `permissions` and expose the normalized ordered ru External protocol and server integration configuration. -| Field | Current Purpose | Status | Notes | -| ----- | ------------------------------------- | ------- | ----- | +| Field | Current Purpose | Status | Notes | +| ----- | ------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | | `mcp` | MCP server definitions and enablement | redesign | Keep opencode's explicit local/remote server entry format, nested under `mcp.servers`; use `disabled` for inactive entries and move timeout here. | Keep the opencode MCP server entry format instead of adopting the common `mcpServers` copy/paste shape. Local servers remain explicit `type: "local"` entries with command arrays and `environment`; remote servers remain explicit `type: "remote"` entries with `url`, `headers`, and optional `oauth`. Nest the server map under `mcp.servers` so protocol-wide settings such as default timeout can live under the same subsystem. @@ -343,8 +344,8 @@ Keep the opencode MCP server entry format instead of adopting the common `mcpSer Behavior affecting long-running conversations and context management. -| Field | Current Purpose | Status | Notes | -| ------------ | ----------------------------------------------------------- | ------- | ----- | +| Field | Current Purpose | Status | Notes | +| ------------ | ----------------------------------------------------------- | -------- | ------------------------------------------------------------------------------------- | | `compaction` | Automatic compaction, pruning, and context reserve settings | redesign | Group retained verbatim history under `keep` and rename context headroom to `buffer`. | Retain the compaction capability but redesign the less clear limits. `keep.turns` is the maximum number of recent user turns to preserve verbatim after compaction, and `keep.tokens` is the token budget for those retained turns. `buffer` is the token headroom reserved so automatic compaction triggers before the input window is exhausted. @@ -367,15 +368,15 @@ Retain the compaction capability but redesign the less clear limits. `keep.turns Fields that should not be ported by inertia; each needs an explicit justification. -| Field | Current Purpose | Status | Notes | -| ------------------------------------ | --------------------------------------- | ------- | ------------------------------------------------------------------- | -| `layout` | Legacy layout selection | remove | Do not port deprecated option; stretch layout is always used. | -| `experimental.disable_paste_summary` | Disable pasted-content summary behavior | remove | Do not port; pasted-input presentation behavior belongs to the client/UI surface. | -| `experimental.batch_tool` | Enable batch tool | remove | Do not port; batch tool is no longer a supported feature. | -| `experimental.openTelemetry` | Enable AI SDK telemetry spans | remove | Do not port; observability is process-level and should use standard OpenTelemetry environment or declarative configuration. | -| `experimental.primary_tools` | Restrict tools to primary agents | remove | Do not port obsolete gating; agent tool access is configured through permissions. | -| `experimental.continue_loop_on_deny` | Continue loop after denied tool call | remove | Do not port legacy denied-tool loop behavior. | -| `experimental.mcp_timeout` | MCP request timeout | redesign | Move to `mcp.timeout` for the default and `mcp.servers..timeout` for per-server overrides. | +| Field | Current Purpose | Status | Notes | +| ------------------------------------ | --------------------------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------- | +| `layout` | Legacy layout selection | remove | Do not port deprecated option; stretch layout is always used. | +| `experimental.disable_paste_summary` | Disable pasted-content summary behavior | remove | Do not port; pasted-input presentation behavior belongs to the client/UI surface. | +| `experimental.batch_tool` | Enable batch tool | remove | Do not port; batch tool is no longer a supported feature. | +| `experimental.openTelemetry` | Enable AI SDK telemetry spans | remove | Do not port; observability is process-level and should use standard OpenTelemetry environment or declarative configuration. | +| `experimental.primary_tools` | Restrict tools to primary agents | remove | Do not port obsolete gating; agent tool access is configured through permissions. | +| `experimental.continue_loop_on_deny` | Continue loop after denied tool call | remove | Do not port legacy denied-tool loop behavior. | +| `experimental.mcp_timeout` | MCP request timeout | redesign | Move to `mcp.timeout` for the default and `mcp.servers..timeout` for per-server overrides. | ## Review Order From 1ceb5dbd56950fde1e417f3a754615350daf3666 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sat, 30 May 2026 04:19:30 +0000 Subject: [PATCH 004/840] chore: update nix node_modules hashes --- nix/hashes.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/nix/hashes.json b/nix/hashes.json index dbec36dc8a..ab320afa56 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-8SdC+4/wZP4Zsco9pt+a9al9MTl+t7RXyDNjJ50pdDo=", - "aarch64-linux": "sha256-lexzZyM0kV1LQSHef7yv7p0bmttYw4y0Oxj9e687eQk=", - "aarch64-darwin": "sha256-Ru3+YPqDauEREew4r2INhay+0eyXjnZ0Z7NuW+pbzBk=", - "x86_64-darwin": "sha256-1TLZjb5qvSVVZx7Pv0esHcXJxNx8/7VPG7Li2+ey0x0=" + "x86_64-linux": "sha256-hYuu1JeGwIKNhZx8mJDh7w7crEOSVWNbSkGld/9+gy8=", + "aarch64-linux": "sha256-vONZPCYWVi/erOJdoJIQQ8IyVgRoWQfU5EAW5b/DmaE=", + "aarch64-darwin": "sha256-6o6pwR4ouPDbwipnhLSMTHxroObIGkSyk+Zi3TEvKxs=", + "x86_64-darwin": "sha256-X3KzfM3R4nVnJBiqTR03oUXVZoxmM4Y8sTeDf+e3M+A=" } } From 181e58f50aee8cdb1fb0eb4dd73cd103fe7d62ee Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Fri, 29 May 2026 23:12:12 -0400 Subject: [PATCH 005/840] feat(cli): add effect cli scaffold --- bun.lock | 24 +++++++++++++++++++++--- packages/cli/package.json | 24 ++++++++++++++++++++++++ packages/cli/src/index.ts | 15 +++++++++++++++ packages/cli/tsconfig.json | 7 +++++++ 4 files changed, 67 insertions(+), 3 deletions(-) create mode 100644 packages/cli/package.json create mode 100755 packages/cli/src/index.ts create mode 100644 packages/cli/tsconfig.json diff --git a/bun.lock b/bun.lock index d787d6f55d..493ce5c39e 100644 --- a/bun.lock +++ b/bun.lock @@ -83,6 +83,22 @@ "vite-plugin-solid": "catalog:", }, }, + "packages/cli": { + "name": "@opencode-ai/cli", + "version": "1.15.12", + "bin": { + "opencode": "./src/index.ts", + }, + "dependencies": { + "@effect/platform-node": "catalog:", + "effect": "catalog:", + }, + "devDependencies": { + "@tsconfig/bun": "catalog:", + "@types/bun": "catalog:", + "@typescript/native-preview": "catalog:", + }, + }, "packages/console/app": { "name": "@opencode-ai/console-app", "version": "1.15.12", @@ -1647,6 +1663,8 @@ "@opencode-ai/app": ["@opencode-ai/app@workspace:packages/app"], + "@opencode-ai/cli": ["@opencode-ai/cli@workspace:packages/cli"], + "@opencode-ai/console-app": ["@opencode-ai/console-app@workspace:packages/console/app"], "@opencode-ai/console-core": ["@opencode-ai/console-core@workspace:packages/console/core"], @@ -4957,7 +4975,7 @@ "uncrypto": ["uncrypto@0.1.3", "", {}, "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q=="], - "undici": ["undici@7.25.0", "", {}, "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ=="], + "undici": ["undici@8.1.0", "", {}, "sha512-E9MkTS4xXLnRPYqxH2e6Hr2/49e7WFDKczKcCaFH4VaZs2iNvHMqeIkyUAD9vM8kujy9TjVrRlQ5KkdEJxB2pw=="], "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], @@ -5509,8 +5527,6 @@ "@dot/log/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], - "@effect/platform-node/undici": ["undici@8.1.0", "", {}, "sha512-E9MkTS4xXLnRPYqxH2e6Hr2/49e7WFDKczKcCaFH4VaZs2iNvHMqeIkyUAD9vM8kujy9TjVrRlQ5KkdEJxB2pw=="], - "@effect/platform-node-shared/ws": ["ws@8.20.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="], "@electron/asar/commander": ["commander@5.1.0", "", {}, "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg=="], @@ -5985,6 +6001,8 @@ "nitro/h3": ["h3@2.0.1-rc.5", "", { "dependencies": { "rou3": "^0.7.9", "srvx": "^0.9.1" }, "peerDependencies": { "crossws": "^0.4.1" }, "optionalPeers": ["crossws"] }, "sha512-qkohAzCab0nLzXNm78tBjZDvtKMTmtygS8BJLT3VPczAQofdqlFXDPkXdLMJN4r05+xqneG8snZJ0HgkERCZTg=="], + "nitro/undici": ["undici@7.25.0", "", {}, "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ=="], + "node-gyp-build-optional-packages/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], "npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="], diff --git a/packages/cli/package.json b/packages/cli/package.json new file mode 100644 index 0000000000..20eeed3727 --- /dev/null +++ b/packages/cli/package.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "name": "@opencode-ai/cli", + "version": "1.15.12", + "private": true, + "type": "module", + "license": "MIT", + "bin": { + "opencode": "./src/index.ts" + }, + "scripts": { + "dev": "bun run src/index.ts", + "typecheck": "tsgo --noEmit" + }, + "dependencies": { + "@effect/platform-node": "catalog:", + "effect": "catalog:" + }, + "devDependencies": { + "@tsconfig/bun": "catalog:", + "@types/bun": "catalog:", + "@typescript/native-preview": "catalog:" + } +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts new file mode 100755 index 0000000000..6f5b5fa713 --- /dev/null +++ b/packages/cli/src/index.ts @@ -0,0 +1,15 @@ +#!/usr/bin/env bun + +import * as NodeRuntime from "@effect/platform-node/NodeRuntime" +import * as NodeServices from "@effect/platform-node/NodeServices" +import * as Effect from "effect/Effect" +import * as Command from "effect/unstable/cli/Command" + +const cli = Command.make("opencode", {}, () => Effect.void).pipe( + Command.withDescription("OpenCode command line interface"), +) + +Command.run(cli, { version: "local" }).pipe( + Effect.provide(NodeServices.layer), + NodeRuntime.runMain, +) diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json new file mode 100644 index 0000000000..fe5c4d217b --- /dev/null +++ b/packages/cli/tsconfig.json @@ -0,0 +1,7 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "@tsconfig/bun/tsconfig.json", + "compilerOptions": { + "noUncheckedIndexedAccess": false + } +} From f6062bd5b67cab6112a2aab63ad3ec0685aaed7e Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sat, 30 May 2026 00:49:20 -0400 Subject: [PATCH 006/840] feat(core): add default agent plugin --- packages/core/src/agent.ts | 9 +- packages/core/src/config/plugin/agent.ts | 3 +- packages/core/src/plugin/agent.ts | 220 +++++++++++++++++++++++ packages/core/src/plugin/boot.ts | 6 + packages/core/test/agent.test.ts | 14 +- 5 files changed, 239 insertions(+), 13 deletions(-) create mode 100644 packages/core/src/plugin/agent.ts diff --git a/packages/core/src/agent.ts b/packages/core/src/agent.ts index 55d95ef77c..ec7dfa2ade 100644 --- a/packages/core/src/agent.ts +++ b/packages/core/src/agent.ts @@ -1,6 +1,6 @@ export * as AgentV2 from "./agent" -import { Array, Context, Effect, Layer, Schema } from "effect" +import { Array, Context, Effect, Layer, Schema, Scope } from "effect" import { castDraft, enableMapSet, type Draft } from "immer" import { ModelV2 } from "./model" import { PermissionV2 } from "./permission" @@ -59,7 +59,7 @@ export type Editor = { export interface Interface { readonly transform: State.Interface["transform"] - readonly update: State.Interface["update"] + readonly update: (update: State.Transform) => Effect.Effect readonly get: (id: ID) => Effect.Effect readonly all: () => Effect.Effect } @@ -90,7 +90,10 @@ export const layer = Layer.effect( return Service.of({ transform: state.transform, - update: state.update, + update: Effect.fn("AgentV2.update")(function* (update) { + const transform = yield* state.transform() + yield* transform(update) + }), get: Effect.fn("AgentV2.get")(function* (id) { return state.get().agents.get(id) }), diff --git a/packages/core/src/config/plugin/agent.ts b/packages/core/src/config/plugin/agent.ts index be2aca6297..c05b0a578f 100644 --- a/packages/core/src/config/plugin/agent.ts +++ b/packages/core/src/config/plugin/agent.ts @@ -12,10 +12,9 @@ export const Plugin = PluginV2.define({ effect: Effect.gen(function* () { const agent = yield* AgentV2.Service const config = yield* Config.Service - const transform = yield* agent.transform() const files = yield* config.get() - yield* transform((editor) => { + yield* agent.update((editor) => { const permissions = new Map() for (const file of files) { diff --git a/packages/core/src/plugin/agent.ts b/packages/core/src/plugin/agent.ts new file mode 100644 index 0000000000..6780b98853 --- /dev/null +++ b/packages/core/src/plugin/agent.ts @@ -0,0 +1,220 @@ +export * as AgentPlugin from "./agent" + +import path from "path" +import { Effect } from "effect" +import { AgentV2 } from "../agent" +import { Global } from "../global" +import { Location } from "../location" +import { PermissionV2 } from "../permission" +import { PluginV2 } from "../plugin" + +const TRUNCATION_GLOB = path.join(Global.Path.data, "tool-output", "*") + +const PROMPT_EXPLORE = `You are a file search specialist. You excel at thoroughly navigating and exploring codebases. + +Your strengths: +- Rapidly finding files using glob patterns +- Searching code and text with powerful regex patterns +- Reading and analyzing file contents + +Guidelines: +- Use Glob for broad file pattern matching +- Use Grep for searching file contents with regex +- Use Read when you know the specific file path you need to read +- Use Bash for file operations like copying, moving, or listing directory contents +- Adapt your search approach based on the thoroughness level specified by the caller +- Return file paths as absolute paths in your final response +- For clear communication, avoid using emojis +- Do not create any files, or run bash commands that modify the user's system state in any way + +Complete the user's search request efficiently and report your findings clearly.` + +const PROMPT_COMPACTION = `You are an anchored context summarization assistant for coding sessions. + +Summarize only the conversation history you are given. The newest turns may be kept verbatim outside your summary, so focus on the older context that still matters for continuing the work. + +If the prompt includes a block, treat it as the current anchored summary. Update it with the new history by preserving still-true details, removing stale details, and merging in new facts. + +Always follow the exact output structure requested by the user prompt. Keep every section, preserve exact file paths and identifiers when known, and prefer terse bullets over paragraphs. + +Do not answer the conversation itself. Do not mention that you are summarizing, compacting, or merging context. Respond in the same language as the conversation.` + +const PROMPT_TITLE = `You are a title generator. You output ONLY a thread title. Nothing else. + + +Generate a brief title that would help the user find this conversation later. + +Follow all rules in +Use the so you know what a good title looks like. +Your output must be: +- A single line +- <=50 characters +- No explanations + + + +- you MUST use the same language as the user message you are summarizing +- Title must be grammatically correct and read naturally - no word salad +- Never include tool names in the title (e.g. "read tool", "bash tool", "edit tool") +- Focus on the main topic or question the user needs to retrieve +- Vary your phrasing - avoid repetitive patterns like always starting with "Analyzing" +- When a file is mentioned, focus on WHAT the user wants to do WITH the file, not just that they shared it +- Keep exact: technical terms, numbers, filenames, HTTP codes +- Remove: the, this, my, a, an +- Never assume tech stack +- Never use tools +- NEVER respond to questions, just generate a title for the conversation +- The title should NEVER include "summarizing" or "generating" when generating a title +- DO NOT SAY YOU CANNOT GENERATE A TITLE OR COMPLAIN ABOUT THE INPUT +- Always output something meaningful, even if the input is minimal. +- If the user message is short or conversational (e.g. "hello", "lol", "what's up", "hey"): + -> create a title that reflects the user's tone or intent (such as Greeting, Quick check-in, Light chat, Intro message, etc.) + + + +"debug 500 errors in production" -> Debugging production 500 errors +"refactor user service" -> Refactoring user service +"why is app.js failing" -> app.js failure investigation +"implement rate limiting" -> Rate limiting implementation +"how do I connect postgres to my API" -> Postgres API connection +"best practices for React hooks" -> React hooks best practices +"@src/auth.ts can you add refresh token support" -> Auth refresh token support +"@utils/parser.ts this is broken" -> Parser bug fix +"look at @config.json" -> Config review +"@App.tsx add dark mode toggle" -> Dark mode toggle in App +` + +const PROMPT_SUMMARY = `Summarize what was done in this conversation. Write like a pull request description. + +Rules: +- 2-3 sentences max +- Describe the changes made, not the process +- Do not mention running tests, builds, or other validation steps +- Do not explain what the user asked for +- Write in first person (I added..., I fixed...) +- Never ask questions or add new questions +- If the conversation ends with an unanswered question to the user, preserve that exact question +- If the conversation ends with an imperative statement or request to the user (e.g. "Now please run the command and paste the console output"), always include that exact request in the summary` + +export const Plugin = PluginV2.define({ + id: PluginV2.ID.make("agent"), + effect: Effect.gen(function* () { + const agent = yield* AgentV2.Service + const location = yield* Location.Service + const worktree = location.directory + const whitelistedDirs = [TRUNCATION_GLOB, path.join(Global.Path.tmp, "*")] + const readonlyExternalDirectory = rules({ + external_directory: { + "*": "ask", + ...Object.fromEntries(whitelistedDirs.map((dir) => [dir, "allow"])), + }, + }) + const defaults = rules({ + "*": "allow", + doom_loop: "ask", + external_directory: { + "*": "ask", + ...Object.fromEntries(whitelistedDirs.map((dir) => [dir, "allow"])), + }, + question: "deny", + plan_enter: "deny", + plan_exit: "deny", + repo_clone: "deny", + repo_overview: "deny", + read: { + "*": "allow", + "*.env": "ask", + "*.env.*": "ask", + "*.env.example": "allow", + }, + }) + + yield* agent.update((editor) => { + editor.update(AgentV2.ID.make("build"), (item) => { + item.description = "The default agent. Executes tools based on configured permissions." + item.mode = "primary" + item.permissions.push(...PermissionV2.merge(defaults, rules({ question: "allow", plan_enter: "allow" }))) + }) + + editor.update(AgentV2.ID.make("plan"), (item) => { + item.description = "Plan mode. Disallows all edit tools." + item.mode = "primary" + item.permissions.push( + ...PermissionV2.merge( + defaults, + rules({ + question: "allow", + plan_exit: "allow", + external_directory: { + [path.join(Global.Path.data, "plans", "*")]: "allow", + }, + edit: { + "*": "deny", + [path.join(".opencode", "plans", "*.md")]: "allow", + [path.relative(worktree, path.join(Global.Path.data, "plans", "*.md"))]: "allow", + }, + }), + ), + ) + }) + + editor.update(AgentV2.ID.make("general"), (item) => { + item.description = + "General-purpose agent for researching complex questions and executing multi-step tasks. Use this agent to execute multiple units of work in parallel." + item.mode = "subagent" + item.permissions.push(...PermissionV2.merge(defaults, rules({ todowrite: "deny" }))) + }) + + editor.update(AgentV2.ID.make("explore"), (item) => { + item.description = + 'Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns (eg. "src/components/**/*.tsx"), search code for keywords (eg. "API endpoints"), or answer questions about the codebase (eg. "how do API endpoints work?"). When calling this agent, specify the desired thoroughness level: "quick" for basic searches, "medium" for moderate exploration, or "very thorough" for comprehensive analysis across multiple locations and naming conventions.' + item.system = PROMPT_EXPLORE + item.mode = "subagent" + item.permissions.push( + ...PermissionV2.merge( + defaults, + rules({ + "*": "deny", + grep: "allow", + glob: "allow", + list: "allow", + bash: "allow", + webfetch: "allow", + websearch: "allow", + read: "allow", + }), + readonlyExternalDirectory, + ), + ) + }) + + editor.update(AgentV2.ID.make("compaction"), (item) => { + item.mode = "primary" + item.hidden = true + item.system = PROMPT_COMPACTION + item.permissions.push(...PermissionV2.merge(defaults, rules({ "*": "deny" }))) + }) + + editor.update(AgentV2.ID.make("title"), (item) => { + item.mode = "primary" + item.hidden = true + item.system = PROMPT_TITLE + item.permissions.push(...PermissionV2.merge(defaults, rules({ "*": "deny" }))) + }) + + editor.update(AgentV2.ID.make("summary"), (item) => { + item.mode = "primary" + item.hidden = true + item.system = PROMPT_SUMMARY + item.permissions.push(...PermissionV2.merge(defaults, rules({ "*": "deny" }))) + }) + }) + }), +}) + +function rules(input: Record>) { + return Object.entries(input).flatMap(([permission, value]) => { + if (typeof value === "string") return [{ permission, pattern: "*", action: value }] + return Object.entries(value).map(([pattern, action]) => ({ permission, pattern, action })) + }) +} diff --git a/packages/core/src/plugin/boot.ts b/packages/core/src/plugin/boot.ts index 5b70e391d7..4456ac1908 100644 --- a/packages/core/src/plugin/boot.ts +++ b/packages/core/src/plugin/boot.ts @@ -7,9 +7,11 @@ import { Catalog } from "../catalog" import { Config } from "../config" import { ConfigAgentPlugin } from "../config/plugin/agent" import { EventV2 } from "../event" +import { Location } from "../location" import { Npm } from "../npm" import { PluginV2 } from "../plugin" import { AccountPlugin } from "./account" +import { AgentPlugin } from "./agent" import { ConfigProviderPlugin } from "../config/plugin/provider" import { EnvPlugin } from "./env" import { ModelsDevPlugin } from "./models-dev" @@ -23,6 +25,7 @@ type Plugin = { | AgentV2.Service | Npm.Service | EventV2.Service + | Location.Service | PluginV2.Service | Config.Service > @@ -42,6 +45,7 @@ export const layer = Layer.effect( const accounts = yield* AccountV2.Service const agents = yield* AgentV2.Service const config = yield* Config.Service + const location = yield* Location.Service const npm = yield* Npm.Service const events = yield* EventV2.Service const done = yield* Deferred.make() @@ -54,6 +58,7 @@ export const layer = Layer.effect( Effect.provideService(AccountV2.Service, accounts), Effect.provideService(AgentV2.Service, agents), Effect.provideService(Config.Service, config), + Effect.provideService(Location.Service, location), Effect.provideService(Npm.Service, npm), Effect.provideService(EventV2.Service, events), Effect.provideService(PluginV2.Service, plugin), @@ -64,6 +69,7 @@ export const layer = Layer.effect( const boot = Effect.gen(function* () { yield* add(EnvPlugin) yield* add(AccountPlugin) + yield* add(AgentPlugin.Plugin) for (const item of ProviderPlugins) { yield* add(item) } diff --git a/packages/core/test/agent.test.ts b/packages/core/test/agent.test.ts index f4b7867a81..c59449e938 100644 --- a/packages/core/test/agent.test.ts +++ b/packages/core/test/agent.test.ts @@ -76,12 +76,10 @@ describe("AgentV2", () => { const id = AgentV2.ID.make("build") yield* agent.update((editor) => - Effect.sync(() => - editor.update(id, (info) => { - info.mode = "primary" - info.hidden = true - }), - ), + editor.update(id, (info) => { + info.mode = "primary" + info.hidden = true + }), ) expect(yield* agent.get(id)).toMatchObject({ id, mode: "primary", hidden: true }) @@ -93,10 +91,10 @@ describe("AgentV2", () => { const agent = yield* AgentV2.Service const id = AgentV2.ID.make("custom") - yield* agent.update((editor) => Effect.sync(() => editor.update(id, () => {}))) + yield* agent.update((editor) => editor.update(id, () => {})) expect(yield* agent.get(id)).toEqual(AgentV2.Info.empty(id)) - yield* agent.update((editor) => Effect.sync(() => editor.remove(id))) + yield* agent.update((editor) => editor.remove(id)) expect(yield* agent.get(id)).toBeUndefined() }), ) From 0d2508ffd54fdf2b24aaaeed86518f14bc4ce8c7 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sat, 30 May 2026 04:51:39 +0000 Subject: [PATCH 007/840] chore: generate --- packages/cli/src/index.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 6f5b5fa713..51a4dfec8f 100755 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -9,7 +9,4 @@ const cli = Command.make("opencode", {}, () => Effect.void).pipe( Command.withDescription("OpenCode command line interface"), ) -Command.run(cli, { version: "local" }).pipe( - Effect.provide(NodeServices.layer), - NodeRuntime.runMain, -) +Command.run(cli, { version: "local" }).pipe(Effect.provide(NodeServices.layer), NodeRuntime.runMain) From bcc345ff82bb46e231b128783533335170227a47 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sat, 30 May 2026 05:09:31 +0000 Subject: [PATCH 008/840] chore: update nix node_modules hashes --- nix/hashes.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/nix/hashes.json b/nix/hashes.json index ab320afa56..2486793a1f 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-hYuu1JeGwIKNhZx8mJDh7w7crEOSVWNbSkGld/9+gy8=", - "aarch64-linux": "sha256-vONZPCYWVi/erOJdoJIQQ8IyVgRoWQfU5EAW5b/DmaE=", - "aarch64-darwin": "sha256-6o6pwR4ouPDbwipnhLSMTHxroObIGkSyk+Zi3TEvKxs=", - "x86_64-darwin": "sha256-X3KzfM3R4nVnJBiqTR03oUXVZoxmM4Y8sTeDf+e3M+A=" + "x86_64-linux": "sha256-rSvAFm+SqMEIcAvOLM7h3Pf01mBU4cNtbMcAG540ktM=", + "aarch64-linux": "sha256-mZrl5McFin9CnjnkoP8N5Xosg5lrJP+YWEP0p0xoYu0=", + "aarch64-darwin": "sha256-P43M7Thg8V8XgW1y8QsTJ0ZkRTlrBR07RdBZKAupdqo=", + "x86_64-darwin": "sha256-mveMGDh1lptCJD05Cb34VRyBWAbz1PlHIOOZtrWihic=" } } From 51a9b0baad7dfb7ca0b3b7bbc99e1a157f00897d Mon Sep 17 00:00:00 2001 From: Frank Date: Fri, 29 May 2026 19:06:15 -0400 Subject: [PATCH 009/840] zen: log ip prefix --- .../console/function/src/log-processor.ts | 34 +++++++++++++++---- 1 file changed, 27 insertions(+), 7 deletions(-) diff --git a/packages/console/function/src/log-processor.ts b/packages/console/function/src/log-processor.ts index 25e1838ed7..65d6832c3e 100644 --- a/packages/console/function/src/log-processor.ts +++ b/packages/console/function/src/log-processor.ts @@ -21,6 +21,7 @@ export default { ) continue + const ip = event.event.request.headers["x-real-ip"] let data: Record = { "cf.continent": event.event.request.cf?.continent, "cf.country": event.event.request.cf?.country, @@ -32,7 +33,8 @@ export default { duration: event.wallTime, request_length: parseInt(event.event.request.headers["content-length"] ?? "0"), status: event.event.response?.status ?? 0, - ip: event.event.request.headers["x-real-ip"], + ip, + "ip.prefix": ipPrefix(ip), } const time = new Date(event.eventTimestamp ?? Date.now()).toISOString() const events = [ @@ -110,6 +112,7 @@ function toLakeEvent(time: string, data: Record) { request_length: integer(data, "request_length"), status: integer(data, "status"), ip: string(data, "ip"), + ip_prefix: string(data, "ip.prefix"), is_stream: boolean(data, "is_stream"), session: string(data, "session"), request: string(data, "request"), @@ -146,15 +149,32 @@ function toLakeEvent(time: string, data: Record) { cost_cache_read_microcents: integer(data, "cost.cache_read.microcents"), cost_cache_write_microcents: integer(data, "cost.cache_write.microcents"), cost_total_microcents: integer(data, "cost.total.microcents"), - cost_input: integer(data, "cost.input"), - cost_output: integer(data, "cost.output"), - cost_cache_read: integer(data, "cost.cache_read"), - cost_cache_write_5m: integer(data, "cost.cache_write_5m"), - cost_cache_write_1h: integer(data, "cost.cache_write_1h"), - cost_total: integer(data, "cost.total"), } } +// Returns a stable lookup key for an IP address. +// IPv4: full address as /32 (e.g. "203.0.113.45/32"). +// IPv6: the /64 network prefix (e.g. "2001:db8:abcd:1234::/64"). ISPs commonly +// rotate the lower 64 host bits via SLAAC privacy extensions (RFC 8981), so +// grouping by /64 collapses those rotations into one key. +function ipPrefix(ip: string | undefined) { + if (!ip) return undefined + if (ip.includes(".") && !ip.includes(":")) return `${ip}/32` + if (!ip.includes(":")) return undefined + + // Expand "::" to its full form, then keep the first 4 hextets. + const [head, tail] = ip.split("::") as [string, string | undefined] + const headParts = head ? head.split(":") : [] + const tailParts = tail !== undefined ? tail.split(":") : [] + const missing = 8 - headParts.length - tailParts.length + if (missing < 0) return undefined + const full = [...headParts, ...new Array(missing).fill("0"), ...tailParts] + if (full.length !== 8) return undefined + + const prefix = full.slice(0, 4).map((part) => part.toLowerCase().replace(/^0+(?=.)/, "")).join(":") + return `${prefix}::/64` +} + function string(data: Record, key: string) { const value = data[key] if (typeof value === "string") return value From 04c46117b9ca06bd627876f99681bce87baf9339 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sat, 30 May 2026 05:13:21 +0000 Subject: [PATCH 010/840] chore: generate --- packages/console/function/src/log-processor.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/console/function/src/log-processor.ts b/packages/console/function/src/log-processor.ts index 65d6832c3e..b499a2c874 100644 --- a/packages/console/function/src/log-processor.ts +++ b/packages/console/function/src/log-processor.ts @@ -171,7 +171,10 @@ function ipPrefix(ip: string | undefined) { const full = [...headParts, ...new Array(missing).fill("0"), ...tailParts] if (full.length !== 8) return undefined - const prefix = full.slice(0, 4).map((part) => part.toLowerCase().replace(/^0+(?=.)/, "")).join(":") + const prefix = full + .slice(0, 4) + .map((part) => part.toLowerCase().replace(/^0+(?=.)/, "")) + .join(":") return `${prefix}::/64` } From 6a2cd816b76252c8d999fe74d08a7c915541a4a3 Mon Sep 17 00:00:00 2001 From: vimtor Date: Sat, 30 May 2026 10:52:21 +0200 Subject: [PATCH 011/840] core: credit referral invites on first Lite checkout Applies saved invites during Lite checkout so users who skip the referral page still credit the inviter. Avoids crediting already-active Lite workspaces so rewards stay tied to the first upgrade. --- .../console/app/src/component/go-referral.tsx | 16 ++-------------- .../console/app/src/lib/referral-invite.ts | 16 ++++++++++++++++ .../routes/workspace/[id]/go/lite-section.tsx | 18 ++++++++++-------- packages/console/core/src/referral.ts | 7 +++++++ 4 files changed, 35 insertions(+), 22 deletions(-) diff --git a/packages/console/app/src/component/go-referral.tsx b/packages/console/app/src/component/go-referral.tsx index d7ba1d7d51..4223dfd47c 100644 --- a/packages/console/app/src/component/go-referral.tsx +++ b/packages/console/app/src/component/go-referral.tsx @@ -2,7 +2,6 @@ import { action, json, query, useAction, useSubmission } from "@solidjs/router" import { createEffect, createMemo, createSignal, For, onCleanup, Show } from "solid-js" import { getRequestEvent } from "solid-js/web" import { Referral } from "@opencode-ai/console-core/referral.js" -import { Actor } from "@opencode-ai/console-core/actor.js" import { withActor } from "~/context/auth.withActor" import { Modal } from "~/component/modal" import { IconCheck, IconCopy } from "~/component/icon" @@ -10,7 +9,7 @@ import { useI18n } from "~/context/i18n" import { useLanguage } from "~/context/language" import { formatResetTime, liteResetTimeKeys } from "~/lib/format-reset-time" import { queryLiteSubscription } from "~/routes/workspace/[id]/go/lite-section" -import { clearReferralCookie, referralCodeFromCookieHeader } from "~/lib/referral-invite" +import { createReferralFromCookie } from "~/lib/referral-invite" import "./go-referral.css" type GoReferralSummary = Awaited> @@ -28,18 +27,7 @@ const emptyUsagePreview = { export const queryGoReferral = query(async (workspaceID: string) => { "use server" return withActor(async () => { - const event = getRequestEvent() - const referralCode = referralCodeFromCookieHeader(event?.request.headers.get("cookie") ?? null) - if (referralCode) { - await Referral.createFromAccount({ - accountID: Actor.account(), - referralCode, - }).catch((error) => { - console.error("Referral create failed", error) - }) - event?.response.headers.append("set-cookie", clearReferralCookie()) - } - + await createReferralFromCookie() return Referral.summary() }, workspaceID) }, "go.referral.get") diff --git a/packages/console/app/src/lib/referral-invite.ts b/packages/console/app/src/lib/referral-invite.ts index b17a0fc3c5..dc83ff0f4c 100644 --- a/packages/console/app/src/lib/referral-invite.ts +++ b/packages/console/app/src/lib/referral-invite.ts @@ -1,4 +1,6 @@ +import { Actor } from "@opencode-ai/console-core/actor.js" import { Referral } from "@opencode-ai/console-core/referral.js" +import { getRequestEvent } from "solid-js/web" const REFERRAL_COOKIE = "oc_referral" const REFERRAL_MAX_AGE = 60 * 60 * 24 * 30 @@ -26,3 +28,17 @@ export function referralCodeFromCookieHeader(header: string | null) { ?.slice(`${REFERRAL_COOKIE}=`.length), ) } + +export async function createReferralFromCookie() { + const event = getRequestEvent() + const referralCode = referralCodeFromCookieHeader(event?.request.headers.get("cookie") ?? null) + if (!referralCode) return + + await Referral.createFromAccount({ + accountID: Actor.account(), + referralCode, + }).catch((error) => { + console.error("Referral create failed", error) + }) + event?.response.headers.append("set-cookie", clearReferralCookie()) +} diff --git a/packages/console/app/src/routes/workspace/[id]/go/lite-section.tsx b/packages/console/app/src/routes/workspace/[id]/go/lite-section.tsx index ab12c59697..50f10b9909 100644 --- a/packages/console/app/src/routes/workspace/[id]/go/lite-section.tsx +++ b/packages/console/app/src/routes/workspace/[id]/go/lite-section.tsx @@ -15,6 +15,7 @@ import { useI18n } from "~/context/i18n" import { useLanguage } from "~/context/language" import { formError } from "~/lib/form-error" import { formatResetTime, liteResetTimeKeys } from "~/lib/format-reset-time" +import { createReferralFromCookie } from "~/lib/referral-invite" import { IconAlipay, IconUpi } from "~/component/icon" @@ -75,15 +76,16 @@ const createLiteCheckoutUrl = action( "use server" return json( await withActor( - () => - Billing.generateLiteCheckoutUrl({ successUrl, cancelUrl, method }) - .then((data) => ({ error: undefined, data })) - .catch((e) => ({ - error: e.message as string, - data: undefined, - })), + async () => { + const data = await Billing.generateLiteCheckoutUrl({ successUrl, cancelUrl, method }) + await createReferralFromCookie() + return { error: undefined, data } + }, workspaceID, - ), + ).catch((e) => ({ + error: e.message as string, + data: undefined, + })), { revalidate: [queryBillingInfo.key, queryLiteSubscription.key] }, ) }, diff --git a/packages/console/core/src/referral.ts b/packages/console/core/src/referral.ts index 96277ca3f1..9f781df858 100644 --- a/packages/console/core/src/referral.ts +++ b/packages/console/core/src/referral.ts @@ -325,6 +325,13 @@ export namespace Referral { .then((rows) => rows.map((row) => row.workspaceID)) if (workspaceIDs.length === 0) return + const activeLite = await tx + .select({ id: LiteTable.id }) + .from(LiteTable) + .where(and(inArray(LiteTable.workspaceID, workspaceIDs), isNull(LiteTable.timeDeleted))) + .then((rows) => rows[0]) + if (activeLite) return + const litePayment = await tx .select({ id: PaymentTable.id }) .from(PaymentTable) From 69b2784b13b66e79030ac0ecef5ec87a95f5a33f Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sat, 30 May 2026 08:56:10 +0000 Subject: [PATCH 012/840] chore: generate --- .../src/routes/workspace/[id]/go/lite-section.tsx | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/packages/console/app/src/routes/workspace/[id]/go/lite-section.tsx b/packages/console/app/src/routes/workspace/[id]/go/lite-section.tsx index 50f10b9909..da7bdc2208 100644 --- a/packages/console/app/src/routes/workspace/[id]/go/lite-section.tsx +++ b/packages/console/app/src/routes/workspace/[id]/go/lite-section.tsx @@ -75,14 +75,11 @@ const createLiteCheckoutUrl = action( async (workspaceID: string, successUrl: string, cancelUrl: string, method?: "alipay" | "upi") => { "use server" return json( - await withActor( - async () => { - const data = await Billing.generateLiteCheckoutUrl({ successUrl, cancelUrl, method }) - await createReferralFromCookie() - return { error: undefined, data } - }, - workspaceID, - ).catch((e) => ({ + await withActor(async () => { + const data = await Billing.generateLiteCheckoutUrl({ successUrl, cancelUrl, method }) + await createReferralFromCookie() + return { error: undefined, data } + }, workspaceID).catch((e) => ({ error: e.message as string, data: undefined, })), From ac8e686f33aa5bdf86f722310ebba148bdc85892 Mon Sep 17 00:00:00 2001 From: Frank Date: Sat, 30 May 2026 10:53:23 -0400 Subject: [PATCH 013/840] zen: batch balance calculation --- .../app/src/routes/zen/util/handler.ts | 34 ++++++++++++++----- .../app/src/routes/zen/util/usageBatcher.ts | 31 +++++++++++++++++ 2 files changed, 57 insertions(+), 8 deletions(-) create mode 100644 packages/console/app/src/routes/zen/util/usageBatcher.ts diff --git a/packages/console/app/src/routes/zen/util/handler.ts b/packages/console/app/src/routes/zen/util/handler.ts index ed76c16c1a..4438688c22 100644 --- a/packages/console/app/src/routes/zen/util/handler.ts +++ b/packages/console/app/src/routes/zen/util/handler.ts @@ -47,6 +47,7 @@ import { i18n, type Key } from "~/i18n" import { localeFromRequest } from "~/lib/language" import { createModelTpmLimiter } from "./modelTpmLimiter" import { createModelTpsLimiter } from "./modelTpsLimiter" +import { accumulateUsage, HOT_WORKSPACES } from "./usageBatcher" type ZenData = Awaited> type RetryOptions = { @@ -981,6 +982,19 @@ export async function handler( authInfo = authInfo! const cost = centsToMicroCents(totalCostInCent) + + // For hot workspaces, batch balance/usage updates through Redis to avoid + // row-level lock contention on BillingTable/UserTable. Returns the amount + // to flush this request, or null to skip the DB writes entirely. + const balanceFlush = await (async () => { + if (billingSource !== "subscription" && billingSource !== "lite" && HOT_WORKSPACES.has(authInfo.workspaceID)) { + const workspaceCost = billingSource === "free" || billingSource === "byok" ? 0 : cost + const flush = await accumulateUsage(authInfo.workspaceID, authInfo.user.id, workspaceCost, cost) + return { batched: true as const, flush } + } + return { batched: false as const, flush: null } + })() + await Database.use((db) => Promise.all([ db.insert(UsageTable).values({ @@ -1082,18 +1096,22 @@ export async function handler( ] } + // Batched hot workspace: skip DB writes unless this request is the flush. + if (balanceFlush.batched && !balanceFlush.flush) return [] + + const workspaceDelta = balanceFlush.flush?.workspaceCost ?? cost + const userDelta = balanceFlush.flush?.userCost ?? cost + const balanceDelta = billingSource === "free" || billingSource === "byok" ? 0 : workspaceDelta + return [ db .update(BillingTable) .set({ - balance: - billingSource === "free" || billingSource === "byok" - ? sql`${BillingTable.balance} - ${0}` - : sql`${BillingTable.balance} - ${cost}`, + balance: sql`${BillingTable.balance} - ${balanceDelta}`, monthlyUsage: sql` CASE - WHEN MONTH(${BillingTable.timeMonthlyUsageUpdated}) = MONTH(now()) AND YEAR(${BillingTable.timeMonthlyUsageUpdated}) = YEAR(now()) THEN ${BillingTable.monthlyUsage} + ${cost} - ELSE ${cost} + WHEN MONTH(${BillingTable.timeMonthlyUsageUpdated}) = MONTH(now()) AND YEAR(${BillingTable.timeMonthlyUsageUpdated}) = YEAR(now()) THEN ${BillingTable.monthlyUsage} + ${workspaceDelta} + ELSE ${workspaceDelta} END `, timeMonthlyUsageUpdated: sql`now()`, @@ -1104,8 +1122,8 @@ export async function handler( .set({ monthlyUsage: sql` CASE - WHEN MONTH(${UserTable.timeMonthlyUsageUpdated}) = MONTH(now()) AND YEAR(${UserTable.timeMonthlyUsageUpdated}) = YEAR(now()) THEN ${UserTable.monthlyUsage} + ${cost} - ELSE ${cost} + WHEN MONTH(${UserTable.timeMonthlyUsageUpdated}) = MONTH(now()) AND YEAR(${UserTable.timeMonthlyUsageUpdated}) = YEAR(now()) THEN ${UserTable.monthlyUsage} + ${userDelta} + ELSE ${userDelta} END `, timeMonthlyUsageUpdated: sql`now()`, diff --git a/packages/console/app/src/routes/zen/util/usageBatcher.ts b/packages/console/app/src/routes/zen/util/usageBatcher.ts new file mode 100644 index 0000000000..315ce0a2eb --- /dev/null +++ b/packages/console/app/src/routes/zen/util/usageBatcher.ts @@ -0,0 +1,31 @@ +import { Resource } from "@opencode-ai/console-resource" +import { getRedis } from "./redis" + +// Workspaces whose balance/usage updates should be batched in Redis to avoid +// row-level lock contention on BillingTable / UserTable. +export const HOT_WORKSPACES = new Set([ + "wrk_01KJ8PX5CH50Y4YNGNS9ZR8YDC", // invoice +]) + +// Probability that a given request flushes the accumulated totals to the DB. +// Lower = fewer DB writes, more staleness. ~1 in 100 -> ~1% of requests write. +const FLUSH_PROBABILITY = 1 / 100 + +export async function accumulateUsage(workspaceID: string, userID: string, workspaceCost: number, userCost: number) { + const redis = getRedis() + const wKey = `${Resource.App.stage}:usage:wrk:${workspaceID}` + const uKey = `${Resource.App.stage}:usage:usr:${workspaceID}:${userID}` + + await Promise.all([redis.incrby(wKey, workspaceCost), redis.incrby(uKey, userCost)]) + + if (Math.random() > FLUSH_PROBABILITY) return null + + // Atomically take the current totals and reset to 0 + const [workspaceTotal, userTotal] = await Promise.all([redis.getdel(wKey), redis.getdel(uKey)]) + + const workspaceFlush = Number(workspaceTotal ?? 0) + const userFlush = Number(userTotal ?? 0) + if (workspaceFlush === 0 && userFlush === 0) return null + + return { workspaceCost: workspaceFlush, userCost: userFlush } +} From ddc30cd1516febc3a7d9d038ce65a51ab454c6e7 Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Sat, 30 May 2026 22:58:11 +0100 Subject: [PATCH 014/840] feat(core): add session metadata support (#23068) Co-authored-by: Aiden Cline --- .../migration.sql | 1 + .../snapshot.json | 1500 +++++++++++++++++ .../routes/instance/httpapi/groups/session.ts | 1 + .../instance/httpapi/handlers/session.ts | 8 +- packages/opencode/src/session/projectors.ts | 1 + packages/opencode/src/session/session.sql.ts | 1 + packages/opencode/src/session/session.ts | 23 + .../test/server/session-actions.test.ts | 79 + .../opencode/test/server/session-list.test.ts | 16 + .../test/session/schema-decoding.test.ts | 2 + .../opencode/test/session/session.test.ts | 31 + packages/sdk/js/src/v2/gen/sdk.gen.ts | 8 + packages/sdk/js/src/v2/gen/types.gen.ts | 15 + packages/sdk/openapi.json | 22 + 14 files changed, 1707 insertions(+), 1 deletion(-) create mode 100644 packages/opencode/migration/20260511173437_session-metadata/migration.sql create mode 100644 packages/opencode/migration/20260511173437_session-metadata/snapshot.json diff --git a/packages/opencode/migration/20260511173437_session-metadata/migration.sql b/packages/opencode/migration/20260511173437_session-metadata/migration.sql new file mode 100644 index 0000000000..0ce73631f0 --- /dev/null +++ b/packages/opencode/migration/20260511173437_session-metadata/migration.sql @@ -0,0 +1 @@ +ALTER TABLE `session` ADD `metadata` text; \ No newline at end of file diff --git a/packages/opencode/migration/20260511173437_session-metadata/snapshot.json b/packages/opencode/migration/20260511173437_session-metadata/snapshot.json new file mode 100644 index 0000000000..07d9ec016a --- /dev/null +++ b/packages/opencode/migration/20260511173437_session-metadata/snapshot.json @@ -0,0 +1,1500 @@ +{ + "version": "7", + "dialect": "sqlite", + "id": "85d2086c-3c95-4706-90b0-f7480b73db5c", + "prevIds": ["fdfcccee-fb3a-481f-b801-b9835fa30d5d"], + "ddl": [ + { + "name": "account_state", + "entityType": "tables" + }, + { + "name": "account", + "entityType": "tables" + }, + { + "name": "control_account", + "entityType": "tables" + }, + { + "name": "workspace", + "entityType": "tables" + }, + { + "name": "data_migration", + "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": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_used", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "data_migration" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_completed", + "entityType": "columns", + "table": "data_migration" + }, + { + "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": "metadata", + "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": "owner_id", + "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": ["name"], + "nameExplicit": false, + "name": "data_migration_pk", + "table": "data_migration", + "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": [] +} diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts index 29b98d16d4..ac41487fd3 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts @@ -45,6 +45,7 @@ export const MessagesQuery = Schema.Struct({ export const StatusMap = Schema.Record(Schema.String, SessionStatus.Info) export const UpdatePayload = Schema.Struct({ title: Schema.optional(Schema.String), + metadata: Schema.optional(Session.Metadata), permission: Schema.optional(Permission.Ruleset), time: Schema.optional( Schema.Struct({ diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts index 4d4cce367b..0d4b9ff989 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts @@ -185,6 +185,9 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", if (ctx.payload.title !== undefined) { yield* session.setTitle({ sessionID: ctx.params.sessionID, title: ctx.payload.title }) } + if (ctx.payload.metadata !== undefined) { + yield* session.setMetadata({ sessionID: ctx.params.sessionID, metadata: ctx.payload.metadata }) + } if (ctx.payload.permission !== undefined) { yield* session.setPermission({ sessionID: ctx.params.sessionID, @@ -202,7 +205,10 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", payload?: typeof ForkPayload.Type }) { return yield* SessionError.mapStorageNotFound( - session.fork({ sessionID: ctx.params.sessionID, messageID: ctx.payload?.messageID }), + session.fork({ + sessionID: ctx.params.sessionID, + messageID: ctx.payload?.messageID, + }), ) }) diff --git a/packages/opencode/src/session/projectors.ts b/packages/opencode/src/session/projectors.ts index 3dd848c5bc..20edf479a4 100644 --- a/packages/opencode/src/session/projectors.ts +++ b/packages/opencode/src/session/projectors.ts @@ -79,6 +79,7 @@ export function toPartialRow(info: DeepPartial) { summary_deletions: grab(info, "summary", (v) => grab(v, "deletions")), summary_files: grab(info, "summary", (v) => grab(v, "files")), summary_diffs: grab(info, "summary", (v) => grab(v, "diffs")), + metadata: grab(info, "metadata"), cost: grab(info, "cost"), tokens_input: grab(info, "tokens", (v) => grab(v, "input")), tokens_output: grab(info, "tokens", (v) => grab(v, "output")), diff --git a/packages/opencode/src/session/session.sql.ts b/packages/opencode/src/session/session.sql.ts index 610ca72c46..948deca4c0 100644 --- a/packages/opencode/src/session/session.sql.ts +++ b/packages/opencode/src/session/session.sql.ts @@ -33,6 +33,7 @@ export const SessionTable = sqliteTable( summary_deletions: integer(), summary_files: integer(), summary_diffs: text({ mode: "json" }).$type(), + metadata: text({ mode: "json" }).$type>(), cost: real().notNull().default(0), tokens_input: integer().notNull().default(0), tokens_output: integer().notNull().default(0), diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index f75ac910d4..e84da3340c 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -100,6 +100,7 @@ export function fromRow(row: SessionRow): Info { }, }, share, + metadata: row.metadata ?? undefined, revert, permission: row.permission ? [...row.permission] : undefined, time: { @@ -129,6 +130,7 @@ export function toRow(info: Info) { summary_deletions: info.summary?.deletions, summary_files: info.summary?.files, summary_diffs: info.summary?.diffs, + metadata: info.metadata, cost: info.cost ?? 0, tokens_input: (info.tokens ?? EmptyTokens).input, tokens_output: (info.tokens ?? EmptyTokens).output, @@ -205,6 +207,8 @@ const Model = Schema.Struct({ variant: optionalOmitUndefined(Schema.String), }) +export const Metadata = Schema.Record(Schema.String, Schema.Any) + export const Info = Schema.Struct({ id: SessionID, slug: Schema.String, @@ -221,6 +225,7 @@ export const Info = Schema.Struct({ agent: optionalOmitUndefined(Schema.String), model: optionalOmitUndefined(Model), version: Schema.String, + metadata: optionalOmitUndefined(Metadata), time: Time, permission: optionalOmitUndefined(Permission.Ruleset), revert: optionalOmitUndefined(Revert), @@ -246,6 +251,7 @@ export const CreateInput = Schema.optional( title: Schema.optional(Schema.String), agent: Schema.optional(Schema.String), model: Schema.optional(Model), + metadata: Schema.optional(Metadata), permission: Schema.optional(Permission.Ruleset), workspaceID: Schema.optional(WorkspaceID), }), @@ -264,6 +270,10 @@ export const SetArchivedInput = Schema.Struct({ sessionID: SessionID, time: Schema.optional(ArchivedTimestamp), }) +export const SetMetadataInput = Schema.Struct({ + sessionID: SessionID, + metadata: Metadata, +}) export const SetPermissionInput = Schema.Struct({ sessionID: SessionID, permission: Permission.Ruleset, @@ -320,6 +330,7 @@ const UpdatedInfo = Schema.Struct({ agent: Schema.optional(Schema.NullOr(Schema.String)), model: Schema.optional(Schema.NullOr(Model)), version: Schema.optional(Schema.NullOr(Schema.String)), + metadata: Schema.optional(Schema.NullOr(Metadata)), time: Schema.optional(UpdatedTime), permission: Schema.optional(Schema.NullOr(Permission.Ruleset)), revert: Schema.optional(Schema.NullOr(Revert)), @@ -455,6 +466,7 @@ export interface Interface { title?: string agent?: string model?: Schema.Schema.Type + metadata?: typeof Metadata.Type permission?: Permission.Ruleset workspaceID?: WorkspaceID }) => Effect.Effect @@ -463,6 +475,7 @@ export interface Interface { readonly get: (id: SessionID) => Effect.Effect readonly setTitle: (input: { sessionID: SessionID; title: string }) => Effect.Effect readonly setArchived: (input: { sessionID: SessionID; time?: number }) => Effect.Effect + readonly setMetadata: (input: typeof SetMetadataInput.Type) => Effect.Effect readonly setPermission: (input: { sessionID: SessionID; permission: Permission.Ruleset }) => Effect.Effect readonly setRevert: (input: { sessionID: SessionID @@ -529,6 +542,7 @@ export const layer: Layer.Layer< workspaceID?: WorkspaceID directory: string path?: string + metadata?: typeof Metadata.Type permission?: Permission.Ruleset }) { const ctx = yield* InstanceState.context @@ -544,6 +558,7 @@ export const layer: Layer.Layer< title: input.title ?? createDefaultTitle(!!input.parentID), agent: input.agent, model: input.model, + metadata: input.metadata, permission: input.permission ? [...input.permission] : undefined, cost: 0, tokens: EmptyTokens, @@ -659,6 +674,7 @@ export const layer: Layer.Layer< title?: string agent?: string model?: Schema.Schema.Type + metadata?: typeof Metadata.Type permission?: Permission.Ruleset workspaceID?: WorkspaceID }) { @@ -671,6 +687,7 @@ export const layer: Layer.Layer< title: input?.title, agent: input?.agent, model: input?.model, + metadata: input?.metadata, permission: input?.permission, workspaceID: input?.workspaceID ?? workspace, }) @@ -685,6 +702,7 @@ export const layer: Layer.Layer< path: sessionPath(ctx.worktree, ctx.directory), workspaceID: original.workspaceID, title, + metadata: structuredClone(original.metadata), }) const msgs = yield* messages({ sessionID: input.sessionID }) const idMap = new Map() @@ -732,6 +750,10 @@ export const layer: Layer.Layer< yield* patch(input.sessionID, { time: { archived: input.time } }) }) + const setMetadata = Effect.fn("Session.setMetadata")(function* (input: typeof SetMetadataInput.Type) { + yield* patch(input.sessionID, { metadata: input.metadata, time: { updated: Date.now() } }) + }) + const setPermission = Effect.fn("Session.setPermission")(function* (input: { sessionID: SessionID permission: Permission.Ruleset @@ -844,6 +866,7 @@ export const layer: Layer.Layer< get, setTitle, setArchived, + setMetadata, setPermission, setRevert, clearRevert, diff --git a/packages/opencode/test/server/session-actions.test.ts b/packages/opencode/test/server/session-actions.test.ts index 4aca3436cf..1a16016a56 100644 --- a/packages/opencode/test/server/session-actions.test.ts +++ b/packages/opencode/test/server/session-actions.test.ts @@ -16,6 +16,85 @@ afterEach(async () => { }) describe("session action routes", () => { + it.instance( + "session routes expose metadata on create, update, get, and fork", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const app = Server.Default().app + const headers = { "Content-Type": "application/json", "x-opencode-directory": test.directory } + + const created = yield* Effect.promise(() => + Promise.resolve( + app.request("/session", { + method: "POST", + headers, + body: JSON.stringify({ + title: "meta-session", + metadata: { source: "sdk", trace: { id: "abc" } }, + }), + }), + ), + ) + expect(created.status).toBe(200) + + const session = (yield* Effect.promise(() => created.json())) as SessionNs.Info + expect(session.metadata).toEqual({ source: "sdk", trace: { id: "abc" } }) + + const updated = yield* Effect.promise(() => + Promise.resolve( + app.request(`/session/${session.id}`, { + method: "PATCH", + headers, + body: JSON.stringify({ metadata: { source: "sdk", trace: { id: "def" }, tags: ["one"] } }), + }), + ), + ) + expect(updated.status).toBe(200) + + const next = (yield* Effect.promise(() => updated.json())) as SessionNs.Info + expect(next.metadata).toEqual({ source: "sdk", trace: { id: "def" }, tags: ["one"] }) + + const fetched = yield* Effect.promise(() => + Promise.resolve( + app.request(`/session/${session.id}`, { headers: { "x-opencode-directory": test.directory } }), + ), + ) + expect(fetched.status).toBe(200) + expect(((yield* Effect.promise(() => fetched.json())) as SessionNs.Info).metadata).toEqual(next.metadata) + + const forked = yield* Effect.promise(() => + Promise.resolve( + app.request(`/session/${session.id}/fork`, { + method: "POST", + headers, + body: JSON.stringify({}), + }), + ), + ) + expect(forked.status).toBe(200) + + const fork = (yield* Effect.promise(() => forked.json())) as SessionNs.Info + expect(fork.metadata).toEqual(next.metadata) + + const reset = yield* Effect.promise(() => + Promise.resolve( + app.request(`/session/${session.id}`, { + method: "PATCH", + headers, + body: JSON.stringify({ metadata: {} }), + }), + ), + ) + expect(reset.status).toBe(200) + expect(((yield* Effect.promise(() => reset.json())) as SessionNs.Info).metadata).toEqual({}) + + yield* SessionNs.Service.use((svc) => svc.remove(fork.id).pipe(Effect.ignore)) + yield* SessionNs.Service.use((svc) => svc.remove(session.id).pipe(Effect.ignore)) + }), + { git: true }, + ) + it.instance( "abort route returns success", () => diff --git a/packages/opencode/test/server/session-list.test.ts b/packages/opencode/test/server/session-list.test.ts index 467ab7c9a5..363e89337b 100644 --- a/packages/opencode/test/server/session-list.test.ts +++ b/packages/opencode/test/server/session-list.test.ts @@ -227,4 +227,20 @@ describe("session.list", () => { }), { git: true }, ) + + it.instance( + "includes metadata in listed sessions", + () => + Effect.gen(function* () { + const meta = { source: "sdk", trace: { id: "abc" } } + const created = yield* withSession({ title: "meta-session", metadata: meta }) + + const listed = (yield* SessionNs.Service.use((session) => session.list({ search: "meta-session" }))).find( + (item) => item.id === created.id, + ) + + expect(listed?.metadata).toEqual(meta) + }), + { git: true }, + ) }) diff --git a/packages/opencode/test/session/schema-decoding.test.ts b/packages/opencode/test/session/schema-decoding.test.ts index 3a367fa6c6..4fcb323fe7 100644 --- a/packages/opencode/test/session/schema-decoding.test.ts +++ b/packages/opencode/test/session/schema-decoding.test.ts @@ -64,6 +64,7 @@ describe("Session.Info", () => { share: { url: "https://share.example.com/s/1" }, title: "Full session", version: "1.0.0", + metadata: { source: "test" }, time: { created: 100, updated: 200, compacting: 150, archived: 300 }, permission: [{ action: "allow" as const, pattern: "*", permission: "read" }], revert: { @@ -157,6 +158,7 @@ describe("Session input schemas", () => { const populated = { parentID: sessionID, title: "child", + metadata: { source: "test" }, permission: [{ action: "ask" as const, pattern: "*", permission: "bash" }], workspaceID, } diff --git a/packages/opencode/test/session/session.test.ts b/packages/opencode/test/session/session.test.ts index 9a2b155781..2b958cb682 100644 --- a/packages/opencode/test/session/session.test.ts +++ b/packages/opencode/test/session/session.test.ts @@ -184,4 +184,35 @@ describe("Session", () => { expect(Exit.isFailure(getExit)).toBe(true) }), ) + + it.instance("persists metadata and copies it on fork by default", () => + Effect.gen(function* () { + const session = yield* SessionNs.Service + const meta = { source: "sdk", trace: { id: "abc" } } + const created = yield* Effect.acquireRelease(session.create({ title: "with-meta", metadata: meta }), (info) => + session.remove(info.id).pipe(Effect.ignore), + ) + const saved = yield* session.get(created.id) + const fork = yield* Effect.acquireRelease(session.fork({ sessionID: created.id }), (info) => + session.remove(info.id).pipe(Effect.ignore), + ) + + expect(saved.metadata).toEqual(meta) + expect(fork.metadata).toEqual(meta) + expect(fork.metadata).not.toBe(meta) + }), + ) + + it.instance("omits metadata when not provided", () => + Effect.gen(function* () { + const session = yield* SessionNs.Service + const created = yield* Effect.acquireRelease(session.create({ title: "empty-meta" }), (info) => + session.remove(info.id).pipe(Effect.ignore), + ) + const saved = yield* session.get(created.id) + + expect(created.metadata).toBeUndefined() + expect(saved.metadata).toBeUndefined() + }), + ) }) diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index a6b327fab4..6c5ea3725e 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -3100,6 +3100,9 @@ export class Session2 extends HeyApiClient { providerID: string variant?: string } + metadata?: { + [key: string]: unknown + } permission?: PermissionRuleset workspaceID?: string }, @@ -3116,6 +3119,7 @@ export class Session2 extends HeyApiClient { { in: "body", key: "title" }, { in: "body", key: "agent" }, { in: "body", key: "model" }, + { in: "body", key: "metadata" }, { in: "body", key: "permission" }, { in: "body", key: "workspaceID" }, ], @@ -3239,6 +3243,9 @@ export class Session2 extends HeyApiClient { directory?: string workspace?: string title?: string + metadata?: { + [key: string]: unknown + } permission?: PermissionRuleset time?: { archived?: number @@ -3255,6 +3262,7 @@ export class Session2 extends HeyApiClient { { in: "query", key: "directory" }, { in: "query", key: "workspace" }, { in: "body", key: "title" }, + { in: "body", key: "metadata" }, { in: "body", key: "permission" }, { in: "body", key: "time" }, ], diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index cdc2f7f574..a1a720688c 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -780,6 +780,9 @@ export type Session = { variant?: string } version: string + metadata?: { + [key: string]: unknown + } time: { created: number updated: number @@ -1516,6 +1519,9 @@ export type GlobalSession = { variant?: string } version: string + metadata?: { + [key: string]: unknown + } time: { created: number updated: number @@ -2086,6 +2092,9 @@ export type SyncEventSessionUpdated = { variant?: string } | null version?: string | null + metadata?: { + [key: string]: unknown + } | null time?: { created?: number | null updated?: number | null @@ -6093,6 +6102,9 @@ export type SessionCreateData = { providerID: string variant?: string } + metadata?: { + [key: string]: unknown + } permission?: PermissionRuleset workspaceID?: string } @@ -6223,6 +6235,9 @@ export type SessionGetResponse = SessionGetResponses[keyof SessionGetResponses] export type SessionUpdateData = { body?: { title?: string + metadata?: { + [key: string]: unknown + } permission?: PermissionRuleset time?: { archived?: number diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 3f626dc3ff..ad4eb350ed 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -5185,6 +5185,9 @@ "required": ["id", "providerID"], "additionalProperties": false }, + "metadata": { + "type": "object" + }, "permission": { "$ref": "#/components/schemas/PermissionRuleset" }, @@ -5509,6 +5512,9 @@ "title": { "type": "string" }, + "metadata": { + "type": "object" + }, "permission": { "$ref": "#/components/schemas/PermissionRuleset" }, @@ -12819,6 +12825,9 @@ "version": { "type": "string" }, + "metadata": { + "type": "object" + }, "time": { "type": "object", "properties": { @@ -14912,6 +14921,9 @@ "version": { "type": "string" }, + "metadata": { + "type": "object" + }, "time": { "type": "object", "properties": { @@ -16758,6 +16770,16 @@ } ] }, + "metadata": { + "anyOf": [ + { + "type": "object" + }, + { + "type": "null" + } + ] + }, "time": { "type": "object", "properties": { From 9b915e70bd9b8a0c1abccd0ac47ebb9a56c28e12 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sat, 30 May 2026 18:57:42 -0400 Subject: [PATCH 015/840] v2: default agents --- packages/cli/package.json | 1 + packages/cli/src/debug/agents.ts | 13 ++++ packages/cli/src/debug/index.ts | 7 ++ packages/cli/src/index.ts | 39 ++++++++++- packages/core/src/plugin/agent.ts | 106 ++++++++++++++---------------- 5 files changed, 107 insertions(+), 59 deletions(-) create mode 100644 packages/cli/src/debug/agents.ts create mode 100644 packages/cli/src/debug/index.ts diff --git a/packages/cli/package.json b/packages/cli/package.json index 20eeed3727..fff3712691 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -14,6 +14,7 @@ }, "dependencies": { "@effect/platform-node": "catalog:", + "@opencode-ai/core": "workspace:*", "effect": "catalog:" }, "devDependencies": { diff --git a/packages/cli/src/debug/agents.ts b/packages/cli/src/debug/agents.ts new file mode 100644 index 0000000000..eb64f6046c --- /dev/null +++ b/packages/cli/src/debug/agents.ts @@ -0,0 +1,13 @@ +import { EOL } from "os" +import { AgentV2 } from "@opencode-ai/core/agent" +import { PluginBoot } from "@opencode-ai/core/plugin/boot" +import * as Effect from "effect/Effect" +import * as Command from "effect/unstable/cli/Command" + +export const AgentsCommand = Command.make("agents", {}, () => + Effect.gen(function* () { + yield* PluginBoot.Service.use((service) => service.wait()) + const agents = yield* AgentV2.Service.use((service) => service.all()) + process.stdout.write(JSON.stringify(agents.sort((a, b) => a.id.localeCompare(b.id)), null, 2) + EOL) + }), +).pipe(Command.withDescription("List all agents")) diff --git a/packages/cli/src/debug/index.ts b/packages/cli/src/debug/index.ts new file mode 100644 index 0000000000..3e4e990225 --- /dev/null +++ b/packages/cli/src/debug/index.ts @@ -0,0 +1,7 @@ +import * as Command from "effect/unstable/cli/Command" +import { AgentsCommand } from "./agents" + +export const DebugCommand = Command.make("debug").pipe( + Command.withDescription("Debugging and troubleshooting tools"), + Command.withSubcommands([AgentsCommand]), +) diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 51a4dfec8f..1d16c33adb 100755 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -2,11 +2,48 @@ import * as NodeRuntime from "@effect/platform-node/NodeRuntime" import * as NodeServices from "@effect/platform-node/NodeServices" +import { AccountV2 } from "@opencode-ai/core/account" +import { AgentV2 } from "@opencode-ai/core/agent" +import { Catalog } from "@opencode-ai/core/catalog" +import { Config } from "@opencode-ai/core/config" +import { EventV2 } from "@opencode-ai/core/event" +import { Location } from "@opencode-ai/core/location" +import { Npm } from "@opencode-ai/core/npm" +import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginBoot } from "@opencode-ai/core/plugin/boot" +import { Policy } from "@opencode-ai/core/policy" +import { AbsolutePath } from "@opencode-ai/core/schema" import * as Effect from "effect/Effect" +import * as Layer from "effect/Layer" import * as Command from "effect/unstable/cli/Command" +import { DebugCommand } from "./debug" const cli = Command.make("opencode", {}, () => Effect.void).pipe( Command.withDescription("OpenCode command line interface"), + Command.withSubcommands([DebugCommand]), ) -Command.run(cli, { version: "local" }).pipe(Effect.provide(NodeServices.layer), NodeRuntime.runMain) +const locationLayer = Location.defaultLayer({ + directory: AbsolutePath.make(process.cwd()), +}) + +const policyLayer = Policy.defaultLayer.pipe(Layer.provideMerge(locationLayer)) +const pluginLayer = PluginV2.defaultLayer +const eventLayer = EventV2.defaultLayer + +const layer = PluginBoot.layer.pipe( + Layer.provideMerge( + Layer.mergeAll( + NodeServices.layer, + Catalog.layer.pipe(Layer.provideMerge(Layer.mergeAll(eventLayer, pluginLayer, policyLayer))), + eventLayer, + pluginLayer, + AccountV2.defaultLayer, + AgentV2.defaultLayer, + Config.defaultLayer.pipe(Layer.provideMerge(policyLayer)), + Npm.defaultLayer, + ), + ), +) + +Command.run(cli, { version: "local" }).pipe(Effect.provide(layer), Effect.scoped, NodeRuntime.runMain) diff --git a/packages/core/src/plugin/agent.ts b/packages/core/src/plugin/agent.ts index 6780b98853..1102c169bc 100644 --- a/packages/core/src/plugin/agent.ts +++ b/packages/core/src/plugin/agent.ts @@ -103,37 +103,34 @@ export const Plugin = PluginV2.define({ const location = yield* Location.Service const worktree = location.directory const whitelistedDirs = [TRUNCATION_GLOB, path.join(Global.Path.tmp, "*")] - const readonlyExternalDirectory = rules({ - external_directory: { - "*": "ask", - ...Object.fromEntries(whitelistedDirs.map((dir) => [dir, "allow"])), - }, - }) - const defaults = rules({ - "*": "allow", - doom_loop: "ask", - external_directory: { - "*": "ask", - ...Object.fromEntries(whitelistedDirs.map((dir) => [dir, "allow"])), - }, - question: "deny", - plan_enter: "deny", - plan_exit: "deny", - repo_clone: "deny", - repo_overview: "deny", - read: { - "*": "allow", - "*.env": "ask", - "*.env.*": "ask", - "*.env.example": "allow", - }, - }) + const readonlyExternalDirectory: PermissionV2.Ruleset = [ + { permission: "external_directory", pattern: "*", action: "ask" }, + ...whitelistedDirs.map((pattern): PermissionV2.Rule => ({ permission: "external_directory", pattern, action: "allow" })), + ] + const defaults: PermissionV2.Ruleset = [ + { permission: "*", pattern: "*", action: "allow" }, + ...readonlyExternalDirectory, + { permission: "question", pattern: "*", action: "deny" }, + { permission: "plan_enter", pattern: "*", action: "deny" }, + { permission: "plan_exit", pattern: "*", action: "deny" }, + { permission: "repo_clone", pattern: "*", action: "deny" }, + { permission: "repo_overview", pattern: "*", action: "deny" }, + { permission: "read", pattern: "*", action: "allow" }, + { permission: "read", pattern: "*.env", action: "ask" }, + { permission: "read", pattern: "*.env.*", action: "ask" }, + { permission: "read", pattern: "*.env.example", action: "allow" }, + ] yield* agent.update((editor) => { editor.update(AgentV2.ID.make("build"), (item) => { item.description = "The default agent. Executes tools based on configured permissions." item.mode = "primary" - item.permissions.push(...PermissionV2.merge(defaults, rules({ question: "allow", plan_enter: "allow" }))) + item.permissions.push( + ...PermissionV2.merge(defaults, [ + { permission: "question", pattern: "*", action: "allow" }, + { permission: "plan_enter", pattern: "*", action: "allow" }, + ]), + ) }) editor.update(AgentV2.ID.make("plan"), (item) => { @@ -142,18 +139,18 @@ export const Plugin = PluginV2.define({ item.permissions.push( ...PermissionV2.merge( defaults, - rules({ - question: "allow", - plan_exit: "allow", - external_directory: { - [path.join(Global.Path.data, "plans", "*")]: "allow", + [ + { permission: "question", pattern: "*", action: "allow" }, + { permission: "plan_exit", pattern: "*", action: "allow" }, + { permission: "external_directory", pattern: path.join(Global.Path.data, "plans", "*"), action: "allow" }, + { permission: "edit", pattern: "*", action: "deny" }, + { permission: "edit", pattern: path.join(".opencode", "plans", "*.md"), action: "allow" }, + { + permission: "edit", + pattern: path.relative(worktree, path.join(Global.Path.data, "plans", "*.md")), + action: "allow", }, - edit: { - "*": "deny", - [path.join(".opencode", "plans", "*.md")]: "allow", - [path.relative(worktree, path.join(Global.Path.data, "plans", "*.md"))]: "allow", - }, - }), + ], ), ) }) @@ -162,7 +159,7 @@ export const Plugin = PluginV2.define({ item.description = "General-purpose agent for researching complex questions and executing multi-step tasks. Use this agent to execute multiple units of work in parallel." item.mode = "subagent" - item.permissions.push(...PermissionV2.merge(defaults, rules({ todowrite: "deny" }))) + item.permissions.push(...PermissionV2.merge(defaults, [{ permission: "todowrite", pattern: "*", action: "deny" }])) }) editor.update(AgentV2.ID.make("explore"), (item) => { @@ -173,16 +170,16 @@ export const Plugin = PluginV2.define({ item.permissions.push( ...PermissionV2.merge( defaults, - rules({ - "*": "deny", - grep: "allow", - glob: "allow", - list: "allow", - bash: "allow", - webfetch: "allow", - websearch: "allow", - read: "allow", - }), + [ + { permission: "*", pattern: "*", action: "deny" }, + { permission: "grep", pattern: "*", action: "allow" }, + { permission: "glob", pattern: "*", action: "allow" }, + { permission: "list", pattern: "*", action: "allow" }, + { permission: "bash", pattern: "*", action: "allow" }, + { permission: "webfetch", pattern: "*", action: "allow" }, + { permission: "websearch", pattern: "*", action: "allow" }, + { permission: "read", pattern: "*", action: "allow" }, + ], readonlyExternalDirectory, ), ) @@ -192,29 +189,22 @@ export const Plugin = PluginV2.define({ item.mode = "primary" item.hidden = true item.system = PROMPT_COMPACTION - item.permissions.push(...PermissionV2.merge(defaults, rules({ "*": "deny" }))) + item.permissions.push(...PermissionV2.merge(defaults, [{ permission: "*", pattern: "*", action: "deny" }])) }) editor.update(AgentV2.ID.make("title"), (item) => { item.mode = "primary" item.hidden = true item.system = PROMPT_TITLE - item.permissions.push(...PermissionV2.merge(defaults, rules({ "*": "deny" }))) + item.permissions.push(...PermissionV2.merge(defaults, [{ permission: "*", pattern: "*", action: "deny" }])) }) editor.update(AgentV2.ID.make("summary"), (item) => { item.mode = "primary" item.hidden = true item.system = PROMPT_SUMMARY - item.permissions.push(...PermissionV2.merge(defaults, rules({ "*": "deny" }))) + item.permissions.push(...PermissionV2.merge(defaults, [{ permission: "*", pattern: "*", action: "deny" }])) }) }) }), }) - -function rules(input: Record>) { - return Object.entries(input).flatMap(([permission, value]) => { - if (typeof value === "string") return [{ permission, pattern: "*", action: value }] - return Object.entries(value).map(([pattern, action]) => ({ permission, pattern, action })) - }) -} From 52e288ea710ab0b84eb2cf84c4bd77c9a84545f6 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sat, 30 May 2026 22:59:16 +0000 Subject: [PATCH 016/840] chore: generate --- bun.lock | 1 + packages/cli/src/debug/agents.ts | 8 ++++++- packages/core/src/plugin/agent.ts | 35 ++++++++++++++++--------------- 3 files changed, 26 insertions(+), 18 deletions(-) diff --git a/bun.lock b/bun.lock index 493ce5c39e..8875d7e890 100644 --- a/bun.lock +++ b/bun.lock @@ -91,6 +91,7 @@ }, "dependencies": { "@effect/platform-node": "catalog:", + "@opencode-ai/core": "workspace:*", "effect": "catalog:", }, "devDependencies": { diff --git a/packages/cli/src/debug/agents.ts b/packages/cli/src/debug/agents.ts index eb64f6046c..b1593f9d96 100644 --- a/packages/cli/src/debug/agents.ts +++ b/packages/cli/src/debug/agents.ts @@ -8,6 +8,12 @@ export const AgentsCommand = Command.make("agents", {}, () => Effect.gen(function* () { yield* PluginBoot.Service.use((service) => service.wait()) const agents = yield* AgentV2.Service.use((service) => service.all()) - process.stdout.write(JSON.stringify(agents.sort((a, b) => a.id.localeCompare(b.id)), null, 2) + EOL) + process.stdout.write( + JSON.stringify( + agents.sort((a, b) => a.id.localeCompare(b.id)), + null, + 2, + ) + EOL, + ) }), ).pipe(Command.withDescription("List all agents")) diff --git a/packages/core/src/plugin/agent.ts b/packages/core/src/plugin/agent.ts index 1102c169bc..9baba75c38 100644 --- a/packages/core/src/plugin/agent.ts +++ b/packages/core/src/plugin/agent.ts @@ -105,7 +105,9 @@ export const Plugin = PluginV2.define({ const whitelistedDirs = [TRUNCATION_GLOB, path.join(Global.Path.tmp, "*")] const readonlyExternalDirectory: PermissionV2.Ruleset = [ { permission: "external_directory", pattern: "*", action: "ask" }, - ...whitelistedDirs.map((pattern): PermissionV2.Rule => ({ permission: "external_directory", pattern, action: "allow" })), + ...whitelistedDirs.map( + (pattern): PermissionV2.Rule => ({ permission: "external_directory", pattern, action: "allow" }), + ), ] const defaults: PermissionV2.Ruleset = [ { permission: "*", pattern: "*", action: "allow" }, @@ -137,21 +139,18 @@ export const Plugin = PluginV2.define({ item.description = "Plan mode. Disallows all edit tools." item.mode = "primary" item.permissions.push( - ...PermissionV2.merge( - defaults, - [ - { permission: "question", pattern: "*", action: "allow" }, - { permission: "plan_exit", pattern: "*", action: "allow" }, - { permission: "external_directory", pattern: path.join(Global.Path.data, "plans", "*"), action: "allow" }, - { permission: "edit", pattern: "*", action: "deny" }, - { permission: "edit", pattern: path.join(".opencode", "plans", "*.md"), action: "allow" }, - { - permission: "edit", - pattern: path.relative(worktree, path.join(Global.Path.data, "plans", "*.md")), - action: "allow", - }, - ], - ), + ...PermissionV2.merge(defaults, [ + { permission: "question", pattern: "*", action: "allow" }, + { permission: "plan_exit", pattern: "*", action: "allow" }, + { permission: "external_directory", pattern: path.join(Global.Path.data, "plans", "*"), action: "allow" }, + { permission: "edit", pattern: "*", action: "deny" }, + { permission: "edit", pattern: path.join(".opencode", "plans", "*.md"), action: "allow" }, + { + permission: "edit", + pattern: path.relative(worktree, path.join(Global.Path.data, "plans", "*.md")), + action: "allow", + }, + ]), ) }) @@ -159,7 +158,9 @@ export const Plugin = PluginV2.define({ item.description = "General-purpose agent for researching complex questions and executing multi-step tasks. Use this agent to execute multiple units of work in parallel." item.mode = "subagent" - item.permissions.push(...PermissionV2.merge(defaults, [{ permission: "todowrite", pattern: "*", action: "deny" }])) + item.permissions.push( + ...PermissionV2.merge(defaults, [{ permission: "todowrite", pattern: "*", action: "deny" }]), + ) }) editor.update(AgentV2.ID.make("explore"), (item) => { From 3070b0f4aee8b350feee0ba7777fb41901ce42ad Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Sat, 30 May 2026 18:07:06 -0500 Subject: [PATCH 017/840] fix(opencode): default display summarized for gateway opus 4.7+ adaptive reasoning (#30027) --- packages/opencode/src/provider/transform.ts | 4 ++ .../opencode/test/provider/transform.test.ts | 43 +++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 688e7dc8c9..ea55bb2ce6 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -697,6 +697,10 @@ export function variants(model: Provider.Model): Record { expect(result.xhigh).toEqual({ thinking: { type: "adaptive", + display: "summarized", }, effort: "xhigh", }) expect(result.max).toEqual({ thinking: { type: "adaptive", + display: "summarized", }, effort: "max", }) @@ -2706,6 +2708,47 @@ describe("ProviderTransform.variants", () => { expect(Object.keys(result)).toEqual(["low", "medium", "high", "xhigh", "max"]) }) + test("anthropic opus 4.8 forces display summarized for adaptive reasoning", () => { + const model = createMockModel({ + id: "anthropic/claude-opus-4-8", + providerID: "gateway", + api: { + id: "anthropic/claude-opus-4-8", + url: "https://gateway.ai", + npm: "@ai-sdk/gateway", + }, + }) + const result = ProviderTransform.variants(model) + expect(Object.keys(result)).toEqual(["low", "medium", "high", "xhigh", "max"]) + expect(result.high).toEqual({ + thinking: { + type: "adaptive", + display: "summarized", + }, + effort: "high", + }) + }) + + test("anthropic opus 4.6 omits display so it keeps the summarized default", () => { + const model = createMockModel({ + id: "anthropic/claude-opus-4-6", + providerID: "gateway", + api: { + id: "anthropic/claude-opus-4-6", + url: "https://gateway.ai", + npm: "@ai-sdk/gateway", + }, + }) + const result = ProviderTransform.variants(model) + expect(Object.keys(result)).toEqual(["low", "medium", "high", "max"]) + expect(result.high).toEqual({ + thinking: { + type: "adaptive", + }, + effort: "high", + }) + }) + test("anthropic models return anthropic thinking options", () => { const model = createMockModel({ id: "anthropic/claude-sonnet-4", From 30f9780561e703147745945490a46646ca27670b Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sat, 30 May 2026 23:13:09 +0000 Subject: [PATCH 018/840] chore: update nix node_modules hashes --- nix/hashes.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/nix/hashes.json b/nix/hashes.json index 2486793a1f..8e95f0b91a 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-rSvAFm+SqMEIcAvOLM7h3Pf01mBU4cNtbMcAG540ktM=", - "aarch64-linux": "sha256-mZrl5McFin9CnjnkoP8N5Xosg5lrJP+YWEP0p0xoYu0=", - "aarch64-darwin": "sha256-P43M7Thg8V8XgW1y8QsTJ0ZkRTlrBR07RdBZKAupdqo=", - "x86_64-darwin": "sha256-mveMGDh1lptCJD05Cb34VRyBWAbz1PlHIOOZtrWihic=" + "x86_64-linux": "sha256-aTweGlyK8w+A9X7FpZnbVh+czXAVlttnn7Efhzm+8kY=", + "aarch64-linux": "sha256-Gl+fwiGv/BC9u8xV4h0NIYbEac+LJSODLSTipWwdWII=", + "aarch64-darwin": "sha256-Vt6eVf9gb+A5nULpMgtrg2YNIQy3L//wNVThVkVbgyc=", + "x86_64-darwin": "sha256-0uHk7YnGTeIOLxajdj+CcAokfAIdDLFeS0VwN0mXRrc=" } } From f4f508e659f304dcc93ee10660a82bd07a258026 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Benoit?= Date: Sun, 31 May 2026 01:30:21 +0200 Subject: [PATCH 019/840] fix(opencode): support sap-ai-core anthropic opus 4.7+ adaptive reasoning (#29991) --- packages/opencode/src/provider/transform.ts | 22 ++- .../opencode/test/provider/transform.test.ts | 184 ++++++------------ 2 files changed, 82 insertions(+), 124 deletions(-) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index ea55bb2ce6..c7b65a1098 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -597,10 +597,12 @@ function openaiCompatibleReasoningEfforts(id: string) { } function anthropicOpus47OrLater(apiId: string) { - const version = /opus-(\d+)[.-](\d+)(?:[.@-]|$)/i.exec(apiId) + // Matches "opus-4.7" (Anthropic/Bedrock/Vertex) and "claude-4.7-opus" (SAP AI Core inverted). + // Greedy \d+ correctly extends to multi-digit majors (e.g. "claude-10.0-opus") for forward compatibility. + const version = /opus-(\d+)[.-](\d+)(?:[.@-]|$)|claude-(\d+)[.-](\d+)-opus(?:[.@-]|$)/i.exec(apiId) if (!version) return false - const major = Number(version[1]) - const minor = Number(version[2]) + const major = Number(version[1] ?? version[3]) + const minor = Number(version[2] ?? version[4]) return major > 4 || (major === 4 && minor >= 7) } @@ -608,7 +610,18 @@ function anthropicAdaptiveEfforts(apiId: string): string[] | null { if (anthropicOpus47OrLater(apiId)) { return ["low", "medium", "high", "xhigh", "max"] } - if (["opus-4-6", "opus-4.6", "sonnet-4-6", "sonnet-4.6"].some((v) => apiId.includes(v))) { + if ( + [ + "opus-4-6", + "opus-4.6", + "4-6-opus", + "4.6-opus", + "sonnet-4-6", + "sonnet-4.6", + "4-6-sonnet", + "4.6-sonnet", + ].some((v) => apiId.includes(v)) + ) { return ["low", "medium", "high", "max"] } return null @@ -999,6 +1012,7 @@ export function variants(model: Provider.Model): Record { }) describe("@jerome-benoit/sap-ai-provider-v2", () => { - test("anthropic models return thinking variants", () => { - const model = createMockModel({ - id: "sap-ai-core/anthropic--claude-sonnet-4", + const sapModel = (apiId: string) => + createMockModel({ + id: `sap-ai-core/${apiId}`, providerID: "sap-ai-core", api: { - id: "anthropic--claude-sonnet-4", + id: apiId, url: "https://api.ai.sap", npm: "@jerome-benoit/sap-ai-provider-v2", }, }) - const result = ProviderTransform.variants(model) + + for (const testCase of [ + { + name: "sonnet 4.6", + apiIds: ["anthropic--claude-sonnet-4-6"], + efforts: ["low", "medium", "high", "max"], + expectedHigh: { thinking: { type: "adaptive" }, effort: "high" }, + }, + { + name: "opus 4.6", + apiIds: ["anthropic--claude-4.6-opus", "anthropic--claude-4-6-opus"], + efforts: ["low", "medium", "high", "max"], + expectedHigh: { thinking: { type: "adaptive" }, effort: "high" }, + }, + { + name: "opus 4.7", + apiIds: ["anthropic--claude-4.7-opus", "anthropic--claude-4-7-opus"], + efforts: ["low", "medium", "high", "xhigh", "max"], + expectedHigh: { thinking: { type: "adaptive", display: "summarized" }, effort: "high" }, + }, + { + name: "opus 4.8", + apiIds: ["anthropic--claude-4.8-opus", "anthropic--claude-4-8-opus"], + efforts: ["low", "medium", "high", "xhigh", "max"], + expectedHigh: { thinking: { type: "adaptive", display: "summarized" }, effort: "high" }, + }, + ]) { + for (const apiId of testCase.apiIds) { + test(`${testCase.name} ${apiId} returns adaptive thinking variants`, () => { + const result = ProviderTransform.variants(sapModel(apiId)) + expect(Object.keys(result)).toEqual(testCase.efforts) + expect(result.high).toEqual(testCase.expectedHigh) + if (testCase.efforts.includes("xhigh")) { + expect(result.xhigh).toEqual({ ...testCase.expectedHigh, effort: "xhigh" }) + } + }) + } + } + + test("anthropic sonnet 4 returns budget-tokens variants", () => { + const result = ProviderTransform.variants(sapModel("anthropic--claude-sonnet-4")) expect(Object.keys(result)).toEqual(["high", "max"]) - expect(result.high).toEqual({ - thinking: { - type: "enabled", - budgetTokens: 16000, - }, - }) - expect(result.max).toEqual({ - thinking: { - type: "enabled", - budgetTokens: 31999, - }, - }) + expect(result.high).toEqual({ thinking: { type: "enabled", budgetTokens: 16000 } }) + expect(result.max).toEqual({ thinking: { type: "enabled", budgetTokens: 31999 } }) }) - test("anthropic 4.6 models return adaptive thinking variants", () => { - const model = createMockModel({ - id: "sap-ai-core/anthropic--claude-sonnet-4-6", - providerID: "sap-ai-core", - api: { - id: "anthropic--claude-sonnet-4-6", - url: "https://api.ai.sap", - npm: "@jerome-benoit/sap-ai-provider-v2", - }, - }) - const result = ProviderTransform.variants(model) - expect(Object.keys(result)).toEqual(["low", "medium", "high", "max"]) - expect(result.low).toEqual({ - thinking: { - type: "adaptive", - }, - effort: "low", - }) - expect(result.max).toEqual({ - thinking: { - type: "adaptive", - }, - effort: "max", - }) - }) - - test("gemini 2.5 models return thinkingConfig variants", () => { - const model = createMockModel({ - id: "sap-ai-core/gcp--gemini-2.5-pro", - providerID: "sap-ai-core", - api: { - id: "gcp--gemini-2.5-pro", - url: "https://api.ai.sap", - npm: "@jerome-benoit/sap-ai-provider-v2", - }, - }) - const result = ProviderTransform.variants(model) + test("gemini 2.5 returns thinkingConfig variants", () => { + const result = ProviderTransform.variants(sapModel("gcp--gemini-2.5-pro")) expect(Object.keys(result)).toEqual(["high", "max"]) - expect(result.high).toEqual({ - thinkingConfig: { - includeThoughts: true, - thinkingBudget: 16000, - }, - }) - expect(result.max).toEqual({ - thinkingConfig: { - includeThoughts: true, - thinkingBudget: 24576, - }, - }) + expect(result.high).toEqual({ thinkingConfig: { includeThoughts: true, thinkingBudget: 16000 } }) + expect(result.max).toEqual({ thinkingConfig: { includeThoughts: true, thinkingBudget: 24576 } }) }) - test("gpt models return reasoningEffort variants", () => { - const model = createMockModel({ - id: "sap-ai-core/azure-openai--gpt-4o", - providerID: "sap-ai-core", - api: { - id: "azure-openai--gpt-4o", - url: "https://api.ai.sap", - npm: "@jerome-benoit/sap-ai-provider-v2", - }, + for (const apiId of ["azure-openai--gpt-4o", "azure-openai--o3-mini"]) { + test(`${apiId} returns reasoningEffort variants`, () => { + const result = ProviderTransform.variants(sapModel(apiId)) + expect(Object.keys(result)).toEqual(["low", "medium", "high"]) + expect(result.low).toEqual({ reasoningEffort: "low" }) + expect(result.high).toEqual({ reasoningEffort: "high" }) }) - const result = ProviderTransform.variants(model) - expect(Object.keys(result)).toEqual(["low", "medium", "high"]) - expect(result.low).toEqual({ reasoningEffort: "low" }) - expect(result.high).toEqual({ reasoningEffort: "high" }) - }) + } - test("o-series models return reasoningEffort variants", () => { - const model = createMockModel({ - id: "sap-ai-core/azure-openai--o3-mini", - providerID: "sap-ai-core", - api: { - id: "azure-openai--o3-mini", - url: "https://api.ai.sap", - npm: "@jerome-benoit/sap-ai-provider-v2", - }, + for (const apiId of ["perplexity--sonar-pro", "mistral--mistral-large"]) { + test(`${apiId} returns empty object`, () => { + expect(ProviderTransform.variants(sapModel(apiId))).toEqual({}) }) - const result = ProviderTransform.variants(model) - expect(Object.keys(result)).toEqual(["low", "medium", "high"]) - expect(result.low).toEqual({ reasoningEffort: "low" }) - expect(result.high).toEqual({ reasoningEffort: "high" }) - }) + } - test("sonar models return empty object", () => { - const model = createMockModel({ - id: "sap-ai-core/perplexity--sonar-pro", - providerID: "sap-ai-core", - api: { - id: "perplexity--sonar-pro", - url: "https://api.ai.sap", - npm: "@jerome-benoit/sap-ai-provider-v2", - }, - }) - const result = ProviderTransform.variants(model) - expect(result).toEqual({}) - }) - - test("mistral models return empty object", () => { - const model = createMockModel({ - id: "sap-ai-core/mistral--mistral-large", - providerID: "sap-ai-core", - api: { - id: "mistral--mistral-large", - url: "https://api.ai.sap", - npm: "@jerome-benoit/sap-ai-provider-v2", - }, - }) - const result = ProviderTransform.variants(model) - expect(result).toEqual({}) + test("non-anthropic models with opus-like substrings do not get adaptive thinking", () => { + expect(ProviderTransform.variants(sapModel("aws--llama-opus-4.7-fake"))).toEqual({}) }) }) From 9c7788bbf9492761805b7b1ed4cb855b5cafbdd0 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sat, 30 May 2026 23:31:35 +0000 Subject: [PATCH 020/840] chore: generate --- packages/opencode/src/provider/transform.ts | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index c7b65a1098..c791aebf97 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -611,16 +611,9 @@ function anthropicAdaptiveEfforts(apiId: string): string[] | null { return ["low", "medium", "high", "xhigh", "max"] } if ( - [ - "opus-4-6", - "opus-4.6", - "4-6-opus", - "4.6-opus", - "sonnet-4-6", - "sonnet-4.6", - "4-6-sonnet", - "4.6-sonnet", - ].some((v) => apiId.includes(v)) + ["opus-4-6", "opus-4.6", "4-6-opus", "4.6-opus", "sonnet-4-6", "sonnet-4.6", "4-6-sonnet", "4.6-sonnet"].some((v) => + apiId.includes(v), + ) ) { return ["low", "medium", "high", "max"] } From 74ce1a1edf14e8d4659137d75f5e43df4f63c07e Mon Sep 17 00:00:00 2001 From: opencode Date: Sat, 30 May 2026 23:40:56 +0000 Subject: [PATCH 021/840] sync release versions for v1.15.13 --- bun.lock | 46 ++++++++++----------- packages/app/package.json | 2 +- packages/cli/package.json | 2 +- packages/console/app/package.json | 2 +- packages/console/core/package.json | 2 +- packages/console/function/package.json | 2 +- packages/console/mail/package.json | 2 +- packages/console/support/package.json | 2 +- packages/core/package.json | 2 +- packages/desktop/package.json | 2 +- packages/effect-drizzle-sqlite/package.json | 2 +- packages/enterprise/package.json | 2 +- packages/extensions/zed/extension.toml | 12 +++--- packages/function/package.json | 2 +- packages/http-recorder/package.json | 2 +- packages/llm/package.json | 2 +- packages/opencode/package.json | 2 +- packages/plugin/package.json | 2 +- packages/sdk/js/package.json | 2 +- packages/slack/package.json | 2 +- packages/stats/app/package.json | 2 +- packages/stats/core/package.json | 2 +- packages/stats/server/package.json | 2 +- packages/ui/package.json | 2 +- packages/web/package.json | 2 +- sdks/vscode/package.json | 2 +- 26 files changed, 53 insertions(+), 53 deletions(-) diff --git a/bun.lock b/bun.lock index 8875d7e890..240e57429b 100644 --- a/bun.lock +++ b/bun.lock @@ -29,7 +29,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.15.12", + "version": "1.15.13", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/core": "workspace:*", @@ -85,7 +85,7 @@ }, "packages/cli": { "name": "@opencode-ai/cli", - "version": "1.15.12", + "version": "1.15.13", "bin": { "opencode": "./src/index.ts", }, @@ -102,7 +102,7 @@ }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.15.12", + "version": "1.15.13", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -138,7 +138,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.15.12", + "version": "1.15.13", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -165,7 +165,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.15.12", + "version": "1.15.13", "dependencies": { "@ai-sdk/anthropic": "3.0.64", "@ai-sdk/openai": "3.0.48", @@ -187,7 +187,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.15.12", + "version": "1.15.13", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -211,7 +211,7 @@ }, "packages/console/support": { "name": "@opencode-ai/console-support", - "version": "1.15.12", + "version": "1.15.13", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@opencode-ai/console-core": "workspace:*", @@ -231,7 +231,7 @@ }, "packages/core": { "name": "@opencode-ai/core", - "version": "1.15.12", + "version": "1.15.13", "bin": { "opencode": "./bin/opencode", }, @@ -293,7 +293,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "1.15.12", + "version": "1.15.13", "dependencies": { "@zip.js/zip.js": "2.7.62", "drizzle-orm": "catalog:", @@ -348,7 +348,7 @@ }, "packages/effect-drizzle-sqlite": { "name": "@opencode-ai/effect-drizzle-sqlite", - "version": "1.15.12", + "version": "1.15.13", "dependencies": { "drizzle-orm": "catalog:", "effect": "catalog:", @@ -362,7 +362,7 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.15.12", + "version": "1.15.13", "dependencies": { "@opencode-ai/core": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -392,7 +392,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.15.12", + "version": "1.15.13", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "catalog:", @@ -408,7 +408,7 @@ }, "packages/http-recorder": { "name": "@opencode-ai/http-recorder", - "version": "1.15.12", + "version": "1.15.13", "dependencies": { "@effect/platform-node": "catalog:", "effect": "catalog:", @@ -421,7 +421,7 @@ }, "packages/llm": { "name": "@opencode-ai/llm", - "version": "1.15.12", + "version": "1.15.13", "dependencies": { "@smithy/eventstream-codec": "4.2.14", "@smithy/util-utf8": "4.2.2", @@ -439,7 +439,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.15.12", + "version": "1.15.13", "bin": { "opencode": "./bin/opencode", }, @@ -579,7 +579,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.15.12", + "version": "1.15.13", "dependencies": { "@opencode-ai/sdk": "workspace:*", "effect": "catalog:", @@ -617,7 +617,7 @@ }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.15.12", + "version": "1.15.13", "dependencies": { "cross-spawn": "catalog:", }, @@ -632,7 +632,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.15.12", + "version": "1.15.13", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -645,7 +645,7 @@ }, "packages/stats/app": { "name": "@opencode-ai/stats-app", - "version": "1.15.12", + "version": "1.15.13", "dependencies": { "@ibm/plex": "6.4.1", "@opencode-ai/stats-core": "workspace:*", @@ -670,7 +670,7 @@ }, "packages/stats/core": { "name": "@opencode-ai/stats-core", - "version": "1.15.12", + "version": "1.15.13", "dependencies": { "@aws-sdk/client-athena": "3.933.0", "@planetscale/database": "1.19.0", @@ -689,7 +689,7 @@ }, "packages/stats/server": { "name": "@opencode-ai/stats-server", - "version": "1.15.12", + "version": "1.15.13", "dependencies": { "@aws-sdk/client-firehose": "3.933.0", "@effect/platform-node": "catalog:", @@ -729,7 +729,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.15.12", + "version": "1.15.13", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/core": "workspace:*", @@ -778,7 +778,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.15.12", + "version": "1.15.13", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", diff --git a/packages/app/package.json b/packages/app/package.json index cefda5301b..239eacdd1b 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "1.15.12", + "version": "1.15.13", "description": "", "type": "module", "exports": { diff --git a/packages/cli/package.json b/packages/cli/package.json index fff3712691..c85fe53d10 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/cli", - "version": "1.15.12", + "version": "1.15.13", "private": true, "type": "module", "license": "MIT", diff --git a/packages/console/app/package.json b/packages/console/app/package.json index e962c2f6e2..e3da004c61 100644 --- a/packages/console/app/package.json +++ b/packages/console/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-app", - "version": "1.15.12", + "version": "1.15.13", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/console/core/package.json b/packages/console/core/package.json index 4d5290f8d1..e9645f8695 100644 --- a/packages/console/core/package.json +++ b/packages/console/core/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/console-core", - "version": "1.15.12", + "version": "1.15.13", "private": true, "type": "module", "license": "MIT", diff --git a/packages/console/function/package.json b/packages/console/function/package.json index 5ad1beccec..48d783723f 100644 --- a/packages/console/function/package.json +++ b/packages/console/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-function", - "version": "1.15.12", + "version": "1.15.13", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/console/mail/package.json b/packages/console/mail/package.json index bbc43f4eac..159f600842 100644 --- a/packages/console/mail/package.json +++ b/packages/console/mail/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-mail", - "version": "1.15.12", + "version": "1.15.13", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", diff --git a/packages/console/support/package.json b/packages/console/support/package.json index f62a2e1084..1c1064169e 100644 --- a/packages/console/support/package.json +++ b/packages/console/support/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-support", - "version": "1.15.12", + "version": "1.15.13", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/core/package.json b/packages/core/package.json index c5d599f210..5c24b5189e 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.15.12", + "version": "1.15.13", "name": "@opencode-ai/core", "type": "module", "license": "MIT", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index cefc5f7830..3a7fe5d66b 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop", "private": true, - "version": "1.15.12", + "version": "1.15.13", "type": "module", "license": "MIT", "homepage": "https://opencode.ai", diff --git a/packages/effect-drizzle-sqlite/package.json b/packages/effect-drizzle-sqlite/package.json index 2338db7eb5..1d0e26dde8 100644 --- a/packages/effect-drizzle-sqlite/package.json +++ b/packages/effect-drizzle-sqlite/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.15.12", + "version": "1.15.13", "name": "@opencode-ai/effect-drizzle-sqlite", "type": "module", "license": "MIT", diff --git a/packages/enterprise/package.json b/packages/enterprise/package.json index 390c63935f..8f94bf05bf 100644 --- a/packages/enterprise/package.json +++ b/packages/enterprise/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/enterprise", - "version": "1.15.12", + "version": "1.15.13", "private": true, "type": "module", "license": "MIT", diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml index 0fefca58d1..a669a3a5dc 100644 --- a/packages/extensions/zed/extension.toml +++ b/packages/extensions/zed/extension.toml @@ -1,7 +1,7 @@ id = "opencode" name = "OpenCode" description = "The open source coding agent." -version = "1.15.12" +version = "1.15.13" schema_version = 1 authors = ["Anomaly"] repository = "https://github.com/anomalyco/opencode" @@ -11,26 +11,26 @@ name = "OpenCode" icon = "./icons/opencode.svg" [agent_servers.opencode.targets.darwin-aarch64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.12/opencode-darwin-arm64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.13/opencode-darwin-arm64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.darwin-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.12/opencode-darwin-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.13/opencode-darwin-x64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-aarch64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.12/opencode-linux-arm64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.13/opencode-linux-arm64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.12/opencode-linux-x64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.13/opencode-linux-x64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.windows-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.12/opencode-windows-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.13/opencode-windows-x64.zip" cmd = "./opencode.exe" args = ["acp"] diff --git a/packages/function/package.json b/packages/function/package.json index d4994a00c1..83888b41ab 100644 --- a/packages/function/package.json +++ b/packages/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/function", - "version": "1.15.12", + "version": "1.15.13", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/http-recorder/package.json b/packages/http-recorder/package.json index 7e79f7f6dc..243b4be76e 100644 --- a/packages/http-recorder/package.json +++ b/packages/http-recorder/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.15.12", + "version": "1.15.13", "name": "@opencode-ai/http-recorder", "type": "module", "license": "MIT", diff --git a/packages/llm/package.json b/packages/llm/package.json index 38a55c27c9..3aca204c2e 100644 --- a/packages/llm/package.json +++ b/packages/llm/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.15.12", + "version": "1.15.13", "name": "@opencode-ai/llm", "type": "module", "license": "MIT", diff --git a/packages/opencode/package.json b/packages/opencode/package.json index bc3086a9eb..e60d5530ac 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.15.12", + "version": "1.15.13", "name": "opencode", "type": "module", "license": "MIT", diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 3b3a8973d1..68f5c56d04 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/plugin", - "version": "1.15.12", + "version": "1.15.13", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index 96cc096823..b3580468e0 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/sdk", - "version": "1.15.12", + "version": "1.15.13", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/slack/package.json b/packages/slack/package.json index 282c9209c5..5850181baf 100644 --- a/packages/slack/package.json +++ b/packages/slack/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/slack", - "version": "1.15.12", + "version": "1.15.13", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/stats/app/package.json b/packages/stats/app/package.json index 14e5264d13..0a8275c375 100644 --- a/packages/stats/app/package.json +++ b/packages/stats/app/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/stats-app", - "version": "1.15.12", + "version": "1.15.13", "private": true, "type": "module", "license": "MIT", diff --git a/packages/stats/core/package.json b/packages/stats/core/package.json index 6b8142cdb1..4fdbf7d626 100644 --- a/packages/stats/core/package.json +++ b/packages/stats/core/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/stats-core", - "version": "1.15.12", + "version": "1.15.13", "private": true, "type": "module", "license": "MIT", diff --git a/packages/stats/server/package.json b/packages/stats/server/package.json index 89fec0c336..4dedc30584 100644 --- a/packages/stats/server/package.json +++ b/packages/stats/server/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/stats-server", - "version": "1.15.12", + "version": "1.15.13", "private": true, "type": "module", "license": "MIT", diff --git a/packages/ui/package.json b/packages/ui/package.json index 8fb64e9c84..8f2275ce51 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/ui", - "version": "1.15.12", + "version": "1.15.13", "type": "module", "license": "MIT", "exports": { diff --git a/packages/web/package.json b/packages/web/package.json index c2697118ed..9609da8966 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -2,7 +2,7 @@ "name": "@opencode-ai/web", "type": "module", "license": "MIT", - "version": "1.15.12", + "version": "1.15.13", "scripts": { "dev": "astro dev", "dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev", diff --git a/sdks/vscode/package.json b/sdks/vscode/package.json index 4b69251bad..1b100e0aa1 100644 --- a/sdks/vscode/package.json +++ b/sdks/vscode/package.json @@ -2,7 +2,7 @@ "name": "opencode", "displayName": "opencode", "description": "opencode for VS Code", - "version": "1.15.12", + "version": "1.15.13", "publisher": "sst-dev", "repository": { "type": "git", From aa86a20b1d7d23697d239f0b4dfd9af92503b60a Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sat, 30 May 2026 19:40:30 -0400 Subject: [PATCH 022/840] fix(core): provide agent service in location layer --- packages/core/src/location-layer.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/core/src/location-layer.ts b/packages/core/src/location-layer.ts index 67293f7c5f..7e9c3f1237 100644 --- a/packages/core/src/location-layer.ts +++ b/packages/core/src/location-layer.ts @@ -4,13 +4,16 @@ import { Catalog } from "./catalog" import { PluginBoot } from "./plugin/boot" import { Policy } from "./policy" import { Config } from "./config" +import { AgentV2 } from "./agent" export class LocationServiceMap extends LayerMap.Service()("@opencode/example/LocationServiceMap", { lookup: (ref: Location.Ref) => { - const result = Layer.mergeAll(Catalog.defaultLayer, PluginBoot.defaultLayer, Config.defaultLayer).pipe( - Layer.provideMerge(Policy.defaultLayer), - Layer.provideMerge(Location.defaultLayer(ref)), - ) + const result = Layer.mergeAll( + Catalog.defaultLayer, + PluginBoot.defaultLayer, + Config.defaultLayer, + AgentV2.defaultLayer, + ).pipe(Layer.provideMerge(Policy.defaultLayer), Layer.provideMerge(Location.defaultLayer(ref))) return result }, idleTimeToLive: "60 minutes", From 0269d6f5dea6e1837e929fe6174a31a8890bcb23 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sat, 30 May 2026 19:59:56 -0400 Subject: [PATCH 023/840] fix(core): isolate location layer instances --- packages/core/src/catalog.ts | 6 ++- packages/core/src/config.ts | 6 ++- packages/core/src/location-layer.ts | 12 +++--- packages/core/test/location-layer.test.ts | 50 +++++++++++++++++++++++ 4 files changed, 65 insertions(+), 9 deletions(-) create mode 100644 packages/core/test/location-layer.test.ts diff --git a/packages/core/src/catalog.ts b/packages/core/src/catalog.ts index a12de5d476..22edc13402 100644 --- a/packages/core/src/catalog.ts +++ b/packages/core/src/catalog.ts @@ -317,4 +317,8 @@ export const layer = Layer.effect( const SMALL_MODEL_RE = /\b(nano|flash|lite|mini|haiku|small|fast)\b/ -export const defaultLayer = layer.pipe(Layer.provide(EventV2.defaultLayer), Layer.provide(PluginV2.defaultLayer)) +export const defaultLayer = layer.pipe( + Layer.provide(EventV2.defaultLayer), + Layer.provide(PluginV2.defaultLayer), + Layer.provide(Policy.defaultLayer), +) diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index c9e1396739..c9e7e4ea21 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -200,4 +200,8 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Global.defaultLayer)) +export const defaultLayer = layer.pipe( + Layer.provide(AppFileSystem.defaultLayer), + Layer.provide(Global.defaultLayer), + Layer.provide(Policy.defaultLayer), +) diff --git a/packages/core/src/location-layer.ts b/packages/core/src/location-layer.ts index 7e9c3f1237..23655faef5 100644 --- a/packages/core/src/location-layer.ts +++ b/packages/core/src/location-layer.ts @@ -2,18 +2,16 @@ import { Layer, LayerMap } from "effect" import { Location } from "./location" import { Catalog } from "./catalog" import { PluginBoot } from "./plugin/boot" -import { Policy } from "./policy" import { Config } from "./config" import { AgentV2 } from "./agent" export class LocationServiceMap extends LayerMap.Service()("@opencode/example/LocationServiceMap", { lookup: (ref: Location.Ref) => { - const result = Layer.mergeAll( - Catalog.defaultLayer, - PluginBoot.defaultLayer, - Config.defaultLayer, - AgentV2.defaultLayer, - ).pipe(Layer.provideMerge(Policy.defaultLayer), Layer.provideMerge(Location.defaultLayer(ref))) + const result = Layer.fresh( + Layer.mergeAll(Catalog.defaultLayer, PluginBoot.defaultLayer, Config.defaultLayer, AgentV2.defaultLayer).pipe( + Layer.provideMerge(Location.defaultLayer(ref)), + ), + ) return result }, idleTimeToLive: "60 minutes", diff --git a/packages/core/test/location-layer.test.ts b/packages/core/test/location-layer.test.ts new file mode 100644 index 0000000000..b4efc07459 --- /dev/null +++ b/packages/core/test/location-layer.test.ts @@ -0,0 +1,50 @@ +import fs from "fs/promises" +import path from "path" +import { describe, expect } from "bun:test" +import { Effect } from "effect" +import { Catalog } from "@opencode-ai/core/catalog" +import { LocationServiceMap } from "@opencode-ai/core/location-layer" +import { PluginBoot } from "@opencode-ai/core/plugin/boot" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { tmpdir } from "./fixture/tmpdir" +import { testEffect } from "./lib/effect" + +const it = testEffect(LocationServiceMap.layer) + +describe("LocationServiceMap", () => { + it.live("isolates location state while sharing location policy with catalog", () => + Effect.acquireRelease( + Effect.promise(() => Promise.all([tmpdir(), tmpdir()])), + (dirs) => Effect.promise(() => Promise.all(dirs.map((dir) => dir[Symbol.asyncDispose]())).then(() => undefined)), + ).pipe( + Effect.flatMap(([blocked, allowed]) => + Effect.gen(function* () { + yield* Effect.promise(() => + fs.writeFile( + path.join(blocked.path, "opencode.json"), + JSON.stringify({ + experimental: { policies: [{ effect: "deny", action: "provider.use", resource: "test" }] }, + }), + ), + ) + + const update = (directory: string) => + Effect.gen(function* () { + yield* PluginBoot.Service.use((boot) => boot.wait()) + const catalog = yield* Catalog.Service + const transform = yield* catalog.transform() + yield* transform((editor) => editor.provider.update(ProviderV2.ID.make("test"), () => {})) + return yield* catalog.provider.all() + }).pipe( + Effect.scoped, + Effect.provide(LocationServiceMap.get({ directory: AbsolutePath.make(directory) })), + ) + + expect((yield* update(blocked.path)).some((provider) => provider.id === ProviderV2.ID.make("test"))).toBe(false) + expect((yield* update(allowed.path)).some((provider) => provider.id === ProviderV2.ID.make("test"))).toBe(true) + }), + ), + ), + ) +}) From 6bcb9cb9bbeedd97cacc3998177eaab4b8010eaa Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sun, 31 May 2026 00:04:25 +0000 Subject: [PATCH 024/840] chore: generate --- packages/core/test/location-layer.test.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/core/test/location-layer.test.ts b/packages/core/test/location-layer.test.ts index b4efc07459..72c735f6c9 100644 --- a/packages/core/test/location-layer.test.ts +++ b/packages/core/test/location-layer.test.ts @@ -36,13 +36,14 @@ describe("LocationServiceMap", () => { const transform = yield* catalog.transform() yield* transform((editor) => editor.provider.update(ProviderV2.ID.make("test"), () => {})) return yield* catalog.provider.all() - }).pipe( - Effect.scoped, - Effect.provide(LocationServiceMap.get({ directory: AbsolutePath.make(directory) })), - ) + }).pipe(Effect.scoped, Effect.provide(LocationServiceMap.get({ directory: AbsolutePath.make(directory) }))) - expect((yield* update(blocked.path)).some((provider) => provider.id === ProviderV2.ID.make("test"))).toBe(false) - expect((yield* update(allowed.path)).some((provider) => provider.id === ProviderV2.ID.make("test"))).toBe(true) + expect((yield* update(blocked.path)).some((provider) => provider.id === ProviderV2.ID.make("test"))).toBe( + false, + ) + expect((yield* update(allowed.path)).some((provider) => provider.id === ProviderV2.ID.make("test"))).toBe( + true, + ) }), ), ), From 7f571d36ea56cc3dd7059cfe82c729fb52b121eb Mon Sep 17 00:00:00 2001 From: Dax Date: Sat, 30 May 2026 21:08:38 -0400 Subject: [PATCH 025/840] refactor(core): move database schema ownership (#29068) Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> --- .opencode/opencode.jsonc | 3 + AGENTS.md | 6 + bun.lock | 20 +- packages/cli/src/debug/agents.ts | 10 +- packages/cli/src/index.ts | 35 +- packages/{opencode => core}/drizzle.config.ts | 2 +- .../migration.sql | 0 .../snapshot.json | 0 .../migration.sql | 0 .../snapshot.json | 0 .../migration.sql | 0 .../snapshot.json | 0 .../20260225215848_workspace/migration.sql | 0 .../20260225215848_workspace/snapshot.json | 0 .../migration.sql | 0 .../snapshot.json | 0 .../20260228203230_blue_harpoon/migration.sql | 0 .../20260228203230_blue_harpoon/snapshot.json | 0 .../migration.sql | 0 .../snapshot.json | 0 .../migration.sql | 0 .../snapshot.json | 0 .../migration.sql | 0 .../snapshot.json | 0 .../20260323234822_events/migration.sql | 0 .../20260323234822_events/snapshot.json | 0 .../migration.sql | 0 .../snapshot.json | 0 .../migration.sql | 0 .../snapshot.json | 0 .../migration.sql | 0 .../snapshot.json | 0 .../migration.sql | 0 .../snapshot.json | 0 .../migration.sql | 0 .../snapshot.json | 0 .../20260501142318_next_venus/migration.sql | 0 .../20260501142318_next_venus/snapshot.json | 0 .../migration.sql | 0 .../snapshot.json | 0 .../migration.sql | 0 .../snapshot.json | 0 .../migration.sql | 0 .../snapshot.json | 0 .../migration.sql | 0 .../snapshot.json | 0 .../migration.sql | 1 + .../snapshot.json | 1635 +++++++++++++++++ packages/core/package.json | 17 +- packages/core/script/migration.ts | 113 ++ packages/core/src/account.ts | 396 +--- .../src/account/sql.ts} | 18 +- packages/core/src/agent.ts | 2 +- packages/core/src/aisdk.ts | 5 +- packages/core/src/auth.ts | 326 ++++ packages/core/src/catalog.ts | 14 +- packages/core/src/config.ts | 6 +- .../src/control-plane/workspace.sql.ts | 10 +- .../src/data-migration.sql.ts | 0 packages/core/src/database/database.ts | 60 + packages/core/src/database/migration.gen.ts | 25 + packages/core/src/database/migration.ts | 58 + .../20260127222353_familiar_lady_ursula.ts | 107 ++ .../20260211171708_add_project_commands.ts | 11 + .../20260213144116_wakeful_the_professor.ts | 23 + .../migration/20260225215848_workspace.ts | 19 + ...20260227213759_add_session_workspace_id.ts | 12 + .../migration/20260228203230_blue_harpoon.ts | 30 + .../20260303231226_add_workspace_fields.ts | 15 + .../20260309230000_move_org_to_state.ts | 13 + .../20260312043431_session_message_cursor.ts | 14 + .../migration/20260323234822_events.ts | 26 + .../20260410174513_workspace-name.ts | 27 + .../20260413175956_chief_energizer.ts | 24 + .../20260423070820_add_icon_url_override.ts | 14 + .../20260427172553_slow_nightmare.ts | 28 + .../20260428004200_add_session_path.ts | 11 + .../migration/20260501142318_next_venus.ts | 12 + .../20260504145000_add_sync_owner.ts | 11 + .../20260507164347_add_workspace_time.ts | 11 + .../migration/20260510033149_session_usage.ts | 56 + .../20260511000411_data_migration_state.ts | 16 + .../20260530232709_lovely_romulus.ts | 11 + .../src/database}/schema.sql.ts | 0 packages/core/src/database/sqlite.bun.ts | 177 ++ packages/core/src/database/sqlite.node.ts | 172 ++ packages/core/src/database/sqlite.ts | 8 + packages/core/src/event.ts | 272 ++- .../event.sql.ts => core/src/event/sql.ts} | 3 +- packages/core/src/id/id.ts | 80 + packages/core/src/location-layer.ts | 39 +- packages/core/src/location.ts | 2 - packages/core/src/permission.ts | 11 + packages/core/src/plugin.ts | 8 +- packages/core/src/plugin/account.ts | 10 +- packages/core/src/plugin/boot.ts | 24 +- packages/core/src/plugin/models-dev.ts | 4 +- packages/core/src/plugin/provider.ts | 68 +- packages/core/src/plugin/provider/index.ts | 67 - packages/core/src/policy.ts | 2 +- packages/core/src/project.ts | 1 + .../src/project/sql.ts} | 6 +- packages/core/src/provider.ts | 3 + packages/core/src/schema.ts | 18 +- packages/core/src/session.ts | 344 +++- .../{session-event.ts => session/event.ts} | 25 +- packages/core/src/session/legacy.ts | 625 +++++++ .../message-updater.ts} | 285 +-- .../message.ts} | 16 +- packages/core/src/session/projector.ts | 456 +++++ .../{session-prompt.ts => session/prompt.ts} | 0 packages/core/src/session/schema.ts | 59 + .../src/session/sql.ts} | 44 +- .../share.sql.ts => core/src/share/sql.ts} | 4 +- packages/core/src/snapshot.ts | 9 + packages/core/src/workspace.ts | 18 + packages/core/test/account.test.ts | 74 +- packages/core/test/agent.test.ts | 2 +- packages/core/test/catalog.test.ts | 30 +- packages/core/test/config/agent.test.ts | 2 +- packages/core/test/config/config.test.ts | 3 +- packages/core/test/database-migration.test.ts | 104 ++ packages/core/test/event.test.ts | 440 ++++- .../core/test/plugin/provider-azure.test.ts | 17 +- .../provider-cloudflare-workers-ai.test.ts | 17 +- .../test/plugin/provider-deepinfra.test.ts | 5 +- .../core/test/plugin/provider-dynamic.test.ts | 5 +- .../core/test/plugin/provider-gitlab.test.ts | 36 +- .../core/test/plugin/provider-google.test.ts | 5 +- .../core/test/plugin/provider-groq.test.ts | 5 +- packages/core/test/plugin/provider-helper.ts | 5 +- .../test/plugin/provider-opencode.test.ts | 4 +- .../core/test/plugin/provider-xai.test.ts | 5 +- packages/core/test/policy.test.ts | 2 +- packages/core/test/project.test.ts | 40 +- packages/effect-sqlite-node/package.json | 22 + packages/effect-sqlite-node/src/index.ts | 166 ++ packages/effect-sqlite-node/tsconfig.json | 15 + .../llm/src/protocols/openai-responses.ts | 7 +- packages/opencode/AGENTS.md | 8 +- packages/opencode/package.json | 4 +- packages/opencode/script/build-node.ts | 32 - packages/opencode/script/build.ts | 31 - packages/opencode/script/check-migrations.ts | 16 - packages/opencode/src/account/account.ts | 2 +- packages/opencode/src/account/repo.ts | 127 +- packages/opencode/src/acp/content.ts | 6 +- packages/opencode/src/acp/directory.ts | 14 +- packages/opencode/src/acp/service.ts | 24 +- packages/opencode/src/acp/session.ts | 6 +- packages/opencode/src/acp/usage.ts | 26 +- packages/opencode/src/agent/agent.ts | 11 +- packages/opencode/src/bus/bus-event.ts | 45 - packages/opencode/src/bus/index.ts | 217 --- packages/opencode/src/cli/cmd/db.ts | 110 +- packages/opencode/src/cli/cmd/debug/agent.ts | 3 +- packages/opencode/src/cli/cmd/debug/scrap.ts | 5 +- packages/opencode/src/cli/cmd/export.ts | 9 +- packages/opencode/src/cli/cmd/github.ts | 19 +- packages/opencode/src/cli/cmd/import.ts | 79 +- packages/opencode/src/cli/cmd/mcp.ts | 15 +- packages/opencode/src/cli/cmd/models.ts | 9 +- packages/opencode/src/cli/cmd/stats.ts | 13 +- packages/opencode/src/cli/cmd/tui/event.ts | 34 +- .../opencode/src/cli/cmd/tui/ui/toast.tsx | 6 +- packages/opencode/src/command/index.ts | 12 +- .../src/control-plane/adapters/index.ts | 14 +- packages/opencode/src/control-plane/schema.ts | 14 - packages/opencode/src/control-plane/types.ts | 10 +- .../src/control-plane/workspace-context.ts | 8 +- .../opencode/src/control-plane/workspace.ts | 362 ++-- packages/opencode/src/data-migration.ts | 161 -- packages/opencode/src/effect/app-runtime.ts | 12 +- .../opencode/src/effect/bootstrap-runtime.ts | 2 - packages/opencode/src/effect/bridge.ts | 4 +- packages/opencode/src/effect/instance-ref.ts | 4 +- packages/opencode/src/effect/runtime-flags.ts | 2 - packages/opencode/src/event-v2-bridge.ts | 98 +- packages/opencode/src/file/index.ts | 12 +- packages/opencode/src/file/watcher.ts | 27 +- packages/opencode/src/ide/index.ts | 12 +- packages/opencode/src/image/image.ts | 5 +- packages/opencode/src/index.ts | 9 +- packages/opencode/src/installation/index.ts | 22 +- packages/opencode/src/lsp/client.ts | 37 +- packages/opencode/src/lsp/lsp.ts | 24 +- packages/opencode/src/mcp/index.ts | 36 +- packages/opencode/src/node.ts | 2 +- packages/opencode/src/permission/index.ts | 41 +- packages/opencode/src/plugin/index.ts | 34 +- packages/opencode/src/project/bootstrap.ts | 2 - .../opencode/src/project/instance-store.ts | 11 + packages/opencode/src/project/project.ts | 195 +- packages/opencode/src/project/schema.ts | 13 - packages/opencode/src/project/vcs.ts | 51 +- packages/opencode/src/provider/auth.ts | 22 +- packages/opencode/src/provider/error.ts | 6 +- packages/opencode/src/provider/provider.ts | 106 +- packages/opencode/src/provider/schema.ts | 30 - packages/opencode/src/pty/index.ts | 24 +- packages/opencode/src/pty/ticket.ts | 4 +- packages/opencode/src/question/index.ts | 20 +- packages/opencode/src/server/event.ts | 7 +- packages/opencode/src/server/projectors.ts | 24 - .../src/server/routes/instance/httpapi/api.ts | 20 +- .../routes/instance/httpapi/groups/control.ts | 5 +- .../instance/httpapi/groups/experimental.ts | 7 +- .../routes/instance/httpapi/groups/global.ts | 12 +- .../routes/instance/httpapi/groups/project.ts | 4 +- .../instance/httpapi/groups/provider.ts | 9 +- .../routes/instance/httpapi/groups/session.ts | 26 +- .../routes/instance/httpapi/groups/tui.ts | 14 +- .../instance/httpapi/groups/v2/message.ts | 2 +- .../instance/httpapi/groups/v2/session.ts | 6 +- .../instance/httpapi/handlers/control.ts | 7 +- .../routes/instance/httpapi/handlers/event.ts | 58 +- .../instance/httpapi/handlers/experimental.ts | 25 +- .../instance/httpapi/handlers/global.ts | 6 +- .../instance/httpapi/handlers/project.ts | 4 +- .../instance/httpapi/handlers/provider.ts | 9 +- .../instance/httpapi/handlers/session.ts | 11 +- .../routes/instance/httpapi/handlers/sync.ts | 61 +- .../routes/instance/httpapi/handlers/tui.ts | 28 +- .../routes/instance/httpapi/handlers/v2.ts | 2 +- .../instance/httpapi/handlers/v2/message.ts | 4 +- .../instance/httpapi/handlers/v2/session.ts | 27 +- .../instance/httpapi/middleware/fence.ts | 21 +- .../httpapi/middleware/workspace-routing.ts | 28 +- .../server/routes/instance/httpapi/server.ts | 8 +- packages/opencode/src/server/shared/fence.ts | 24 +- packages/opencode/src/session/compaction.ts | 63 +- packages/opencode/src/session/instruction.ts | 9 +- packages/opencode/src/session/llm.ts | 24 +- packages/opencode/src/session/llm/request.ts | 3 +- packages/opencode/src/session/message-v2.ts | 686 +------ packages/opencode/src/session/message.ts | 7 +- packages/opencode/src/session/overflow.ts | 3 +- packages/opencode/src/session/processor.ts | 53 +- .../opencode/src/session/projectors-next.ts | 204 -- packages/opencode/src/session/projectors.ts | 200 -- packages/opencode/src/session/prompt.ts | 171 +- .../opencode/src/session/prompt/reference.ts | 3 +- packages/opencode/src/session/reminders.ts | 3 +- packages/opencode/src/session/retry.ts | 9 +- packages/opencode/src/session/revert.ts | 29 +- packages/opencode/src/session/run-state.ts | 27 +- packages/opencode/src/session/schema.ts | 4 +- packages/opencode/src/session/session.ts | 449 +++-- packages/opencode/src/session/status.ts | 32 +- packages/opencode/src/session/summary.ts | 13 +- packages/opencode/src/session/todo.ts | 74 +- packages/opencode/src/session/tools.ts | 10 +- packages/opencode/src/share/session.ts | 7 +- packages/opencode/src/share/share-next.ts | 101 +- packages/opencode/src/skill/index.ts | 16 +- packages/opencode/src/storage/db.bun.ts | 8 - packages/opencode/src/storage/db.node.ts | 8 - packages/opencode/src/storage/db.ts | 200 -- .../opencode/src/storage/json-migration.ts | 6 +- packages/opencode/src/storage/schema.ts | 10 +- packages/opencode/src/sync/index.ts | 411 ----- packages/opencode/src/tool/apply_patch.ts | 8 +- packages/opencode/src/tool/edit.ts | 12 +- packages/opencode/src/tool/plan.ts | 5 +- packages/opencode/src/tool/registry.ts | 19 +- packages/opencode/src/tool/task.ts | 10 +- packages/opencode/src/tool/tool.ts | 5 +- packages/opencode/src/tool/write.ts | 8 +- .../src/v2/provider-parity-checklist.md | 95 - packages/opencode/src/v2/session.ts | 372 ---- packages/opencode/src/worktree/index.ts | 43 +- packages/opencode/test/account/repo.test.ts | 15 +- .../opencode/test/account/service.test.ts | 15 +- packages/opencode/test/acp/directory.test.ts | 16 +- .../opencode/test/acp/service-session.test.ts | 10 +- packages/opencode/test/acp/session.test.ts | 6 +- packages/opencode/test/acp/usage.test.ts | 20 +- .../agent/plugin-agent-regression.test.ts | 4 +- packages/opencode/test/auth/auth.test.ts | 125 +- packages/opencode/test/bus/bus-effect.test.ts | 288 --- .../opencode/test/bus/bus-integration.test.ts | 88 - packages/opencode/test/bus/bus.test.ts | 240 --- .../opencode/test/cli/github-action.test.ts | 11 +- .../__snapshots__/help-snapshots.test.ts.snap | 1 - packages/opencode/test/config/config.test.ts | 25 +- .../test/control-plane/adapters.test.ts | 8 +- .../test/control-plane/workspace.test.ts | 332 ++-- .../opencode/test/effect/run-service.test.ts | 4 +- .../test/effect/runtime-flags.test.ts | 25 - packages/opencode/test/fake/provider.ts | 6 +- packages/opencode/test/file/watcher.test.ts | 2 + packages/opencode/test/fixture/db.ts | 5 +- packages/opencode/test/fixture/fixture.ts | 54 +- packages/opencode/test/fixture/flag.ts | 4 +- packages/opencode/test/fixture/workspace.ts | 6 +- packages/opencode/test/format/format.test.ts | 398 ++-- packages/opencode/test/lib/effect.ts | 45 +- packages/opencode/test/lsp/index.test.ts | 136 +- packages/opencode/test/lsp/lifecycle.test.ts | 180 +- .../test/mcp/oauth-auto-connect.test.ts | 4 +- .../opencode/test/mcp/oauth-browser.test.ts | 14 +- .../opencode/test/permission/next.test.ts | 28 +- .../test/plugin/auth-override.test.ts | 11 +- .../test/plugin/loader-shared.test.ts | 8 +- packages/opencode/test/plugin/trigger.test.ts | 62 +- .../test/plugin/workspace-adapter.test.ts | 20 +- packages/opencode/test/preload.ts | 7 +- .../test/project/migrate-global.test.ts | 68 +- .../opencode/test/project/project.test.ts | 460 ++--- packages/opencode/test/project/vcs.test.ts | 23 +- .../test/project/worktree-remove.test.ts | 32 +- .../opencode/test/project/worktree.test.ts | 7 - .../test/provider/amazon-bedrock.test.ts | 43 +- .../test/provider/cf-ai-gateway-e2e.test.ts | 6 +- .../test/provider/digitalocean.test.ts | 5 +- .../opencode/test/provider/gitlab-duo.test.ts | 2 +- .../test/provider/header-timeout.test.ts | 14 +- .../opencode/test/provider/provider.test.ts | 241 +-- .../opencode/test/provider/transform.test.ts | 10 +- .../test/pty/pty-output-isolation.test.ts | 4 +- .../opencode/test/pty/pty-session.test.ts | 34 +- packages/opencode/test/pty/ticket.test.ts | 6 +- .../opencode/test/question/question.test.ts | 30 +- .../test/server/global-session-list.test.ts | 18 +- .../server/httpapi-event-diagnostics.test.ts | 279 --- .../test/server/httpapi-event.test.ts | 49 +- .../test/server/httpapi-exercise/backend.ts | 2 +- .../test/server/httpapi-exercise/runner.ts | 22 +- .../test/server/httpapi-exercise/runtime.ts | 3 + .../test/server/httpapi-exercise/types.ts | 5 +- .../test/server/httpapi-experimental.test.ts | 83 +- .../server/httpapi-instance-context.test.ts | 10 +- .../test/server/httpapi-instance.test.ts | 12 +- .../opencode/test/server/httpapi-layer.ts | 33 + .../test/server/httpapi-provider.test.ts | 99 +- .../server/httpapi-schema-error-body.test.ts | 96 +- .../opencode/test/server/httpapi-sdk.test.ts | 146 +- .../test/server/httpapi-session.test.ts | 202 +- .../opencode/test/server/httpapi-sync.test.ts | 84 +- .../server/httpapi-workspace-routing.test.ts | 20 +- .../test/server/httpapi-workspace.test.ts | 105 +- .../server/negative-tokens-regression.test.ts | 45 +- .../test/server/project-init-git.test.ts | 17 +- .../test/server/session-actions.test.ts | 98 +- .../server/session-diff-missing-patch.test.ts | 15 +- .../opencode/test/server/session-list.test.ts | 38 +- .../test/server/session-messages.test.ts | 37 +- .../test/server/session-select.test.ts | 62 +- .../server/worktree-endpoint-repro.test.ts | 12 +- .../opencode/test/session/compaction.test.ts | 88 +- .../opencode/test/session/instruction.test.ts | 14 +- .../test/session/llm-native-recorded.test.ts | 28 +- .../opencode/test/session/llm-native.test.ts | 31 +- packages/opencode/test/session/llm.test.ts | 84 +- .../opencode/test/session/message-v2.test.ts | 192 +- .../test/session/messages-pagination.test.ts | 104 +- .../test/session/processor-effect.test.ts | 114 +- packages/opencode/test/session/prompt.test.ts | 106 +- packages/opencode/test/session/retry.test.ts | 78 +- .../test/session/revert-compact.test.ts | 40 +- .../test/session/schema-decoding.test.ts | 8 +- .../test/session/session-schema.test.ts | 6 +- .../opencode/test/session/session.test.ts | 66 +- .../test/session/snapshot-tool-race.test.ts | 12 +- .../structured-output-integration.test.ts | 3 +- .../test/session/structured-output.test.ts | 17 +- .../opencode/test/share/share-next.test.ts | 64 +- packages/opencode/test/skill/skill.test.ts | 12 +- .../opencode/test/snapshot/snapshot.test.ts | 4 +- packages/opencode/test/storage/db.test.ts | 38 - .../test/storage/json-migration.test.ts | 30 +- .../storage/workspace-time-migration.test.ts | 12 +- packages/opencode/test/sync/index.test.ts | 390 ---- .../opencode/test/tool/apply_patch.test.ts | 4 +- packages/opencode/test/tool/edit.test.ts | 13 +- .../test/tool/external-directory.test.ts | 53 +- packages/opencode/test/tool/grep.test.ts | 5 +- packages/opencode/test/tool/lsp.test.ts | 58 +- packages/opencode/test/tool/question.test.ts | 13 +- packages/opencode/test/tool/read.test.ts | 6 +- packages/opencode/test/tool/registry.test.ts | 18 +- .../opencode/test/tool/repo_clone.test.ts | 53 +- .../opencode/test/tool/repo_overview.test.ts | 43 +- packages/opencode/test/tool/shell.test.ts | 5 +- packages/opencode/test/tool/skill.test.ts | 20 +- packages/opencode/test/tool/task.test.ts | 18 +- packages/opencode/test/tool/websearch.test.ts | 11 +- packages/opencode/test/tool/write.test.ts | 4 +- .../test/v2/session-message-updater.test.ts | 65 +- specs/storage/remove-opencode-db.md | 239 +++ 390 files changed, 11127 insertions(+), 9164 deletions(-) rename packages/{opencode => core}/drizzle.config.ts (79%) rename packages/{opencode => core}/migration/20260127222353_familiar_lady_ursula/migration.sql (100%) rename packages/{opencode => core}/migration/20260127222353_familiar_lady_ursula/snapshot.json (100%) rename packages/{opencode => core}/migration/20260211171708_add_project_commands/migration.sql (100%) rename packages/{opencode => core}/migration/20260211171708_add_project_commands/snapshot.json (100%) rename packages/{opencode => core}/migration/20260213144116_wakeful_the_professor/migration.sql (100%) rename packages/{opencode => core}/migration/20260213144116_wakeful_the_professor/snapshot.json (100%) rename packages/{opencode => core}/migration/20260225215848_workspace/migration.sql (100%) rename packages/{opencode => core}/migration/20260225215848_workspace/snapshot.json (100%) rename packages/{opencode => core}/migration/20260227213759_add_session_workspace_id/migration.sql (100%) rename packages/{opencode => core}/migration/20260227213759_add_session_workspace_id/snapshot.json (100%) rename packages/{opencode => core}/migration/20260228203230_blue_harpoon/migration.sql (100%) rename packages/{opencode => core}/migration/20260228203230_blue_harpoon/snapshot.json (100%) rename packages/{opencode => core}/migration/20260303231226_add_workspace_fields/migration.sql (100%) rename packages/{opencode => core}/migration/20260303231226_add_workspace_fields/snapshot.json (100%) rename packages/{opencode => core}/migration/20260309230000_move_org_to_state/migration.sql (100%) rename packages/{opencode => core}/migration/20260309230000_move_org_to_state/snapshot.json (100%) rename packages/{opencode => core}/migration/20260312043431_session_message_cursor/migration.sql (100%) rename packages/{opencode => core}/migration/20260312043431_session_message_cursor/snapshot.json (100%) rename packages/{opencode => core}/migration/20260323234822_events/migration.sql (100%) rename packages/{opencode => core}/migration/20260323234822_events/snapshot.json (100%) rename packages/{opencode => core}/migration/20260410174513_workspace-name/migration.sql (100%) rename packages/{opencode => core}/migration/20260410174513_workspace-name/snapshot.json (100%) rename packages/{opencode => core}/migration/20260413175956_chief_energizer/migration.sql (100%) rename packages/{opencode => core}/migration/20260413175956_chief_energizer/snapshot.json (100%) rename packages/{opencode => core}/migration/20260423070820_add_icon_url_override/migration.sql (100%) rename packages/{opencode => core}/migration/20260423070820_add_icon_url_override/snapshot.json (100%) rename packages/{opencode => core}/migration/20260427172553_slow_nightmare/migration.sql (100%) rename packages/{opencode => core}/migration/20260427172553_slow_nightmare/snapshot.json (100%) rename packages/{opencode => core}/migration/20260428004200_add_session_path/migration.sql (100%) rename packages/{opencode => core}/migration/20260428004200_add_session_path/snapshot.json (100%) rename packages/{opencode => core}/migration/20260501142318_next_venus/migration.sql (100%) rename packages/{opencode => core}/migration/20260501142318_next_venus/snapshot.json (100%) rename packages/{opencode => core}/migration/20260504145000_add_sync_owner/migration.sql (100%) rename packages/{opencode => core}/migration/20260504145000_add_sync_owner/snapshot.json (100%) rename packages/{opencode => core}/migration/20260507164347_add_workspace_time/migration.sql (100%) rename packages/{opencode => core}/migration/20260507164347_add_workspace_time/snapshot.json (100%) rename packages/{opencode => core}/migration/20260510033149_session_usage/migration.sql (100%) rename packages/{opencode => core}/migration/20260510033149_session_usage/snapshot.json (100%) rename packages/{opencode => core}/migration/20260511000411_data_migration_state/migration.sql (100%) rename packages/{opencode => core}/migration/20260511000411_data_migration_state/snapshot.json (100%) create mode 100644 packages/core/migration/20260530232709_lovely_romulus/migration.sql create mode 100644 packages/core/migration/20260530232709_lovely_romulus/snapshot.json create mode 100644 packages/core/script/migration.ts rename packages/{opencode/src/account/account.sql.ts => core/src/account/sql.ts} (61%) create mode 100644 packages/core/src/auth.ts rename packages/{opencode => core}/src/control-plane/workspace.sql.ts (66%) rename packages/{opencode => core}/src/data-migration.sql.ts (100%) create mode 100644 packages/core/src/database/database.ts create mode 100644 packages/core/src/database/migration.gen.ts create mode 100644 packages/core/src/database/migration.ts create mode 100644 packages/core/src/database/migration/20260127222353_familiar_lady_ursula.ts create mode 100644 packages/core/src/database/migration/20260211171708_add_project_commands.ts create mode 100644 packages/core/src/database/migration/20260213144116_wakeful_the_professor.ts create mode 100644 packages/core/src/database/migration/20260225215848_workspace.ts create mode 100644 packages/core/src/database/migration/20260227213759_add_session_workspace_id.ts create mode 100644 packages/core/src/database/migration/20260228203230_blue_harpoon.ts create mode 100644 packages/core/src/database/migration/20260303231226_add_workspace_fields.ts create mode 100644 packages/core/src/database/migration/20260309230000_move_org_to_state.ts create mode 100644 packages/core/src/database/migration/20260312043431_session_message_cursor.ts create mode 100644 packages/core/src/database/migration/20260323234822_events.ts create mode 100644 packages/core/src/database/migration/20260410174513_workspace-name.ts create mode 100644 packages/core/src/database/migration/20260413175956_chief_energizer.ts create mode 100644 packages/core/src/database/migration/20260423070820_add_icon_url_override.ts create mode 100644 packages/core/src/database/migration/20260427172553_slow_nightmare.ts create mode 100644 packages/core/src/database/migration/20260428004200_add_session_path.ts create mode 100644 packages/core/src/database/migration/20260501142318_next_venus.ts create mode 100644 packages/core/src/database/migration/20260504145000_add_sync_owner.ts create mode 100644 packages/core/src/database/migration/20260507164347_add_workspace_time.ts create mode 100644 packages/core/src/database/migration/20260510033149_session_usage.ts create mode 100644 packages/core/src/database/migration/20260511000411_data_migration_state.ts create mode 100644 packages/core/src/database/migration/20260530232709_lovely_romulus.ts rename packages/{opencode/src/storage => core/src/database}/schema.sql.ts (100%) create mode 100644 packages/core/src/database/sqlite.bun.ts create mode 100644 packages/core/src/database/sqlite.node.ts create mode 100644 packages/core/src/database/sqlite.ts rename packages/{opencode/src/sync/event.sql.ts => core/src/event/sql.ts} (86%) create mode 100644 packages/core/src/id/id.ts delete mode 100644 packages/core/src/plugin/provider/index.ts rename packages/{opencode/src/project/project.sql.ts => core/src/project/sql.ts} (75%) rename packages/core/src/{session-event.ts => session/event.ts} (95%) create mode 100644 packages/core/src/session/legacy.ts rename packages/core/src/{session-message-updater.ts => session/message-updater.ts} (58%) rename packages/core/src/{session-message.ts => session/message.ts} (95%) create mode 100644 packages/core/src/session/projector.ts rename packages/core/src/{session-prompt.ts => session/prompt.ts} (100%) create mode 100644 packages/core/src/session/schema.ts rename packages/{opencode/src/session/session.sql.ts => core/src/session/sql.ts} (75%) rename packages/{opencode/src/share/share.sql.ts => core/src/share/sql.ts} (75%) create mode 100644 packages/core/src/snapshot.ts create mode 100644 packages/core/src/workspace.ts create mode 100644 packages/core/test/database-migration.test.ts create mode 100644 packages/effect-sqlite-node/package.json create mode 100644 packages/effect-sqlite-node/src/index.ts create mode 100644 packages/effect-sqlite-node/tsconfig.json delete mode 100644 packages/opencode/script/check-migrations.ts delete mode 100644 packages/opencode/src/bus/bus-event.ts delete mode 100644 packages/opencode/src/bus/index.ts delete mode 100644 packages/opencode/src/control-plane/schema.ts delete mode 100644 packages/opencode/src/data-migration.ts delete mode 100644 packages/opencode/src/project/schema.ts delete mode 100644 packages/opencode/src/provider/schema.ts delete mode 100644 packages/opencode/src/session/projectors-next.ts delete mode 100644 packages/opencode/src/session/projectors.ts delete mode 100644 packages/opencode/src/storage/db.bun.ts delete mode 100644 packages/opencode/src/storage/db.node.ts delete mode 100644 packages/opencode/src/storage/db.ts delete mode 100644 packages/opencode/src/sync/index.ts delete mode 100644 packages/opencode/src/v2/provider-parity-checklist.md delete mode 100644 packages/opencode/src/v2/session.ts delete mode 100644 packages/opencode/test/bus/bus-effect.test.ts delete mode 100644 packages/opencode/test/bus/bus-integration.test.ts delete mode 100644 packages/opencode/test/bus/bus.test.ts delete mode 100644 packages/opencode/test/server/httpapi-event-diagnostics.test.ts create mode 100644 packages/opencode/test/server/httpapi-layer.ts delete mode 100644 packages/opencode/test/storage/db.test.ts delete mode 100644 packages/opencode/test/sync/index.test.ts create mode 100644 specs/storage/remove-opencode-db.md diff --git a/.opencode/opencode.jsonc b/.opencode/opencode.jsonc index 0ae2fbe26b..7f07577f8c 100644 --- a/.opencode/opencode.jsonc +++ b/.opencode/opencode.jsonc @@ -2,6 +2,9 @@ "$schema": "https://opencode.ai/config.json", "provider": {}, "permission": {}, + "reference": { + "effect": "github.com/Effect-TS/effect-smol", + }, "mcp": {}, "tools": { "github-triage": false, diff --git a/AGENTS.md b/AGENTS.md index fa39b00a40..1ee5be8b0f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -47,6 +47,12 @@ obj.b const { a, b } = obj ``` +### Imports + +- Never alias imports. Do not use `import { foo as bar } from "..."` or renamed imports like `resolve as pathResolve`. +- Never use star imports. Do not use `import * as Foo from "..."` or `import type * as Foo from "..."`. +- If a namespace-style value is needed, import the module's own exported namespace by name, for example `import { Project } from "@opencode-ai/core/project"`, then reference `Project.ID`. + ### Variables Prefer `const` over `let`. Use ternaries or early returns instead of reassignment. diff --git a/bun.lock b/bun.lock index 240e57429b..9b33108f27 100644 --- a/bun.lock +++ b/bun.lock @@ -259,8 +259,11 @@ "@aws-sdk/credential-providers": "3.993.0", "@effect/opentelemetry": "catalog:", "@effect/platform-node": "catalog:", + "@effect/sql-sqlite-bun": "catalog:", "@npmcli/arborist": "9.4.0", "@npmcli/config": "10.8.1", + "@opencode-ai/effect-drizzle-sqlite": "workspace:*", + "@opencode-ai/effect-sqlite-node": "workspace:*", "@openrouter/ai-sdk-provider": "2.8.1", "@opentelemetry/api": "1.9.0", "@opentelemetry/context-async-hooks": "2.6.1", @@ -268,6 +271,7 @@ "@opentelemetry/sdk-trace-base": "2.6.1", "ai-gateway-provider": "3.1.2", "cross-spawn": "catalog:", + "drizzle-orm": "catalog:", "effect": "catalog:", "gitlab-ai-provider": "6.8.0", "glob": "13.0.5", @@ -289,6 +293,7 @@ "@types/npm-package-arg": "6.1.4", "@types/npmcli__arborist": "6.3.3", "@types/semver": "catalog:", + "drizzle-kit": "catalog:", }, }, "packages/desktop": { @@ -360,6 +365,18 @@ "@typescript/native-preview": "catalog:", }, }, + "packages/effect-sqlite-node": { + "name": "@opencode-ai/effect-sqlite-node", + "version": "1.15.10", + "dependencies": { + "effect": "catalog:", + }, + "devDependencies": { + "@tsconfig/bun": "catalog:", + "@types/node": "catalog:", + "@typescript/native-preview": "catalog:", + }, + }, "packages/enterprise": { "name": "@opencode-ai/enterprise", "version": "1.15.13", @@ -569,7 +586,6 @@ "@types/which": "3.0.4", "@types/yargs": "17.0.33", "@typescript/native-preview": "catalog:", - "drizzle-kit": "catalog:", "drizzle-orm": "catalog:", "prettier": "3.6.2", "typescript": "catalog:", @@ -1684,6 +1700,8 @@ "@opencode-ai/effect-drizzle-sqlite": ["@opencode-ai/effect-drizzle-sqlite@workspace:packages/effect-drizzle-sqlite"], + "@opencode-ai/effect-sqlite-node": ["@opencode-ai/effect-sqlite-node@workspace:packages/effect-sqlite-node"], + "@opencode-ai/enterprise": ["@opencode-ai/enterprise@workspace:packages/enterprise"], "@opencode-ai/function": ["@opencode-ai/function@workspace:packages/function"], diff --git a/packages/cli/src/debug/agents.ts b/packages/cli/src/debug/agents.ts index b1593f9d96..5bef47c3ce 100644 --- a/packages/cli/src/debug/agents.ts +++ b/packages/cli/src/debug/agents.ts @@ -3,6 +3,8 @@ import { AgentV2 } from "@opencode-ai/core/agent" import { PluginBoot } from "@opencode-ai/core/plugin/boot" import * as Effect from "effect/Effect" import * as Command from "effect/unstable/cli/Command" +import { LocationServiceMap } from "@opencode-ai/core/location-layer" +import { AbsolutePath } from "@opencode-ai/core/schema" export const AgentsCommand = Command.make("agents", {}, () => Effect.gen(function* () { @@ -15,5 +17,11 @@ export const AgentsCommand = Command.make("agents", {}, () => 2, ) + EOL, ) - }), + }).pipe( + Effect.provide( + LocationServiceMap.get({ + directory: AbsolutePath.make(process.cwd()), + }), + ), + ), ).pipe(Command.withDescription("List all agents")) diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 1d16c33adb..7caf8e0cd3 100755 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -2,48 +2,17 @@ import * as NodeRuntime from "@effect/platform-node/NodeRuntime" import * as NodeServices from "@effect/platform-node/NodeServices" -import { AccountV2 } from "@opencode-ai/core/account" -import { AgentV2 } from "@opencode-ai/core/agent" -import { Catalog } from "@opencode-ai/core/catalog" -import { Config } from "@opencode-ai/core/config" -import { EventV2 } from "@opencode-ai/core/event" -import { Location } from "@opencode-ai/core/location" -import { Npm } from "@opencode-ai/core/npm" -import { PluginV2 } from "@opencode-ai/core/plugin" -import { PluginBoot } from "@opencode-ai/core/plugin/boot" -import { Policy } from "@opencode-ai/core/policy" -import { AbsolutePath } from "@opencode-ai/core/schema" import * as Effect from "effect/Effect" import * as Layer from "effect/Layer" import * as Command from "effect/unstable/cli/Command" import { DebugCommand } from "./debug" +import { LocationServiceMap } from "@opencode-ai/core/location-layer" const cli = Command.make("opencode", {}, () => Effect.void).pipe( Command.withDescription("OpenCode command line interface"), Command.withSubcommands([DebugCommand]), ) -const locationLayer = Location.defaultLayer({ - directory: AbsolutePath.make(process.cwd()), -}) - -const policyLayer = Policy.defaultLayer.pipe(Layer.provideMerge(locationLayer)) -const pluginLayer = PluginV2.defaultLayer -const eventLayer = EventV2.defaultLayer - -const layer = PluginBoot.layer.pipe( - Layer.provideMerge( - Layer.mergeAll( - NodeServices.layer, - Catalog.layer.pipe(Layer.provideMerge(Layer.mergeAll(eventLayer, pluginLayer, policyLayer))), - eventLayer, - pluginLayer, - AccountV2.defaultLayer, - AgentV2.defaultLayer, - Config.defaultLayer.pipe(Layer.provideMerge(policyLayer)), - Npm.defaultLayer, - ), - ), -) +const layer = Layer.mergeAll(LocationServiceMap.layer, NodeServices.layer) Command.run(cli, { version: "local" }).pipe(Effect.provide(layer), Effect.scoped, NodeRuntime.runMain) diff --git a/packages/opencode/drizzle.config.ts b/packages/core/drizzle.config.ts similarity index 79% rename from packages/opencode/drizzle.config.ts rename to packages/core/drizzle.config.ts index 1b4fd556e9..a90ac4e2fe 100644 --- a/packages/opencode/drizzle.config.ts +++ b/packages/core/drizzle.config.ts @@ -2,7 +2,7 @@ import { defineConfig } from "drizzle-kit" export default defineConfig({ dialect: "sqlite", - schema: "./src/**/*.sql.ts", + schema: ["./src/**/*.sql.ts", "./src/**/sql.ts"], out: "./migration", dbCredentials: { url: "/home/thdxr/.local/share/opencode/opencode.db", diff --git a/packages/opencode/migration/20260127222353_familiar_lady_ursula/migration.sql b/packages/core/migration/20260127222353_familiar_lady_ursula/migration.sql similarity index 100% rename from packages/opencode/migration/20260127222353_familiar_lady_ursula/migration.sql rename to packages/core/migration/20260127222353_familiar_lady_ursula/migration.sql diff --git a/packages/opencode/migration/20260127222353_familiar_lady_ursula/snapshot.json b/packages/core/migration/20260127222353_familiar_lady_ursula/snapshot.json similarity index 100% rename from packages/opencode/migration/20260127222353_familiar_lady_ursula/snapshot.json rename to packages/core/migration/20260127222353_familiar_lady_ursula/snapshot.json diff --git a/packages/opencode/migration/20260211171708_add_project_commands/migration.sql b/packages/core/migration/20260211171708_add_project_commands/migration.sql similarity index 100% rename from packages/opencode/migration/20260211171708_add_project_commands/migration.sql rename to packages/core/migration/20260211171708_add_project_commands/migration.sql diff --git a/packages/opencode/migration/20260211171708_add_project_commands/snapshot.json b/packages/core/migration/20260211171708_add_project_commands/snapshot.json similarity index 100% rename from packages/opencode/migration/20260211171708_add_project_commands/snapshot.json rename to packages/core/migration/20260211171708_add_project_commands/snapshot.json diff --git a/packages/opencode/migration/20260213144116_wakeful_the_professor/migration.sql b/packages/core/migration/20260213144116_wakeful_the_professor/migration.sql similarity index 100% rename from packages/opencode/migration/20260213144116_wakeful_the_professor/migration.sql rename to packages/core/migration/20260213144116_wakeful_the_professor/migration.sql diff --git a/packages/opencode/migration/20260213144116_wakeful_the_professor/snapshot.json b/packages/core/migration/20260213144116_wakeful_the_professor/snapshot.json similarity index 100% rename from packages/opencode/migration/20260213144116_wakeful_the_professor/snapshot.json rename to packages/core/migration/20260213144116_wakeful_the_professor/snapshot.json diff --git a/packages/opencode/migration/20260225215848_workspace/migration.sql b/packages/core/migration/20260225215848_workspace/migration.sql similarity index 100% rename from packages/opencode/migration/20260225215848_workspace/migration.sql rename to packages/core/migration/20260225215848_workspace/migration.sql diff --git a/packages/opencode/migration/20260225215848_workspace/snapshot.json b/packages/core/migration/20260225215848_workspace/snapshot.json similarity index 100% rename from packages/opencode/migration/20260225215848_workspace/snapshot.json rename to packages/core/migration/20260225215848_workspace/snapshot.json diff --git a/packages/opencode/migration/20260227213759_add_session_workspace_id/migration.sql b/packages/core/migration/20260227213759_add_session_workspace_id/migration.sql similarity index 100% rename from packages/opencode/migration/20260227213759_add_session_workspace_id/migration.sql rename to packages/core/migration/20260227213759_add_session_workspace_id/migration.sql diff --git a/packages/opencode/migration/20260227213759_add_session_workspace_id/snapshot.json b/packages/core/migration/20260227213759_add_session_workspace_id/snapshot.json similarity index 100% rename from packages/opencode/migration/20260227213759_add_session_workspace_id/snapshot.json rename to packages/core/migration/20260227213759_add_session_workspace_id/snapshot.json diff --git a/packages/opencode/migration/20260228203230_blue_harpoon/migration.sql b/packages/core/migration/20260228203230_blue_harpoon/migration.sql similarity index 100% rename from packages/opencode/migration/20260228203230_blue_harpoon/migration.sql rename to packages/core/migration/20260228203230_blue_harpoon/migration.sql diff --git a/packages/opencode/migration/20260228203230_blue_harpoon/snapshot.json b/packages/core/migration/20260228203230_blue_harpoon/snapshot.json similarity index 100% rename from packages/opencode/migration/20260228203230_blue_harpoon/snapshot.json rename to packages/core/migration/20260228203230_blue_harpoon/snapshot.json diff --git a/packages/opencode/migration/20260303231226_add_workspace_fields/migration.sql b/packages/core/migration/20260303231226_add_workspace_fields/migration.sql similarity index 100% rename from packages/opencode/migration/20260303231226_add_workspace_fields/migration.sql rename to packages/core/migration/20260303231226_add_workspace_fields/migration.sql diff --git a/packages/opencode/migration/20260303231226_add_workspace_fields/snapshot.json b/packages/core/migration/20260303231226_add_workspace_fields/snapshot.json similarity index 100% rename from packages/opencode/migration/20260303231226_add_workspace_fields/snapshot.json rename to packages/core/migration/20260303231226_add_workspace_fields/snapshot.json diff --git a/packages/opencode/migration/20260309230000_move_org_to_state/migration.sql b/packages/core/migration/20260309230000_move_org_to_state/migration.sql similarity index 100% rename from packages/opencode/migration/20260309230000_move_org_to_state/migration.sql rename to packages/core/migration/20260309230000_move_org_to_state/migration.sql diff --git a/packages/opencode/migration/20260309230000_move_org_to_state/snapshot.json b/packages/core/migration/20260309230000_move_org_to_state/snapshot.json similarity index 100% rename from packages/opencode/migration/20260309230000_move_org_to_state/snapshot.json rename to packages/core/migration/20260309230000_move_org_to_state/snapshot.json diff --git a/packages/opencode/migration/20260312043431_session_message_cursor/migration.sql b/packages/core/migration/20260312043431_session_message_cursor/migration.sql similarity index 100% rename from packages/opencode/migration/20260312043431_session_message_cursor/migration.sql rename to packages/core/migration/20260312043431_session_message_cursor/migration.sql diff --git a/packages/opencode/migration/20260312043431_session_message_cursor/snapshot.json b/packages/core/migration/20260312043431_session_message_cursor/snapshot.json similarity index 100% rename from packages/opencode/migration/20260312043431_session_message_cursor/snapshot.json rename to packages/core/migration/20260312043431_session_message_cursor/snapshot.json diff --git a/packages/opencode/migration/20260323234822_events/migration.sql b/packages/core/migration/20260323234822_events/migration.sql similarity index 100% rename from packages/opencode/migration/20260323234822_events/migration.sql rename to packages/core/migration/20260323234822_events/migration.sql diff --git a/packages/opencode/migration/20260323234822_events/snapshot.json b/packages/core/migration/20260323234822_events/snapshot.json similarity index 100% rename from packages/opencode/migration/20260323234822_events/snapshot.json rename to packages/core/migration/20260323234822_events/snapshot.json diff --git a/packages/opencode/migration/20260410174513_workspace-name/migration.sql b/packages/core/migration/20260410174513_workspace-name/migration.sql similarity index 100% rename from packages/opencode/migration/20260410174513_workspace-name/migration.sql rename to packages/core/migration/20260410174513_workspace-name/migration.sql diff --git a/packages/opencode/migration/20260410174513_workspace-name/snapshot.json b/packages/core/migration/20260410174513_workspace-name/snapshot.json similarity index 100% rename from packages/opencode/migration/20260410174513_workspace-name/snapshot.json rename to packages/core/migration/20260410174513_workspace-name/snapshot.json diff --git a/packages/opencode/migration/20260413175956_chief_energizer/migration.sql b/packages/core/migration/20260413175956_chief_energizer/migration.sql similarity index 100% rename from packages/opencode/migration/20260413175956_chief_energizer/migration.sql rename to packages/core/migration/20260413175956_chief_energizer/migration.sql diff --git a/packages/opencode/migration/20260413175956_chief_energizer/snapshot.json b/packages/core/migration/20260413175956_chief_energizer/snapshot.json similarity index 100% rename from packages/opencode/migration/20260413175956_chief_energizer/snapshot.json rename to packages/core/migration/20260413175956_chief_energizer/snapshot.json diff --git a/packages/opencode/migration/20260423070820_add_icon_url_override/migration.sql b/packages/core/migration/20260423070820_add_icon_url_override/migration.sql similarity index 100% rename from packages/opencode/migration/20260423070820_add_icon_url_override/migration.sql rename to packages/core/migration/20260423070820_add_icon_url_override/migration.sql diff --git a/packages/opencode/migration/20260423070820_add_icon_url_override/snapshot.json b/packages/core/migration/20260423070820_add_icon_url_override/snapshot.json similarity index 100% rename from packages/opencode/migration/20260423070820_add_icon_url_override/snapshot.json rename to packages/core/migration/20260423070820_add_icon_url_override/snapshot.json diff --git a/packages/opencode/migration/20260427172553_slow_nightmare/migration.sql b/packages/core/migration/20260427172553_slow_nightmare/migration.sql similarity index 100% rename from packages/opencode/migration/20260427172553_slow_nightmare/migration.sql rename to packages/core/migration/20260427172553_slow_nightmare/migration.sql diff --git a/packages/opencode/migration/20260427172553_slow_nightmare/snapshot.json b/packages/core/migration/20260427172553_slow_nightmare/snapshot.json similarity index 100% rename from packages/opencode/migration/20260427172553_slow_nightmare/snapshot.json rename to packages/core/migration/20260427172553_slow_nightmare/snapshot.json diff --git a/packages/opencode/migration/20260428004200_add_session_path/migration.sql b/packages/core/migration/20260428004200_add_session_path/migration.sql similarity index 100% rename from packages/opencode/migration/20260428004200_add_session_path/migration.sql rename to packages/core/migration/20260428004200_add_session_path/migration.sql diff --git a/packages/opencode/migration/20260428004200_add_session_path/snapshot.json b/packages/core/migration/20260428004200_add_session_path/snapshot.json similarity index 100% rename from packages/opencode/migration/20260428004200_add_session_path/snapshot.json rename to packages/core/migration/20260428004200_add_session_path/snapshot.json diff --git a/packages/opencode/migration/20260501142318_next_venus/migration.sql b/packages/core/migration/20260501142318_next_venus/migration.sql similarity index 100% rename from packages/opencode/migration/20260501142318_next_venus/migration.sql rename to packages/core/migration/20260501142318_next_venus/migration.sql diff --git a/packages/opencode/migration/20260501142318_next_venus/snapshot.json b/packages/core/migration/20260501142318_next_venus/snapshot.json similarity index 100% rename from packages/opencode/migration/20260501142318_next_venus/snapshot.json rename to packages/core/migration/20260501142318_next_venus/snapshot.json diff --git a/packages/opencode/migration/20260504145000_add_sync_owner/migration.sql b/packages/core/migration/20260504145000_add_sync_owner/migration.sql similarity index 100% rename from packages/opencode/migration/20260504145000_add_sync_owner/migration.sql rename to packages/core/migration/20260504145000_add_sync_owner/migration.sql diff --git a/packages/opencode/migration/20260504145000_add_sync_owner/snapshot.json b/packages/core/migration/20260504145000_add_sync_owner/snapshot.json similarity index 100% rename from packages/opencode/migration/20260504145000_add_sync_owner/snapshot.json rename to packages/core/migration/20260504145000_add_sync_owner/snapshot.json diff --git a/packages/opencode/migration/20260507164347_add_workspace_time/migration.sql b/packages/core/migration/20260507164347_add_workspace_time/migration.sql similarity index 100% rename from packages/opencode/migration/20260507164347_add_workspace_time/migration.sql rename to packages/core/migration/20260507164347_add_workspace_time/migration.sql diff --git a/packages/opencode/migration/20260507164347_add_workspace_time/snapshot.json b/packages/core/migration/20260507164347_add_workspace_time/snapshot.json similarity index 100% rename from packages/opencode/migration/20260507164347_add_workspace_time/snapshot.json rename to packages/core/migration/20260507164347_add_workspace_time/snapshot.json diff --git a/packages/opencode/migration/20260510033149_session_usage/migration.sql b/packages/core/migration/20260510033149_session_usage/migration.sql similarity index 100% rename from packages/opencode/migration/20260510033149_session_usage/migration.sql rename to packages/core/migration/20260510033149_session_usage/migration.sql diff --git a/packages/opencode/migration/20260510033149_session_usage/snapshot.json b/packages/core/migration/20260510033149_session_usage/snapshot.json similarity index 100% rename from packages/opencode/migration/20260510033149_session_usage/snapshot.json rename to packages/core/migration/20260510033149_session_usage/snapshot.json diff --git a/packages/opencode/migration/20260511000411_data_migration_state/migration.sql b/packages/core/migration/20260511000411_data_migration_state/migration.sql similarity index 100% rename from packages/opencode/migration/20260511000411_data_migration_state/migration.sql rename to packages/core/migration/20260511000411_data_migration_state/migration.sql diff --git a/packages/opencode/migration/20260511000411_data_migration_state/snapshot.json b/packages/core/migration/20260511000411_data_migration_state/snapshot.json similarity index 100% rename from packages/opencode/migration/20260511000411_data_migration_state/snapshot.json rename to packages/core/migration/20260511000411_data_migration_state/snapshot.json diff --git a/packages/core/migration/20260530232709_lovely_romulus/migration.sql b/packages/core/migration/20260530232709_lovely_romulus/migration.sql new file mode 100644 index 0000000000..0ce73631f0 --- /dev/null +++ b/packages/core/migration/20260530232709_lovely_romulus/migration.sql @@ -0,0 +1 @@ +ALTER TABLE `session` ADD `metadata` text; \ No newline at end of file diff --git a/packages/core/migration/20260530232709_lovely_romulus/snapshot.json b/packages/core/migration/20260530232709_lovely_romulus/snapshot.json new file mode 100644 index 0000000000..f171a75273 --- /dev/null +++ b/packages/core/migration/20260530232709_lovely_romulus/snapshot.json @@ -0,0 +1,1635 @@ +{ + "version": "7", + "dialect": "sqlite", + "id": "bf93c73b-5a48-4d63-9909-3c36a79b9788", + "prevIds": [ + "be5eae31-b7f8-4292-8827-c36a524abd1b", + "fdfcccee-fb3a-481f-b801-b9835fa30d5d" + ], + "ddl": [ + { + "name": "workspace", + "entityType": "tables" + }, + { + "name": "data_migration", + "entityType": "tables" + }, + { + "name": "account_state", + "entityType": "tables" + }, + { + "name": "account", + "entityType": "tables" + }, + { + "name": "control_account", + "entityType": "tables" + }, + { + "name": "event_sequence", + "entityType": "tables" + }, + { + "name": "event", + "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" + }, + { + "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": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_used", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "data_migration" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_completed", + "entityType": "columns", + "table": "data_migration" + }, + { + "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": "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": "owner_id", + "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" + }, + { + "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": "metadata", + "entityType": "columns", + "table": "session" + }, + { + "type": "real", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "cost", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "tokens_input", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "tokens_output", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "tokens_reasoning", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "tokens_cache_read", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "tokens_cache_write", + "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" + }, + { + "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": [ + "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": [ + "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": [ + "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": [ + "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": "workspace_pk", + "table": "workspace", + "entityType": "pks" + }, + { + "columns": [ + "name" + ], + "nameExplicit": false, + "name": "data_migration_pk", + "table": "data_migration", + "entityType": "pks" + }, + { + "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": [ + "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": [ + "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": [ + { + "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/core/package.json b/packages/core/package.json index 5c24b5189e..1c3f2a3f00 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -6,6 +6,8 @@ "license": "MIT", "private": true, "scripts": { + "db": "bun drizzle-kit", + "migration": "bun run script/migration.ts", "test": "bun test", "test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml", "typecheck": "tsgo --noEmit" @@ -16,14 +18,21 @@ "exports": { "./*": "./src/*.ts" }, - "imports": {}, + "imports": { + "#sqlite": { + "bun": "./src/database/sqlite.bun.ts", + "node": "./src/database/sqlite.node.ts", + "default": "./src/database/sqlite.bun.ts" + } + }, "devDependencies": { "@tsconfig/bun": "catalog:", "@types/bun": "catalog:", "@types/cross-spawn": "catalog:", "@types/npm-package-arg": "6.1.4", "@types/npmcli__arborist": "6.3.3", - "@types/semver": "catalog:" + "@types/semver": "catalog:", + "drizzle-kit": "catalog:" }, "dependencies": { "@ai-sdk/alibaba": "1.0.17", @@ -49,8 +58,11 @@ "@aws-sdk/credential-providers": "3.993.0", "@effect/opentelemetry": "catalog:", "@effect/platform-node": "catalog:", + "@effect/sql-sqlite-bun": "catalog:", "@npmcli/arborist": "9.4.0", "@npmcli/config": "10.8.1", + "@opencode-ai/effect-drizzle-sqlite": "workspace:*", + "@opencode-ai/effect-sqlite-node": "workspace:*", "@opentelemetry/api": "1.9.0", "@opentelemetry/context-async-hooks": "2.6.1", "@opentelemetry/exporter-trace-otlp-http": "0.214.0", @@ -58,6 +70,7 @@ "@openrouter/ai-sdk-provider": "2.8.1", "ai-gateway-provider": "3.1.2", "cross-spawn": "catalog:", + "drizzle-orm": "catalog:", "effect": "catalog:", "gitlab-ai-provider": "6.8.0", "glob": "13.0.5", diff --git a/packages/core/script/migration.ts b/packages/core/script/migration.ts new file mode 100644 index 0000000000..8c5f1f6cc6 --- /dev/null +++ b/packages/core/script/migration.ts @@ -0,0 +1,113 @@ +#!/usr/bin/env bun + +import { $ } from "bun" +import fs from "fs/promises" +import os from "os" +import path from "path" +import { pathToFileURL } from "url" + +const root = path.resolve(import.meta.dirname, "../../..") +const sqlDir = path.join(root, "packages/core/migration") +const tsDir = path.join(root, "packages/core/src/database/migration") +const registry = path.join(root, "packages/core/src/database/migration.gen.ts") + +if (Bun.argv.includes("--check")) { + await check() + process.exit(0) +} + +await $`bun drizzle-kit generate`.cwd(path.join(root, "packages/core")) + +const sqlMigrations = (await Array.fromAsync(new Bun.Glob("*/migration.sql").scan({ cwd: sqlDir }))) + .map((file) => file.split("/")[0]) + .filter((name) => name !== undefined) + .sort() + +for (const name of sqlMigrations) { + if (await Bun.file(path.join(tsDir, `${name}.ts`)).exists()) continue + await Bun.write(path.join(tsDir, `${name}.ts`), renderMigration(name, await Bun.file(path.join(sqlDir, name, "migration.sql")).text())) +} + +await Bun.write(registry, renderRegistry(sqlMigrations)) + +async function check() { + const temporary = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-core-migration-check-")) + const output = path.join(temporary, "migration") + try { + await fs.cp(sqlDir, output, { recursive: true }) + const config = path.join(temporary, "drizzle.config.ts") + await Bun.write( + config, + `import config from ${JSON.stringify(pathToFileURL(path.join(root, "packages/core/drizzle.config.ts")).href)} + +export default { ...config, out: ${JSON.stringify(output)} } +`, + ) + const before = await snapshot(output) + await $`bun drizzle-kit generate --config ${config}`.cwd(path.join(root, "packages/core")) + const after = await snapshot(output) + if (JSON.stringify(after) !== JSON.stringify(before)) { + throw new Error("Core schema has ungenerated database migrations. Run `bun script/migration.ts` from packages/core.") + } + + const migrations = before + .map((entry) => entry.path.split("/")[0]) + .filter((name, index, all) => name !== undefined && all.indexOf(name) === index) + .sort() + for (const name of migrations) { + if (await Bun.file(path.join(tsDir, `${name}.ts`)).exists()) continue + throw new Error(`Database migration TypeScript wrapper is missing for ${name}. Run \`bun script/migration.ts\` from packages/core.`) + } + if ((await Bun.file(registry).text()) !== renderRegistry(migrations)) { + throw new Error("Database migration registry is stale. Run `bun script/migration.ts` from packages/core.") + } + } finally { + await fs.rm(temporary, { recursive: true, force: true }) + } +} + +async function snapshot(directory: string) { + const files = await Array.fromAsync(new Bun.Glob("**/*").scan({ cwd: directory, onlyFiles: true })) + return Promise.all( + files.sort().map(async (file) => ({ path: file, contents: await Bun.file(path.join(directory, file)).text() })), + ) +} + +function renderMigration(name: string, sql: string) { + return `import { Effect } from "effect" +import type { DatabaseMigration } from "../migration" + +export default { + id: ${JSON.stringify(name)}, + up(tx) { + return Effect.gen(function* () { +${sql + .split("--> statement-breakpoint") + .map((statement) => statement.trim()) + .filter((statement) => statement.length > 0) + .map(renderRun) + .join("\n")} + }) + }, +} satisfies DatabaseMigration.Migration +` +} + +function renderRun(statement: string) { + const lines = statement.replaceAll("\t", " ").split("\n") + if (lines.length === 1) return ` yield* tx.run(\`${escapeTemplate(lines[0])}\`)` + return ` yield* tx.run(\`\n${lines.map((line) => ` ${escapeTemplate(line)}`).join("\n")}\n \`)` +} + +function escapeTemplate(line: string) { + return line.replaceAll("\\", "\\\\").replaceAll("`", "\\`").replaceAll("${", "\\${") +} + +function renderRegistry(names: string[]) { + return `import type { DatabaseMigration } from "./migration" + +export const migrations = (await Promise.all([ +${names.map((name) => ` import("./migration/${name}"),`).join("\n")} +])).map((module) => module.default) satisfies DatabaseMigration.Migration[] +` +} diff --git a/packages/core/src/account.ts b/packages/core/src/account.ts index a124a9a158..4de8176e4b 100644 --- a/packages/core/src/account.ts +++ b/packages/core/src/account.ts @@ -1,319 +1,101 @@ -import path from "path" -import { Effect, Layer, Option, Schema, Context, SynchronizedRef } from "effect" -import { Identifier } from "./util/identifier" -import { NonNegativeInt, withStatics } from "./schema" -import { Global } from "./global" -import { AppFileSystem } from "./filesystem" -import { EventV2 } from "./event" +export * as AccountV2 from "./account" -export const ID = Schema.String.pipe( - Schema.brand("AccountV2.ID"), - withStatics((schema) => ({ create: () => schema.make("acc_" + Identifier.ascending()) })), -) -export type ID = typeof ID.Type +import { Schema } from "effect" +import type * as HttpClientError from "effect/unstable/http/HttpClientError" -export const ServiceID = Schema.String.pipe(Schema.brand("ServiceID")) -export type ServiceID = typeof ServiceID.Type +export const ID = Schema.String.pipe(Schema.brand("AccountID")) +export type ID = Schema.Schema.Type -export class OAuthCredential extends Schema.Class("AccountV2.OAuthCredential")({ - type: Schema.Literal("oauth"), - refresh: Schema.String, - access: Schema.String, - expires: NonNegativeInt, -}) {} +export const OrgID = Schema.String.pipe(Schema.brand("OrgID")) +export type OrgID = Schema.Schema.Type -export class ApiKeyCredential extends Schema.Class("AccountV2.ApiKeyCredential")({ - type: Schema.Literal("api"), - key: Schema.String, - metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)), -}) {} +export const AccessToken = Schema.String.pipe(Schema.brand("AccessToken")) +export type AccessToken = Schema.Schema.Type -export const Credential = Schema.Union([OAuthCredential, ApiKeyCredential]) - .pipe(Schema.toTaggedUnion("type")) - .annotate({ - identifier: "AccountV2.Credential", - }) -export type Credential = Schema.Schema.Type +export const RefreshToken = Schema.String.pipe(Schema.brand("RefreshToken")) +export type RefreshToken = Schema.Schema.Type -export class Info extends Schema.Class("AccountV2.Info")({ +export const DeviceCode = Schema.String.pipe(Schema.brand("DeviceCode")) +export type DeviceCode = Schema.Schema.Type + +export const UserCode = Schema.String.pipe(Schema.brand("UserCode")) +export type UserCode = Schema.Schema.Type + +export class Info extends Schema.Class("Account")({ id: ID, - serviceID: ServiceID, - description: Schema.String, - credential: Credential, + email: Schema.String, + url: Schema.String, + active_org_id: Schema.NullOr(OrgID), }) {} -export class FileWriteError extends Schema.TaggedErrorClass()("AccountV2.FileWriteError", { - operation: Schema.Union([Schema.Literal("migrate"), Schema.Literal("write")]), +export class Org extends Schema.Class("Org")({ + id: OrgID, + name: Schema.String, +}) {} + +export class AccountRepoError extends Schema.TaggedErrorClass()("AccountRepoError", { + message: Schema.String, + cause: Schema.optional(Schema.Defect), +}) {} + +export class AccountServiceError extends Schema.TaggedErrorClass()("AccountServiceError", { + message: Schema.String, + cause: Schema.optional(Schema.Defect), +}) {} + +export class AccountTransportError extends Schema.TaggedErrorClass()("AccountTransportError", { + method: Schema.String, + url: Schema.String, + description: Schema.optional(Schema.String), + cause: Schema.optional(Schema.Defect), +}) { + static fromHttpClientError(error: HttpClientError.TransportError): AccountTransportError { + return new AccountTransportError({ + method: error.request.method, + url: error.request.url, + description: error.description, + cause: error.cause, + }) + } + + override get message(): string { + return [ + `Could not reach ${this.method} ${this.url}.`, + `This failed before the server returned an HTTP response.`, + this.description, + `Check your network, proxy, or VPN configuration and try again.`, + ] + .filter(Boolean) + .join("\n") + } +} + +export type AccountError = AccountRepoError | AccountServiceError | AccountTransportError + +export class Login extends Schema.Class("Login")({ + code: DeviceCode, + user: UserCode, + url: Schema.String, + server: Schema.String, + expiry: Schema.Duration, + interval: Schema.Duration, +}) {} + +export class PollSuccess extends Schema.TaggedClass()("PollSuccess", { + email: Schema.String, +}) {} + +export class PollPending extends Schema.TaggedClass()("PollPending", {}) {} + +export class PollSlow extends Schema.TaggedClass()("PollSlow", {}) {} + +export class PollExpired extends Schema.TaggedClass()("PollExpired", {}) {} + +export class PollDenied extends Schema.TaggedClass()("PollDenied", {}) {} + +export class PollError extends Schema.TaggedClass()("PollError", { cause: Schema.Defect, }) {} -export type Error = FileWriteError - -export const Event = { - Added: EventV2.define({ - type: "account.added", - schema: { - account: Info, - }, - }), - Removed: EventV2.define({ - type: "account.removed", - schema: { - account: Info, - }, - }), - Switched: EventV2.define({ - type: "account.switched", - schema: { - serviceID: ServiceID, - from: Schema.optional(ID), - to: Schema.optional(ID), - }, - }), -} - -interface Writable { - version: 2 - accounts: Record - active: Record -} - -const decodeV1 = Schema.decodeUnknownOption(Schema.Record(Schema.String, Credential)) - -function migrate(old: Record): Writable { - const accounts: Record = {} - const active: Record = {} - for (const [serviceID, value] of Object.entries(old)) { - const decoded = Option.getOrElse(decodeV1({ [serviceID]: value }), () => ({})) - const parsed = (decoded as Record)[serviceID] - if (!parsed) continue - const id = Identifier.ascending() - const account = ID.make(id) - const brandedServiceID = ServiceID.make(serviceID) - accounts[id] = new Info({ - id: account, - serviceID: brandedServiceID, - description: "default", - credential: parsed, - }) - active[brandedServiceID] = account - } - return { version: 2, accounts, active } -} - -export interface Interface { - readonly get: (id: ID) => Effect.Effect - readonly all: () => Effect.Effect - readonly create: (input: { - serviceID: ServiceID - credential: Credential - description?: string - }) => Effect.Effect - readonly update: (id: ID, updates: Partial>) => Effect.Effect - readonly remove: (id: ID) => Effect.Effect - readonly activate: (id: ID) => Effect.Effect - readonly active: (serviceID: ServiceID) => Effect.Effect - readonly forService: (serviceID: ServiceID) => Effect.Effect -} - -export class Service extends Context.Service()("@opencode/v2/Account") {} - -export const layer = Layer.effect( - Service, - Effect.gen(function* () { - const fsys = yield* AppFileSystem.Service - const global = yield* Global.Service - const events = yield* EventV2.Service - const file = path.join(global.data, "account.json") - const legacyFile = path.join(global.data, "auth.json") - - const writeMigrated = Effect.fnUntraced(function* (raw: Record) { - const migrated = migrate(raw) - yield* fsys - .writeJson(file, migrated, 0o600) - .pipe(Effect.mapError((cause) => new FileWriteError({ operation: "migrate", cause }))) - return migrated - }) - - const parseAuthContent = () => { - try { - return JSON.parse(process.env.OPENCODE_AUTH_CONTENT ?? "") - } catch {} - } - - const load: () => Effect.Effect = Effect.fnUntraced(function* () { - if (process.env.OPENCODE_AUTH_CONTENT) { - const raw = parseAuthContent() - if (raw && typeof raw === "object") { - if ("version" in raw && raw.version === 2) return raw as Writable - return yield* writeMigrated(raw as Record) - } - return { version: 2, accounts: {}, active: {} } - } - - const legacy = yield* fsys.readJson(legacyFile).pipe(Effect.orElseSucceed(() => null)) - if (legacy && typeof legacy === "object") return yield* writeMigrated(legacy as Record) - - const raw = yield* fsys.readJson(file).pipe(Effect.orElseSucceed(() => null)) - - if (raw && typeof raw === "object") { - if ("version" in raw && raw.version === 2) return raw as Writable - return yield* writeMigrated(raw as Record) - } - - return { version: 2, accounts: {}, active: {} } - }) - - const write = (data: Writable) => - fsys - .writeJson(file, data, 0o600) - .pipe(Effect.mapError((cause) => new FileWriteError({ operation: "write", cause }))) - - const state = SynchronizedRef.makeUnsafe( - yield* load().pipe(Effect.orElseSucceed((): Writable => ({ version: 2, accounts: {}, active: {} }))), - ) - - const activate = Effect.fn("AccountV2.activate")(function* (id: ID) { - const data = yield* SynchronizedRef.get(state) - const account = data.accounts[id] - if (!account) return - const activated = yield* SynchronizedRef.modifyEffect( - state, - Effect.fnUntraced(function* (data) { - const nextAccount = data.accounts[id] - if (!nextAccount) return [undefined, data] as const - - const next = { ...data, active: { ...data.active, [nextAccount.serviceID]: id } } - yield* write(next) - return [{ serviceID: nextAccount.serviceID, from: data.active[nextAccount.serviceID], to: id }, next] as const - }), - ) - if (activated) yield* events.publish(Event.Switched, activated) - }) - - const result: Interface = { - get: Effect.fn("AccountV2.get")(function* (id) { - return (yield* SynchronizedRef.get(state)).accounts[id] - }), - - all: Effect.fn("AccountV2.all")(function* () { - return Object.values((yield* SynchronizedRef.get(state)).accounts) - }), - - active: Effect.fn("AccountV2.active")(function* (serviceID) { - const data = yield* SynchronizedRef.get(state) - return ( - data.accounts[data.active[serviceID]] ?? Object.values(data.accounts).find((a) => a.serviceID === serviceID) - ) - }), - - forService: Effect.fn("AccountV2.list")(function* (serviceID) { - return Object.values((yield* SynchronizedRef.get(state)).accounts).filter((a) => a.serviceID === serviceID) - }), - - create: Effect.fn("AccountV2.add")(function* (input) { - const id = ID.make(Identifier.ascending()) - const account = new Info({ - id, - serviceID: input.serviceID, - description: input.description ?? "default", - credential: input.credential, - }) - const added = yield* SynchronizedRef.modifyEffect( - state, - Effect.fnUntraced(function* (data) { - const next = { - ...data, - accounts: { ...data.accounts, [account.id]: account }, - active: { ...data.active, [account.serviceID]: account.id }, - } - - yield* write(next) - return [ - { - account, - switched: { serviceID: account.serviceID, from: data.active[account.serviceID], to: account.id }, - }, - next, - ] as const - }), - ) - yield* events.publish(Event.Added, { account: added.account }) - yield* events.publish(Event.Switched, added.switched) - return added.account - }), - - update: Effect.fn("AccountV2.update")(function* (id, updates) { - const existing = (yield* SynchronizedRef.get(state)).accounts[id] - if (!existing) return - yield* SynchronizedRef.modifyEffect( - state, - Effect.fnUntraced(function* (data) { - if (!data.accounts[id]) return [undefined, data] as const - - const next = { - ...data, - accounts: { - ...data.accounts, - [id]: new Info({ - id, - serviceID: existing.serviceID, - description: updates.description ?? existing.description, - credential: updates.credential ?? existing.credential, - }), - }, - } - - yield* write(next) - return [undefined, next] as const - }), - ) - }), - - remove: Effect.fn("AccountV2.remove")(function* (id) { - const removed = yield* SynchronizedRef.modifyEffect( - state, - Effect.fnUntraced(function* (data) { - const accounts = { ...data.accounts } - const active = { ...data.active } - const removed = accounts[id] - if (!removed) return [undefined, data] as const - const wasActive = active[removed.serviceID] === id - delete accounts[id] - const replacement = Object.values(accounts).find((account) => account.serviceID === removed.serviceID) - if (wasActive) { - if (replacement) active[removed.serviceID] = replacement.id - else delete active[removed.serviceID] - } - - const next = { ...data, accounts, active } - yield* write(next) - return [ - { - account: removed, - switched: wasActive ? { serviceID: removed.serviceID, from: id, to: replacement?.id } : undefined, - }, - next, - ] as const - }), - ) - if (removed) { - yield* events.publish(Event.Removed, { account: removed.account }) - if (removed.switched) yield* events.publish(Event.Switched, removed.switched) - } - }), - - activate, - } - - return Service.of(result) - }), -) - -export const defaultLayer = layer.pipe( - Layer.provide(AppFileSystem.defaultLayer), - Layer.provide(Global.defaultLayer), - Layer.provide(EventV2.defaultLayer), -) - -export * as AccountV2 from "./account" +export const PollResult = Schema.Union([PollSuccess, PollPending, PollSlow, PollExpired, PollDenied, PollError]) +export type PollResult = Schema.Schema.Type diff --git a/packages/opencode/src/account/account.sql.ts b/packages/core/src/account/sql.ts similarity index 61% rename from packages/opencode/src/account/account.sql.ts rename to packages/core/src/account/sql.ts index 35bfd1e3ed..4f45651d78 100644 --- a/packages/opencode/src/account/account.sql.ts +++ b/packages/core/src/account/sql.ts @@ -1,14 +1,14 @@ import { sqliteTable, text, integer, primaryKey } from "drizzle-orm/sqlite-core" -import { type AccessToken, type AccountID, type OrgID, type RefreshToken } from "./schema" -import { Timestamps } from "../storage/schema.sql" +import { AccountV2 } from "../account" +import { Timestamps } from "../database/schema.sql" export const AccountTable = sqliteTable("account", { - id: text().$type().primaryKey(), + id: text().$type().primaryKey(), email: text().notNull(), url: text().notNull(), - access_token: text().$type().notNull(), - refresh_token: text().$type().notNull(), + access_token: text().$type().notNull(), + refresh_token: text().$type().notNull(), token_expiry: integer(), ...Timestamps, }) @@ -16,9 +16,9 @@ export const AccountTable = sqliteTable("account", { export const AccountStateTable = sqliteTable("account_state", { id: integer().primaryKey(), active_account_id: text() - .$type() + .$type() .references(() => AccountTable.id, { onDelete: "set null" }), - active_org_id: text().$type(), + active_org_id: text().$type(), }) // LEGACY @@ -27,8 +27,8 @@ export const ControlAccountTable = sqliteTable( { email: text().notNull(), url: text().notNull(), - access_token: text().$type().notNull(), - refresh_token: text().$type().notNull(), + access_token: text().$type().notNull(), + refresh_token: text().$type().notNull(), token_expiry: integer(), active: integer({ mode: "boolean" }) .notNull() diff --git a/packages/core/src/agent.ts b/packages/core/src/agent.ts index ec7dfa2ade..c4971b2721 100644 --- a/packages/core/src/agent.ts +++ b/packages/core/src/agent.ts @@ -104,4 +104,4 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer +export const locationLayer = layer diff --git a/packages/core/src/aisdk.ts b/packages/core/src/aisdk.ts index 5fa2294309..4560f8a2ce 100644 --- a/packages/core/src/aisdk.ts +++ b/packages/core/src/aisdk.ts @@ -3,6 +3,7 @@ export * as AISDK from "./aisdk" import type { LanguageModelV3 } from "@ai-sdk/provider" import { Cause, Context, Effect, Layer, Schema } from "effect" import { ModelV2 } from "./model" +import { EventV2 } from "./event" import { PluginV2 } from "./plugin" import { ProviderV2 } from "./provider" @@ -169,4 +170,6 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer.pipe(Layer.provide(PluginV2.defaultLayer)) +export const defaultLayer = layer.pipe( + Layer.provide(PluginV2.locationLayer.pipe(Layer.provide(EventV2.defaultLayer))), +) diff --git a/packages/core/src/auth.ts b/packages/core/src/auth.ts new file mode 100644 index 0000000000..916bef9d17 --- /dev/null +++ b/packages/core/src/auth.ts @@ -0,0 +1,326 @@ +export * as Auth from "./auth" + +import path from "path" +import { Effect, Layer, Option, Schema, Context, SynchronizedRef } from "effect" +import { Identifier } from "./util/identifier" +import { NonNegativeInt, withStatics } from "./schema" +import { Global } from "./global" +import { AppFileSystem } from "./filesystem" +import { EventV2 } from "./event" + +export const ID = Schema.String.pipe( + Schema.brand("Auth.ID"), + withStatics((schema) => ({ create: () => schema.make("acc_" + Identifier.ascending()) })), +) +export type ID = typeof ID.Type + +export const ServiceID = Schema.String.pipe(Schema.brand("ServiceID")) +export type ServiceID = typeof ServiceID.Type + +export const OrgID = Schema.String.pipe(Schema.brand("OrgID")) +export type OrgID = typeof OrgID.Type +export const AccessToken = Schema.String.pipe(Schema.brand("AccessToken")) +export type AccessToken = typeof AccessToken.Type +export const RefreshToken = Schema.String.pipe(Schema.brand("RefreshToken")) +export type RefreshToken = typeof RefreshToken.Type + +export class OAuthCredential extends Schema.Class("Auth.OAuthCredential")({ + type: Schema.Literal("oauth"), + refresh: Schema.String, + access: Schema.String, + expires: NonNegativeInt, +}) {} + +export class ApiKeyCredential extends Schema.Class("Auth.ApiKeyCredential")({ + type: Schema.Literal("api"), + key: Schema.String, + metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)), +}) {} + +export const Credential = Schema.Union([OAuthCredential, ApiKeyCredential]) + .pipe(Schema.toTaggedUnion("type")) + .annotate({ + identifier: "Auth.Credential", + }) +export type Credential = Schema.Schema.Type + +export class Info extends Schema.Class("Auth.Info")({ + id: ID, + serviceID: ServiceID, + description: Schema.String, + credential: Credential, +}) {} + +export class FileWriteError extends Schema.TaggedErrorClass()("Auth.FileWriteError", { + operation: Schema.Union([Schema.Literal("migrate"), Schema.Literal("write")]), + cause: Schema.Defect, +}) {} + +export type Error = FileWriteError + +export const Event = { + Added: EventV2.define({ + type: "account.added", + schema: { + account: Info, + }, + }), + Removed: EventV2.define({ + type: "account.removed", + schema: { + account: Info, + }, + }), + Switched: EventV2.define({ + type: "account.switched", + schema: { + serviceID: ServiceID, + from: Schema.optional(ID), + to: Schema.optional(ID), + }, + }), +} + +interface Writable { + version: 2 + accounts: Record + active: Record +} + +const decodeV1 = Schema.decodeUnknownOption(Schema.Record(Schema.String, Credential)) + +function migrate(old: Record): Writable { + const accounts: Record = {} + const active: Record = {} + for (const [serviceID, value] of Object.entries(old)) { + const decoded = Option.getOrElse(decodeV1({ [serviceID]: value }), () => ({})) + const parsed = (decoded as Record)[serviceID] + if (!parsed) continue + const id = Identifier.ascending() + const account = ID.make(id) + const brandedServiceID = ServiceID.make(serviceID) + accounts[id] = new Info({ + id: account, + serviceID: brandedServiceID, + description: "default", + credential: parsed, + }) + active[brandedServiceID] = account + } + return { version: 2, accounts, active } +} + +export interface Interface { + readonly get: (id: ID) => Effect.Effect + readonly all: () => Effect.Effect + readonly create: (input: { + serviceID: ServiceID + credential: Credential + description?: string + }) => Effect.Effect + readonly update: (id: ID, updates: Partial>) => Effect.Effect + readonly remove: (id: ID) => Effect.Effect + readonly activate: (id: ID) => Effect.Effect + readonly active: (serviceID: ServiceID) => Effect.Effect + readonly forService: (serviceID: ServiceID) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/v2/Account") {} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const fsys = yield* AppFileSystem.Service + const global = yield* Global.Service + const events = yield* EventV2.Service + const file = path.join(global.data, "account.json") + const legacyFile = path.join(global.data, "auth.json") + + const writeMigrated = Effect.fnUntraced(function* (raw: Record) { + const migrated = migrate(raw) + yield* fsys + .writeJson(file, migrated, 0o600) + .pipe(Effect.mapError((cause) => new FileWriteError({ operation: "migrate", cause }))) + return migrated + }) + + const parseAuthContent = () => { + try { + return JSON.parse(process.env.OPENCODE_AUTH_CONTENT ?? "") + } catch {} + } + + const load: () => Effect.Effect = Effect.fnUntraced(function* () { + if (process.env.OPENCODE_AUTH_CONTENT) { + const raw = parseAuthContent() + if (raw && typeof raw === "object") { + if ("version" in raw && raw.version === 2) return raw as Writable + return yield* writeMigrated(raw as Record) + } + return { version: 2, accounts: {}, active: {} } + } + + const legacy = yield* fsys.readJson(legacyFile).pipe(Effect.orElseSucceed(() => null)) + if (legacy && typeof legacy === "object") return yield* writeMigrated(legacy as Record) + + const raw = yield* fsys.readJson(file).pipe(Effect.orElseSucceed(() => null)) + + if (raw && typeof raw === "object") { + if ("version" in raw && raw.version === 2) return raw as Writable + return yield* writeMigrated(raw as Record) + } + + return { version: 2, accounts: {}, active: {} } + }) + + const write = (data: Writable) => + fsys + .writeJson(file, data, 0o600) + .pipe(Effect.mapError((cause) => new FileWriteError({ operation: "write", cause }))) + + const state = SynchronizedRef.makeUnsafe( + yield* load().pipe(Effect.orElseSucceed((): Writable => ({ version: 2, accounts: {}, active: {} }))), + ) + + const activate = Effect.fn("Auth.activate")(function* (id: ID) { + const data = yield* SynchronizedRef.get(state) + const account = data.accounts[id] + if (!account) return + const activated = yield* SynchronizedRef.modifyEffect( + state, + Effect.fnUntraced(function* (data) { + const nextAccount = data.accounts[id] + if (!nextAccount) return [undefined, data] as const + + const next = { ...data, active: { ...data.active, [nextAccount.serviceID]: id } } + yield* write(next) + return [{ serviceID: nextAccount.serviceID, from: data.active[nextAccount.serviceID], to: id }, next] as const + }), + ) + if (activated) yield* events.publish(Event.Switched, activated) + }) + + const result: Interface = { + get: Effect.fn("Auth.get")(function* (id) { + return (yield* SynchronizedRef.get(state)).accounts[id] + }), + + all: Effect.fn("Auth.all")(function* () { + return Object.values((yield* SynchronizedRef.get(state)).accounts) + }), + + active: Effect.fn("Auth.active")(function* (serviceID) { + const data = yield* SynchronizedRef.get(state) + return ( + data.accounts[data.active[serviceID]] ?? Object.values(data.accounts).find((a) => a.serviceID === serviceID) + ) + }), + + forService: Effect.fn("Auth.list")(function* (serviceID) { + return Object.values((yield* SynchronizedRef.get(state)).accounts).filter((a) => a.serviceID === serviceID) + }), + + create: Effect.fn("Auth.add")(function* (input) { + const id = ID.make(Identifier.ascending()) + const account = new Info({ + id, + serviceID: input.serviceID, + description: input.description ?? "default", + credential: input.credential, + }) + const added = yield* SynchronizedRef.modifyEffect( + state, + Effect.fnUntraced(function* (data) { + const next = { + ...data, + accounts: { ...data.accounts, [account.id]: account }, + active: { ...data.active, [account.serviceID]: account.id }, + } + + yield* write(next) + return [ + { + account, + switched: { serviceID: account.serviceID, from: data.active[account.serviceID], to: account.id }, + }, + next, + ] as const + }), + ) + yield* events.publish(Event.Added, { account: added.account }) + yield* events.publish(Event.Switched, added.switched) + return added.account + }), + + update: Effect.fn("Auth.update")(function* (id, updates) { + const existing = (yield* SynchronizedRef.get(state)).accounts[id] + if (!existing) return + yield* SynchronizedRef.modifyEffect( + state, + Effect.fnUntraced(function* (data) { + if (!data.accounts[id]) return [undefined, data] as const + + const next = { + ...data, + accounts: { + ...data.accounts, + [id]: new Info({ + id, + serviceID: existing.serviceID, + description: updates.description ?? existing.description, + credential: updates.credential ?? existing.credential, + }), + }, + } + + yield* write(next) + return [undefined, next] as const + }), + ) + }), + + remove: Effect.fn("Auth.remove")(function* (id) { + const removed = yield* SynchronizedRef.modifyEffect( + state, + Effect.fnUntraced(function* (data) { + const accounts = { ...data.accounts } + const active = { ...data.active } + const removed = accounts[id] + if (!removed) return [undefined, data] as const + const wasActive = active[removed.serviceID] === id + delete accounts[id] + const replacement = Object.values(accounts).find((account) => account.serviceID === removed.serviceID) + if (wasActive) { + if (replacement) active[removed.serviceID] = replacement.id + else delete active[removed.serviceID] + } + + const next = { ...data, accounts, active } + yield* write(next) + return [ + { + account: removed, + switched: wasActive ? { serviceID: removed.serviceID, from: id, to: replacement?.id } : undefined, + }, + next, + ] as const + }), + ) + if (removed) { + yield* events.publish(Event.Removed, { account: removed.account }) + if (removed.switched) yield* events.publish(Event.Switched, removed.switched) + } + }), + + activate, + } + + return Service.of(result) + }), +) + +export const defaultLayer = layer.pipe( + Layer.provide(AppFileSystem.defaultLayer), + Layer.provide(Global.defaultLayer), + Layer.provide(EventV2.defaultLayer), +) diff --git a/packages/core/src/catalog.ts b/packages/core/src/catalog.ts index 22edc13402..d318544854 100644 --- a/packages/core/src/catalog.ts +++ b/packages/core/src/catalog.ts @@ -89,7 +89,7 @@ enableMapSet() export const layer = Layer.effect( Service, Effect.gen(function* () { - yield* Location.Service + const location = yield* Location.Service const plugin = yield* PluginV2.Service const events = yield* EventV2.Service const policy = yield* Policy.Service @@ -199,6 +199,11 @@ export const layer = Layer.effect( }) yield* events.subscribe(PluginV2.Event.Added).pipe( + // Plugin registries are location scoped even though the event bus is process scoped. + Stream.filter( + (event) => + event.location?.directory === location.directory && event.location.workspaceID === location.workspaceID, + ), Stream.runForEach((event) => state.update((catalog) => plugin.triggerFor(event.data.id, "catalog.transform", catalog, {}), "plugin.added"), ), @@ -317,8 +322,7 @@ export const layer = Layer.effect( const SMALL_MODEL_RE = /\b(nano|flash|lite|mini|haiku|small|fast)\b/ -export const defaultLayer = layer.pipe( - Layer.provide(EventV2.defaultLayer), - Layer.provide(PluginV2.defaultLayer), - Layer.provide(Policy.defaultLayer), +export const locationLayer = layer.pipe( + Layer.provideMerge(PluginV2.locationLayer), + Layer.provideMerge(Policy.locationLayer), ) diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index c9e7e4ea21..d5f7bfdffc 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -200,8 +200,4 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer.pipe( - Layer.provide(AppFileSystem.defaultLayer), - Layer.provide(Global.defaultLayer), - Layer.provide(Policy.defaultLayer), -) +export const locationLayer = layer.pipe(Layer.provideMerge(Policy.locationLayer)) diff --git a/packages/opencode/src/control-plane/workspace.sql.ts b/packages/core/src/control-plane/workspace.sql.ts similarity index 66% rename from packages/opencode/src/control-plane/workspace.sql.ts rename to packages/core/src/control-plane/workspace.sql.ts index 1afaf7cbc9..ef5195216a 100644 --- a/packages/opencode/src/control-plane/workspace.sql.ts +++ b/packages/core/src/control-plane/workspace.sql.ts @@ -1,17 +1,17 @@ import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core" -import { ProjectTable } from "../project/project.sql" -import type { ProjectID } from "../project/schema" -import type { WorkspaceID } from "./schema" +import { ProjectTable } from "../project/sql" +import { ProjectV2 } from "../project" +import { WorkspaceV2 } from "../workspace" export const WorkspaceTable = sqliteTable("workspace", { - id: text().$type().primaryKey(), + id: text().$type().primaryKey(), type: text().notNull(), name: text().notNull().default(""), branch: text(), directory: text(), extra: text({ mode: "json" }), project_id: text() - .$type() + .$type() .notNull() .references(() => ProjectTable.id, { onDelete: "cascade" }), time_used: integer() diff --git a/packages/opencode/src/data-migration.sql.ts b/packages/core/src/data-migration.sql.ts similarity index 100% rename from packages/opencode/src/data-migration.sql.ts rename to packages/core/src/data-migration.sql.ts diff --git a/packages/core/src/database/database.ts b/packages/core/src/database/database.ts new file mode 100644 index 0000000000..ba7aa91b0e --- /dev/null +++ b/packages/core/src/database/database.ts @@ -0,0 +1,60 @@ +export * as Database from "./database" + +import { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite" +import { layer as sqliteLayer } from "#sqlite" +import { Context, Effect, Layer } from "effect" +import { Global } from "../global" +import { Flag } from "../flag/flag" +import { isAbsolute, join } from "path" +import { DatabaseMigration } from "./migration" +import { InstallationChannel } from "../installation/version" + +const makeDatabase = EffectDrizzleSqlite.makeWithDefaults() +type DatabaseShape = Effect.Success + +export interface Interface { + db: DatabaseShape +} + +export class Service extends Context.Service()("@opencode/v2/storage/Database") {} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const db = yield* makeDatabase + + yield* db.run("PRAGMA journal_mode = WAL") + yield* db.run("PRAGMA synchronous = NORMAL") + yield* db.run("PRAGMA busy_timeout = 5000") + yield* db.run("PRAGMA cache_size = -64000") + yield* db.run("PRAGMA foreign_keys = ON") + yield* db.run("PRAGMA wal_checkpoint(PASSIVE)") + yield* DatabaseMigration.apply(db) + + return { db } + }).pipe(Effect.orDie), +) + +export function layerFromPath(filename: string) { + return layer.pipe(Layer.provide(sqliteLayer({ filename }))) +} + +export function path() { + if (Flag.OPENCODE_DB) { + if (Flag.OPENCODE_DB === ":memory:" || isAbsolute(Flag.OPENCODE_DB)) return Flag.OPENCODE_DB + return join(Global.Path.data, Flag.OPENCODE_DB) + } + if ( + ["latest", "beta", "prod"].includes(InstallationChannel) || + process.env.OPENCODE_DISABLE_CHANNEL_DB === "1" || + process.env.OPENCODE_DISABLE_CHANNEL_DB === "true" + ) + return join(Global.Path.data, "opencode.db") + return join(Global.Path.data, `opencode-${InstallationChannel.replace(/[^a-zA-Z0-9._-]/g, "-")}.db`) +} + +export const defaultLayer = Layer.unwrap( + Effect.gen(function* () { + return layerFromPath(path()) + }), +).pipe(Layer.provide(Global.defaultLayer)) diff --git a/packages/core/src/database/migration.gen.ts b/packages/core/src/database/migration.gen.ts new file mode 100644 index 0000000000..4447c60088 --- /dev/null +++ b/packages/core/src/database/migration.gen.ts @@ -0,0 +1,25 @@ +import type { DatabaseMigration } from "./migration" + +export const migrations = (await Promise.all([ + import("./migration/20260127222353_familiar_lady_ursula"), + import("./migration/20260211171708_add_project_commands"), + import("./migration/20260213144116_wakeful_the_professor"), + import("./migration/20260225215848_workspace"), + import("./migration/20260227213759_add_session_workspace_id"), + import("./migration/20260228203230_blue_harpoon"), + import("./migration/20260303231226_add_workspace_fields"), + import("./migration/20260309230000_move_org_to_state"), + import("./migration/20260312043431_session_message_cursor"), + import("./migration/20260323234822_events"), + import("./migration/20260410174513_workspace-name"), + import("./migration/20260413175956_chief_energizer"), + import("./migration/20260423070820_add_icon_url_override"), + import("./migration/20260427172553_slow_nightmare"), + import("./migration/20260428004200_add_session_path"), + import("./migration/20260501142318_next_venus"), + import("./migration/20260504145000_add_sync_owner"), + import("./migration/20260507164347_add_workspace_time"), + import("./migration/20260510033149_session_usage"), + import("./migration/20260511000411_data_migration_state"), + import("./migration/20260530232709_lovely_romulus"), +])).map((module) => module.default) satisfies DatabaseMigration.Migration[] diff --git a/packages/core/src/database/migration.ts b/packages/core/src/database/migration.ts new file mode 100644 index 0000000000..42aebf02d1 --- /dev/null +++ b/packages/core/src/database/migration.ts @@ -0,0 +1,58 @@ +export * as DatabaseMigration from "./migration" + +import { sql } from "drizzle-orm" +import { Effect } from "effect" +import type { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite" +import { migrations } from "./migration.gen" + +type Database = EffectDrizzleSqlite.EffectSQLiteDatabase +type Transaction = Parameters[0]>[0] + +export type Migration = { + id: string + up: (tx: Transaction) => Effect.Effect +} + +export function apply(db: Database) { + return applyOnly(db, migrations) +} + +export function applyOnly(db: Database, input: Migration[]) { + return Effect.gen(function* () { + yield* db.run( + sql`CREATE TABLE IF NOT EXISTS ${sql.identifier("migration")} (id TEXT PRIMARY KEY, time_completed INTEGER NOT NULL)`, + ) + let completed = new Set( + (yield* db.all<{ id: string }>(sql`SELECT id FROM ${sql.identifier("migration")}`)).map((row) => row.id), + ) + if (completed.size === 0) { + // Existing installs used Drizzle's migration journal. Seed the new + // journal once so TypeScript migrations don't replay old SQL. + if ( + yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ${"__drizzle_migrations"}`) + ) { + yield* db.run(sql` + INSERT OR IGNORE INTO ${sql.identifier("migration")} (id, time_completed) + SELECT name, ${Date.now()} + FROM ${sql.identifier("__drizzle_migrations")} + WHERE name IS NOT NULL + `) + completed = new Set( + (yield* db.all<{ id: string }>(sql`SELECT id FROM ${sql.identifier("migration")}`)).map((row) => row.id), + ) + } + } + + for (const migration of input) { + if (completed.has(migration.id)) continue + yield* db.transaction((tx) => + Effect.gen(function* () { + yield* migration.up(tx) + yield* tx.run( + sql`INSERT INTO ${sql.identifier("migration")} (id, time_completed) VALUES (${migration.id}, ${Date.now()})`, + ) + }), + ) + } + }) +} diff --git a/packages/core/src/database/migration/20260127222353_familiar_lady_ursula.ts b/packages/core/src/database/migration/20260127222353_familiar_lady_ursula.ts new file mode 100644 index 0000000000..468a7103fb --- /dev/null +++ b/packages/core/src/database/migration/20260127222353_familiar_lady_ursula.ts @@ -0,0 +1,107 @@ +import { Effect } from "effect" +import type { DatabaseMigration } from "../migration" + +export default { + id: "20260127222353_familiar_lady_ursula", + up(tx) { + return Effect.gen(function* () { + yield* tx.run(` + CREATE TABLE \`project\` ( + \`id\` text PRIMARY KEY, + \`worktree\` text NOT NULL, + \`vcs\` text, + \`name\` text, + \`icon_url\` text, + \`icon_color\` text, + \`time_created\` integer NOT NULL, + \`time_updated\` integer NOT NULL, + \`time_initialized\` integer, + \`sandboxes\` text NOT NULL + ); + `) + yield* tx.run(` + CREATE TABLE \`message\` ( + \`id\` text PRIMARY KEY, + \`session_id\` text NOT NULL, + \`time_created\` integer NOT NULL, + \`time_updated\` integer NOT NULL, + \`data\` text NOT NULL, + CONSTRAINT \`fk_message_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE + ); + `) + yield* tx.run(` + CREATE TABLE \`part\` ( + \`id\` text PRIMARY KEY, + \`message_id\` text NOT NULL, + \`session_id\` text NOT NULL, + \`time_created\` integer NOT NULL, + \`time_updated\` integer NOT NULL, + \`data\` text NOT NULL, + CONSTRAINT \`fk_part_message_id_message_id_fk\` FOREIGN KEY (\`message_id\`) REFERENCES \`message\`(\`id\`) ON DELETE CASCADE + ); + `) + yield* tx.run(` + CREATE TABLE \`permission\` ( + \`project_id\` text PRIMARY KEY, + \`time_created\` integer NOT NULL, + \`time_updated\` integer NOT NULL, + \`data\` text NOT NULL, + CONSTRAINT \`fk_permission_project_id_project_id_fk\` FOREIGN KEY (\`project_id\`) REFERENCES \`project\`(\`id\`) ON DELETE CASCADE + ); + `) + yield* tx.run(` + CREATE TABLE \`session\` ( + \`id\` text PRIMARY KEY, + \`project_id\` text NOT NULL, + \`parent_id\` text, + \`slug\` text NOT NULL, + \`directory\` text NOT NULL, + \`title\` text NOT NULL, + \`version\` text NOT NULL, + \`share_url\` text, + \`summary_additions\` integer, + \`summary_deletions\` integer, + \`summary_files\` integer, + \`summary_diffs\` text, + \`revert\` text, + \`permission\` text, + \`time_created\` integer NOT NULL, + \`time_updated\` integer NOT NULL, + \`time_compacting\` integer, + \`time_archived\` integer, + CONSTRAINT \`fk_session_project_id_project_id_fk\` FOREIGN KEY (\`project_id\`) REFERENCES \`project\`(\`id\`) ON DELETE CASCADE + ); + `) + yield* tx.run(` + CREATE TABLE \`todo\` ( + \`session_id\` text NOT NULL, + \`content\` text NOT NULL, + \`status\` text NOT NULL, + \`priority\` text NOT NULL, + \`position\` integer NOT NULL, + \`time_created\` integer NOT NULL, + \`time_updated\` integer NOT NULL, + CONSTRAINT \`todo_pk\` PRIMARY KEY(\`session_id\`, \`position\`), + CONSTRAINT \`fk_todo_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE + ); + `) + yield* tx.run(` + CREATE TABLE \`session_share\` ( + \`session_id\` text PRIMARY KEY, + \`id\` text NOT NULL, + \`secret\` text NOT NULL, + \`url\` text NOT NULL, + \`time_created\` integer NOT NULL, + \`time_updated\` integer NOT NULL, + CONSTRAINT \`fk_session_share_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE + ); + `) + yield* tx.run(`CREATE INDEX \`message_session_idx\` ON \`message\` (\`session_id\`);`) + yield* tx.run(`CREATE INDEX \`part_message_idx\` ON \`part\` (\`message_id\`);`) + yield* tx.run(`CREATE INDEX \`part_session_idx\` ON \`part\` (\`session_id\`);`) + yield* tx.run(`CREATE INDEX \`session_project_idx\` ON \`session\` (\`project_id\`);`) + yield* tx.run(`CREATE INDEX \`session_parent_idx\` ON \`session\` (\`parent_id\`);`) + yield* tx.run(`CREATE INDEX \`todo_session_idx\` ON \`todo\` (\`session_id\`);`) + }) + }, +} satisfies DatabaseMigration.Migration diff --git a/packages/core/src/database/migration/20260211171708_add_project_commands.ts b/packages/core/src/database/migration/20260211171708_add_project_commands.ts new file mode 100644 index 0000000000..d31a533db3 --- /dev/null +++ b/packages/core/src/database/migration/20260211171708_add_project_commands.ts @@ -0,0 +1,11 @@ +import { Effect } from "effect" +import type { DatabaseMigration } from "../migration" + +export default { + id: "20260211171708_add_project_commands", + up(tx) { + return Effect.gen(function* () { + yield* tx.run(`ALTER TABLE \`project\` ADD \`commands\` text;`) + }) + }, +} satisfies DatabaseMigration.Migration diff --git a/packages/core/src/database/migration/20260213144116_wakeful_the_professor.ts b/packages/core/src/database/migration/20260213144116_wakeful_the_professor.ts new file mode 100644 index 0000000000..8077182d93 --- /dev/null +++ b/packages/core/src/database/migration/20260213144116_wakeful_the_professor.ts @@ -0,0 +1,23 @@ +import { Effect } from "effect" +import type { DatabaseMigration } from "../migration" + +export default { + id: "20260213144116_wakeful_the_professor", + up(tx) { + return Effect.gen(function* () { + yield* tx.run(` + CREATE TABLE \`control_account\` ( + \`email\` text NOT NULL, + \`url\` text NOT NULL, + \`access_token\` text NOT NULL, + \`refresh_token\` text NOT NULL, + \`token_expiry\` integer, + \`active\` integer NOT NULL, + \`time_created\` integer NOT NULL, + \`time_updated\` integer NOT NULL, + CONSTRAINT \`control_account_pk\` PRIMARY KEY(\`email\`, \`url\`) + ); + `) + }) + }, +} satisfies DatabaseMigration.Migration diff --git a/packages/core/src/database/migration/20260225215848_workspace.ts b/packages/core/src/database/migration/20260225215848_workspace.ts new file mode 100644 index 0000000000..cc816951ef --- /dev/null +++ b/packages/core/src/database/migration/20260225215848_workspace.ts @@ -0,0 +1,19 @@ +import { Effect } from "effect" +import type { DatabaseMigration } from "../migration" + +export default { + id: "20260225215848_workspace", + up(tx) { + return Effect.gen(function* () { + yield* tx.run(` + CREATE TABLE \`workspace\` ( + \`id\` text PRIMARY KEY, + \`branch\` text, + \`project_id\` text NOT NULL, + \`config\` text NOT NULL, + CONSTRAINT \`fk_workspace_project_id_project_id_fk\` FOREIGN KEY (\`project_id\`) REFERENCES \`project\`(\`id\`) ON DELETE CASCADE + ); + `) + }) + }, +} satisfies DatabaseMigration.Migration diff --git a/packages/core/src/database/migration/20260227213759_add_session_workspace_id.ts b/packages/core/src/database/migration/20260227213759_add_session_workspace_id.ts new file mode 100644 index 0000000000..430407156d --- /dev/null +++ b/packages/core/src/database/migration/20260227213759_add_session_workspace_id.ts @@ -0,0 +1,12 @@ +import { Effect } from "effect" +import type { DatabaseMigration } from "../migration" + +export default { + id: "20260227213759_add_session_workspace_id", + up(tx) { + return Effect.gen(function* () { + yield* tx.run(`ALTER TABLE \`session\` ADD \`workspace_id\` text;`) + yield* tx.run(`CREATE INDEX \`session_workspace_idx\` ON \`session\` (\`workspace_id\`);`) + }) + }, +} satisfies DatabaseMigration.Migration diff --git a/packages/core/src/database/migration/20260228203230_blue_harpoon.ts b/packages/core/src/database/migration/20260228203230_blue_harpoon.ts new file mode 100644 index 0000000000..83e2978f70 --- /dev/null +++ b/packages/core/src/database/migration/20260228203230_blue_harpoon.ts @@ -0,0 +1,30 @@ +import { Effect } from "effect" +import type { DatabaseMigration } from "../migration" + +export default { + id: "20260228203230_blue_harpoon", + up(tx) { + return Effect.gen(function* () { + yield* tx.run(` + CREATE TABLE \`account\` ( + \`id\` text PRIMARY KEY, + \`email\` text NOT NULL, + \`url\` text NOT NULL, + \`access_token\` text NOT NULL, + \`refresh_token\` text NOT NULL, + \`token_expiry\` integer, + \`selected_org_id\` text, + \`time_created\` integer NOT NULL, + \`time_updated\` integer NOT NULL + ); + `) + yield* tx.run(` + CREATE TABLE \`account_state\` ( + \`id\` integer PRIMARY KEY NOT NULL, + \`active_account_id\` text, + FOREIGN KEY (\`active_account_id\`) REFERENCES \`account\`(\`id\`) ON UPDATE no action ON DELETE set null + ); + `) + }) + }, +} satisfies DatabaseMigration.Migration diff --git a/packages/core/src/database/migration/20260303231226_add_workspace_fields.ts b/packages/core/src/database/migration/20260303231226_add_workspace_fields.ts new file mode 100644 index 0000000000..380e9cc68b --- /dev/null +++ b/packages/core/src/database/migration/20260303231226_add_workspace_fields.ts @@ -0,0 +1,15 @@ +import { Effect } from "effect" +import type { DatabaseMigration } from "../migration" + +export default { + id: "20260303231226_add_workspace_fields", + up(tx) { + return Effect.gen(function* () { + yield* tx.run(`ALTER TABLE \`workspace\` ADD \`type\` text NOT NULL;`) + yield* tx.run(`ALTER TABLE \`workspace\` ADD \`name\` text;`) + yield* tx.run(`ALTER TABLE \`workspace\` ADD \`directory\` text;`) + yield* tx.run(`ALTER TABLE \`workspace\` ADD \`extra\` text;`) + yield* tx.run(`ALTER TABLE \`workspace\` DROP COLUMN \`config\`;`) + }) + }, +} satisfies DatabaseMigration.Migration diff --git a/packages/core/src/database/migration/20260309230000_move_org_to_state.ts b/packages/core/src/database/migration/20260309230000_move_org_to_state.ts new file mode 100644 index 0000000000..63671a84fd --- /dev/null +++ b/packages/core/src/database/migration/20260309230000_move_org_to_state.ts @@ -0,0 +1,13 @@ +import { Effect } from "effect" +import type { DatabaseMigration } from "../migration" + +export default { + id: "20260309230000_move_org_to_state", + up(tx) { + return Effect.gen(function* () { + yield* tx.run(`ALTER TABLE \`account_state\` ADD \`active_org_id\` text;`) + yield* tx.run(`UPDATE \`account_state\` SET \`active_org_id\` = (SELECT \`selected_org_id\` FROM \`account\` WHERE \`account\`.\`id\` = \`account_state\`.\`active_account_id\`);`) + yield* tx.run(`ALTER TABLE \`account\` DROP COLUMN \`selected_org_id\`;`) + }) + }, +} satisfies DatabaseMigration.Migration diff --git a/packages/core/src/database/migration/20260312043431_session_message_cursor.ts b/packages/core/src/database/migration/20260312043431_session_message_cursor.ts new file mode 100644 index 0000000000..86e20a66d2 --- /dev/null +++ b/packages/core/src/database/migration/20260312043431_session_message_cursor.ts @@ -0,0 +1,14 @@ +import { Effect } from "effect" +import type { DatabaseMigration } from "../migration" + +export default { + id: "20260312043431_session_message_cursor", + up(tx) { + return Effect.gen(function* () { + yield* tx.run(`DROP INDEX IF EXISTS \`message_session_idx\`;`) + yield* tx.run(`DROP INDEX IF EXISTS \`part_message_idx\`;`) + yield* tx.run(`CREATE INDEX \`message_session_time_created_id_idx\` ON \`message\` (\`session_id\`,\`time_created\`,\`id\`);`) + yield* tx.run(`CREATE INDEX \`part_message_id_id_idx\` ON \`part\` (\`message_id\`,\`id\`);`) + }) + }, +} satisfies DatabaseMigration.Migration diff --git a/packages/core/src/database/migration/20260323234822_events.ts b/packages/core/src/database/migration/20260323234822_events.ts new file mode 100644 index 0000000000..2b1996fbac --- /dev/null +++ b/packages/core/src/database/migration/20260323234822_events.ts @@ -0,0 +1,26 @@ +import { Effect } from "effect" +import type { DatabaseMigration } from "../migration" + +export default { + id: "20260323234822_events", + up(tx) { + return Effect.gen(function* () { + yield* tx.run(` + CREATE TABLE \`event_sequence\` ( + \`aggregate_id\` text PRIMARY KEY, + \`seq\` integer NOT NULL + ); + `) + yield* tx.run(` + CREATE TABLE \`event\` ( + \`id\` text PRIMARY KEY, + \`aggregate_id\` text NOT NULL, + \`seq\` integer NOT NULL, + \`type\` text NOT NULL, + \`data\` text NOT NULL, + CONSTRAINT \`fk_event_aggregate_id_event_sequence_aggregate_id_fk\` FOREIGN KEY (\`aggregate_id\`) REFERENCES \`event_sequence\`(\`aggregate_id\`) ON DELETE CASCADE + ); + `) + }) + }, +} satisfies DatabaseMigration.Migration diff --git a/packages/core/src/database/migration/20260410174513_workspace-name.ts b/packages/core/src/database/migration/20260410174513_workspace-name.ts new file mode 100644 index 0000000000..3b37a0bfc1 --- /dev/null +++ b/packages/core/src/database/migration/20260410174513_workspace-name.ts @@ -0,0 +1,27 @@ +import { Effect } from "effect" +import type { DatabaseMigration } from "../migration" + +export default { + id: "20260410174513_workspace-name", + up(tx) { + return Effect.gen(function* () { + yield* tx.run(`PRAGMA foreign_keys=OFF;`) + yield* tx.run(` + CREATE TABLE \`__new_workspace\` ( + \`id\` text PRIMARY KEY, + \`type\` text NOT NULL, + \`name\` text DEFAULT '' NOT NULL, + \`branch\` text, + \`directory\` text, + \`extra\` text, + \`project_id\` text NOT NULL, + CONSTRAINT \`fk_workspace_project_id_project_id_fk\` FOREIGN KEY (\`project_id\`) REFERENCES \`project\`(\`id\`) ON DELETE CASCADE + ); + `) + yield* tx.run(`INSERT INTO \`__new_workspace\`(\`id\`, \`type\`, \`branch\`, \`name\`, \`directory\`, \`extra\`, \`project_id\`) SELECT \`id\`, \`type\`, \`branch\`, \`name\`, \`directory\`, \`extra\`, \`project_id\` FROM \`workspace\`;`) + yield* tx.run(`DROP TABLE \`workspace\`;`) + yield* tx.run(`ALTER TABLE \`__new_workspace\` RENAME TO \`workspace\`;`) + yield* tx.run(`PRAGMA foreign_keys=ON;`) + }) + }, +} satisfies DatabaseMigration.Migration diff --git a/packages/core/src/database/migration/20260413175956_chief_energizer.ts b/packages/core/src/database/migration/20260413175956_chief_energizer.ts new file mode 100644 index 0000000000..a03477e09e --- /dev/null +++ b/packages/core/src/database/migration/20260413175956_chief_energizer.ts @@ -0,0 +1,24 @@ +import { Effect } from "effect" +import type { DatabaseMigration } from "../migration" + +export default { + id: "20260413175956_chief_energizer", + up(tx) { + return Effect.gen(function* () { + yield* tx.run(` + CREATE TABLE \`session_entry\` ( + \`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_entry_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE + ); + `) + yield* tx.run(`CREATE INDEX \`session_entry_session_idx\` ON \`session_entry\` (\`session_id\`);`) + yield* tx.run(`CREATE INDEX \`session_entry_session_type_idx\` ON \`session_entry\` (\`session_id\`,\`type\`);`) + yield* tx.run(`CREATE INDEX \`session_entry_time_created_idx\` ON \`session_entry\` (\`time_created\`);`) + }) + }, +} satisfies DatabaseMigration.Migration diff --git a/packages/core/src/database/migration/20260423070820_add_icon_url_override.ts b/packages/core/src/database/migration/20260423070820_add_icon_url_override.ts new file mode 100644 index 0000000000..20b1f9163a --- /dev/null +++ b/packages/core/src/database/migration/20260423070820_add_icon_url_override.ts @@ -0,0 +1,14 @@ +import { Effect } from "effect" +import type { DatabaseMigration } from "../migration" + +export default { + id: "20260423070820_add_icon_url_override", + up(tx) { + return Effect.gen(function* () { + yield* tx.run(` + ALTER TABLE \`project\` ADD \`icon_url_override\` text; + UPDATE \`project\` SET \`icon_url_override\` = \`icon_url\` WHERE \`icon_url\` IS NOT NULL; + `) + }) + }, +} satisfies DatabaseMigration.Migration diff --git a/packages/core/src/database/migration/20260427172553_slow_nightmare.ts b/packages/core/src/database/migration/20260427172553_slow_nightmare.ts new file mode 100644 index 0000000000..0b0bd133a6 --- /dev/null +++ b/packages/core/src/database/migration/20260427172553_slow_nightmare.ts @@ -0,0 +1,28 @@ +import { Effect } from "effect" +import type { DatabaseMigration } from "../migration" + +export default { + id: "20260427172553_slow_nightmare", + up(tx) { + return Effect.gen(function* () { + yield* tx.run(` + 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 + ); + `) + yield* tx.run(`DROP INDEX IF EXISTS \`session_entry_session_idx\`;`) + yield* tx.run(`DROP INDEX IF EXISTS \`session_entry_session_type_idx\`;`) + yield* tx.run(`DROP INDEX IF EXISTS \`session_entry_time_created_idx\`;`) + yield* tx.run(`CREATE INDEX \`session_message_session_idx\` ON \`session_message\` (\`session_id\`);`) + yield* tx.run(`CREATE INDEX \`session_message_session_type_idx\` ON \`session_message\` (\`session_id\`,\`type\`);`) + yield* tx.run(`CREATE INDEX \`session_message_time_created_idx\` ON \`session_message\` (\`time_created\`);`) + yield* tx.run(`DROP TABLE \`session_entry\`;`) + }) + }, +} satisfies DatabaseMigration.Migration diff --git a/packages/core/src/database/migration/20260428004200_add_session_path.ts b/packages/core/src/database/migration/20260428004200_add_session_path.ts new file mode 100644 index 0000000000..a60ef377fc --- /dev/null +++ b/packages/core/src/database/migration/20260428004200_add_session_path.ts @@ -0,0 +1,11 @@ +import { Effect } from "effect" +import type { DatabaseMigration } from "../migration" + +export default { + id: "20260428004200_add_session_path", + up(tx) { + return Effect.gen(function* () { + yield* tx.run(`ALTER TABLE \`session\` ADD \`path\` text;`) + }) + }, +} satisfies DatabaseMigration.Migration diff --git a/packages/core/src/database/migration/20260501142318_next_venus.ts b/packages/core/src/database/migration/20260501142318_next_venus.ts new file mode 100644 index 0000000000..6c5b078f8f --- /dev/null +++ b/packages/core/src/database/migration/20260501142318_next_venus.ts @@ -0,0 +1,12 @@ +import { Effect } from "effect" +import type { DatabaseMigration } from "../migration" + +export default { + id: "20260501142318_next_venus", + up(tx) { + return Effect.gen(function* () { + yield* tx.run(`ALTER TABLE \`session\` ADD \`agent\` text;`) + yield* tx.run(`ALTER TABLE \`session\` ADD \`model\` text;`) + }) + }, +} satisfies DatabaseMigration.Migration diff --git a/packages/core/src/database/migration/20260504145000_add_sync_owner.ts b/packages/core/src/database/migration/20260504145000_add_sync_owner.ts new file mode 100644 index 0000000000..33e8554914 --- /dev/null +++ b/packages/core/src/database/migration/20260504145000_add_sync_owner.ts @@ -0,0 +1,11 @@ +import { Effect } from "effect" +import type { DatabaseMigration } from "../migration" + +export default { + id: "20260504145000_add_sync_owner", + up(tx) { + return Effect.gen(function* () { + yield* tx.run(`ALTER TABLE \`event_sequence\` ADD \`owner_id\` text;`) + }) + }, +} satisfies DatabaseMigration.Migration diff --git a/packages/core/src/database/migration/20260507164347_add_workspace_time.ts b/packages/core/src/database/migration/20260507164347_add_workspace_time.ts new file mode 100644 index 0000000000..df7e90fc93 --- /dev/null +++ b/packages/core/src/database/migration/20260507164347_add_workspace_time.ts @@ -0,0 +1,11 @@ +import { Effect } from "effect" +import type { DatabaseMigration } from "../migration" + +export default { + id: "20260507164347_add_workspace_time", + up(tx) { + return Effect.gen(function* () { + yield* tx.run(`ALTER TABLE \`workspace\` ADD \`time_used\` integer NOT NULL DEFAULT 0;`) + }) + }, +} satisfies DatabaseMigration.Migration diff --git a/packages/core/src/database/migration/20260510033149_session_usage.ts b/packages/core/src/database/migration/20260510033149_session_usage.ts new file mode 100644 index 0000000000..5dcd1f658e --- /dev/null +++ b/packages/core/src/database/migration/20260510033149_session_usage.ts @@ -0,0 +1,56 @@ +import { Effect } from "effect" +import type { DatabaseMigration } from "../migration" + +export default { + id: "20260510033149_session_usage", + up(tx) { + return Effect.gen(function* () { + yield* tx.run(`ALTER TABLE \`session\` ADD \`cost\` real DEFAULT 0 NOT NULL;`) + yield* tx.run(`ALTER TABLE \`session\` ADD \`tokens_input\` integer DEFAULT 0 NOT NULL;`) + yield* tx.run(`ALTER TABLE \`session\` ADD \`tokens_output\` integer DEFAULT 0 NOT NULL;`) + yield* tx.run(`ALTER TABLE \`session\` ADD \`tokens_reasoning\` integer DEFAULT 0 NOT NULL;`) + yield* tx.run(`ALTER TABLE \`session\` ADD \`tokens_cache_read\` integer DEFAULT 0 NOT NULL;`) + yield* tx.run(`ALTER TABLE \`session\` ADD \`tokens_cache_write\` integer DEFAULT 0 NOT NULL;`) + yield* tx.run(` + UPDATE session + SET + cost = coalesce(( + SELECT sum(coalesce(json_extract(message.data, '$.cost'), 0)) + FROM message + WHERE message.session_id = session.id + AND json_extract(message.data, '$.role') = 'assistant' + ), 0), + tokens_input = coalesce(( + SELECT sum(coalesce(json_extract(message.data, '$.tokens.input'), 0)) + FROM message + WHERE message.session_id = session.id + AND json_extract(message.data, '$.role') = 'assistant' + ), 0), + tokens_output = coalesce(( + SELECT sum(coalesce(json_extract(message.data, '$.tokens.output'), 0)) + FROM message + WHERE message.session_id = session.id + AND json_extract(message.data, '$.role') = 'assistant' + ), 0), + tokens_reasoning = coalesce(( + SELECT sum(coalesce(json_extract(message.data, '$.tokens.reasoning'), 0)) + FROM message + WHERE message.session_id = session.id + AND json_extract(message.data, '$.role') = 'assistant' + ), 0), + tokens_cache_read = coalesce(( + SELECT sum(coalesce(json_extract(message.data, '$.tokens.cache.read'), 0)) + FROM message + WHERE message.session_id = session.id + AND json_extract(message.data, '$.role') = 'assistant' + ), 0), + tokens_cache_write = coalesce(( + SELECT sum(coalesce(json_extract(message.data, '$.tokens.cache.write'), 0)) + FROM message + WHERE message.session_id = session.id + AND json_extract(message.data, '$.role') = 'assistant' + ), 0) + `) + }) + }, +} satisfies DatabaseMigration.Migration diff --git a/packages/core/src/database/migration/20260511000411_data_migration_state.ts b/packages/core/src/database/migration/20260511000411_data_migration_state.ts new file mode 100644 index 0000000000..7ff0b66189 --- /dev/null +++ b/packages/core/src/database/migration/20260511000411_data_migration_state.ts @@ -0,0 +1,16 @@ +import { Effect } from "effect" +import type { DatabaseMigration } from "../migration" + +export default { + id: "20260511000411_data_migration_state", + up(tx) { + return Effect.gen(function* () { + yield* tx.run(` + CREATE TABLE \`data_migration\` ( + \`name\` text PRIMARY KEY, + \`time_completed\` integer NOT NULL + ); + `) + }) + }, +} satisfies DatabaseMigration.Migration diff --git a/packages/core/src/database/migration/20260530232709_lovely_romulus.ts b/packages/core/src/database/migration/20260530232709_lovely_romulus.ts new file mode 100644 index 0000000000..2fe023543d --- /dev/null +++ b/packages/core/src/database/migration/20260530232709_lovely_romulus.ts @@ -0,0 +1,11 @@ +import { Effect } from "effect" +import type { DatabaseMigration } from "../migration" + +export default { + id: "20260530232709_lovely_romulus", + up(tx) { + return Effect.gen(function* () { + yield* tx.run(`ALTER TABLE \`session\` ADD \`metadata\` text;`) + }) + }, +} satisfies DatabaseMigration.Migration diff --git a/packages/opencode/src/storage/schema.sql.ts b/packages/core/src/database/schema.sql.ts similarity index 100% rename from packages/opencode/src/storage/schema.sql.ts rename to packages/core/src/database/schema.sql.ts diff --git a/packages/core/src/database/sqlite.bun.ts b/packages/core/src/database/sqlite.bun.ts new file mode 100644 index 0000000000..02a41e07cb --- /dev/null +++ b/packages/core/src/database/sqlite.bun.ts @@ -0,0 +1,177 @@ +import { Database } from "bun:sqlite" +import { drizzle } from "drizzle-orm/bun-sqlite" +import * as Context from "effect/Context" +import * as Effect from "effect/Effect" +import * as Fiber from "effect/Fiber" +import { identity } from "effect/Function" +import * as Layer from "effect/Layer" +import * as Scope from "effect/Scope" +import * as Semaphore from "effect/Semaphore" +import * as Stream from "effect/Stream" +import * as Reactivity from "effect/unstable/reactivity/Reactivity" +import * as Client from "effect/unstable/sql/SqlClient" +import type { Connection } from "effect/unstable/sql/SqlConnection" +import { classifySqliteError, SqlError } from "effect/unstable/sql/SqlError" +import * as Statement from "effect/unstable/sql/Statement" +import { Sqlite } from "./sqlite" + +const ATTR_DB_SYSTEM_NAME = "db.system.name" + +const TypeId = "~@opencode-ai/core/database/SqliteBun" as const +type TypeId = typeof TypeId + +interface SqliteClient extends Client.SqlClient { + readonly [TypeId]: TypeId + readonly config: Config + readonly export: Effect.Effect + readonly loadExtension: (path: string) => Effect.Effect + readonly updateValues: never +} + +interface Config { + readonly filename: string + readonly readonly?: boolean + readonly create?: boolean + readonly readwrite?: boolean + readonly disableWAL?: boolean + readonly spanAttributes?: Record + readonly transformResultNames?: (str: string) => string + readonly transformQueryNames?: (str: string) => string +} + +interface SqliteConnection extends Connection { + readonly export: Effect.Effect + readonly loadExtension: (path: string) => Effect.Effect +} + +const make = (options: Config) => + Effect.gen(function* () { + const native = (yield* Sqlite.Native) as Database + + const compiler = Statement.makeCompilerSqlite(options.transformQueryNames) + const transformRows = options.transformResultNames ? Statement.defaultTransforms(options.transformResultNames).array : undefined + + const run = (query: string, params: ReadonlyArray = []) => + Effect.withFiber>, SqlError>((fiber) => { + const statement = native.query(query) + // @ts-ignore bun-types missing safeIntegers method, fixed in https://github.com/oven-sh/bun/pull/26627 + statement.safeIntegers(Context.get(fiber.context, Client.SafeIntegers)) + try { + return Effect.succeed((statement.all(...(params as any)) ?? []) as Array>) + } catch (cause) { + return Effect.fail( + new SqlError({ + reason: classifySqliteError(cause, { message: "Failed to execute statement", operation: "execute" }), + }), + ) + } + }) + + const runValues = (query: string, params: ReadonlyArray = []) => + Effect.withFiber, SqlError>((fiber) => { + const statement = native.query(query) + // @ts-ignore bun-types missing safeIntegers method, fixed in https://github.com/oven-sh/bun/pull/26627 + statement.safeIntegers(Context.get(fiber.context, Client.SafeIntegers)) + try { + return Effect.succeed((statement.values(...(params as any)) ?? []) as Array) + } catch (cause) { + return Effect.fail( + new SqlError({ + reason: classifySqliteError(cause, { message: "Failed to execute statement", operation: "execute" }), + }), + ) + } + }) + + const connection = identity({ + execute(query, params, transformRows) { + return transformRows ? Effect.map(run(query, params), transformRows) : run(query, params) + }, + executeRaw(query, params) { + return run(query, params) + }, + executeValues(query, params) { + return runValues(query, params) + }, + executeUnprepared(query, params, transformRows) { + return this.execute(query, params, transformRows) + }, + executeStream() { + return Stream.die("executeStream not implemented") + }, + export: Effect.try({ + try: () => native.serialize(), + catch: (cause) => + new SqlError({ reason: classifySqliteError(cause, { message: "Failed to export database", operation: "export" }) }), + }), + loadExtension: (path) => + Effect.try({ + try: () => native.loadExtension(path), + catch: (cause) => + new SqlError({ + reason: classifySqliteError(cause, { message: "Failed to load extension", operation: "loadExtension" }), + }), + }), + }) + + const semaphore = yield* Semaphore.make(1) + const acquirer = semaphore.withPermits(1)(Effect.succeed(connection)) + const transactionAcquirer = Effect.uninterruptibleMask((restore) => { + const fiber = Fiber.getCurrent()! + const scope = Context.getUnsafe(fiber.context, Scope.Scope) + return Effect.as(Effect.tap(restore(semaphore.take(1)), () => Scope.addFinalizer(scope, semaphore.release(1))), connection) + }) + + const client = Object.assign( + (yield* Client.make({ + acquirer, + compiler, + transactionAcquirer, + spanAttributes: [ + ...(options.spanAttributes ? Object.entries(options.spanAttributes) : []), + [ATTR_DB_SYSTEM_NAME, "sqlite"], + ], + transformRows, + })) as SqliteClient, + { + [TypeId]: TypeId, + config: options, + export: Effect.flatMap(acquirer, (_) => _.export), + loadExtension: (path: string) => Effect.flatMap(acquirer, (_) => _.loadExtension(path)), + }, + ) + + return client + }) + +const nativeLayer = (config: Config) => + Layer.effect( + Sqlite.Native, + Effect.gen(function* () { + const native = new Database(config.filename, { + readonly: config.readonly, + readwrite: config.readwrite ?? true, + create: config.create ?? true, + }) + yield* Effect.addFinalizer(() => Effect.sync(() => native.close())) + if (config.disableWAL !== true) native.run("PRAGMA journal_mode = WAL;") + return native + }), + ) + +const sqliteLayer = (config: Config) => Layer.effect(Client.SqlClient, make(config)) + +const drizzleLayer = Layer.effect( + Sqlite.Drizzle, + Effect.gen(function* () { + return drizzle({ client: (yield* Sqlite.Native) as Database }) + }), +) + +export const layer = (config: Config) => + Layer.merge( + nativeLayer(config), + Layer.merge(sqliteLayer(config), drizzleLayer).pipe(Layer.provide(nativeLayer(config))), + ).pipe( + Layer.provide(Reactivity.layer), + ) diff --git a/packages/core/src/database/sqlite.node.ts b/packages/core/src/database/sqlite.node.ts new file mode 100644 index 0000000000..cb9272adfb --- /dev/null +++ b/packages/core/src/database/sqlite.node.ts @@ -0,0 +1,172 @@ +import { DatabaseSync, type SQLInputValue } from "node:sqlite" +import { drizzle } from "drizzle-orm/node-sqlite" +import * as Context from "effect/Context" +import * as Effect from "effect/Effect" +import * as Fiber from "effect/Fiber" +import { identity } from "effect/Function" +import * as Layer from "effect/Layer" +import * as Scope from "effect/Scope" +import * as Semaphore from "effect/Semaphore" +import * as Stream from "effect/Stream" +import * as Reactivity from "effect/unstable/reactivity/Reactivity" +import * as Client from "effect/unstable/sql/SqlClient" +import type { Connection } from "effect/unstable/sql/SqlConnection" +import { classifySqliteError, SqlError } from "effect/unstable/sql/SqlError" +import * as Statement from "effect/unstable/sql/Statement" +import { Sqlite } from "./sqlite" + +const ATTR_DB_SYSTEM_NAME = "db.system.name" + +const TypeId = "~@opencode-ai/core/database/SqliteNode" as const +type TypeId = typeof TypeId + +interface SqliteClient extends Client.SqlClient { + readonly [TypeId]: TypeId + readonly config: Config + readonly loadExtension: (path: string) => Effect.Effect + readonly updateValues: never +} + +interface Config { + readonly filename: string + readonly readonly?: boolean + readonly create?: boolean + readonly readwrite?: boolean + readonly disableWAL?: boolean + readonly timeout?: number + readonly allowExtension?: boolean + readonly spanAttributes?: Record + readonly transformResultNames?: (str: string) => string + readonly transformQueryNames?: (str: string) => string +} + +interface SqliteConnection extends Connection { + readonly loadExtension: (path: string) => Effect.Effect +} + +const make = (options: Config) => + Effect.gen(function* () { + const native = (yield* Sqlite.Native) as DatabaseSync + + const compiler = Statement.makeCompilerSqlite(options.transformQueryNames) + const transformRows = options.transformResultNames ? Statement.defaultTransforms(options.transformResultNames).array : undefined + + const run = (query: string, params: ReadonlyArray = []) => + Effect.withFiber>, SqlError>((fiber) => { + const statement = native.prepare(query) + statement.setReadBigInts(Context.get(fiber.context, Client.SafeIntegers)) + try { + return Effect.succeed(statement.all(...(params as SQLInputValue[])) as Array>) + } catch (cause) { + return Effect.fail( + new SqlError({ + reason: classifySqliteError(cause, { message: "Failed to execute statement", operation: "execute" }), + }), + ) + } + }) + + const runValues = (query: string, params: ReadonlyArray = []) => + Effect.withFiber>, SqlError>((fiber) => { + const statement = native.prepare(query) + statement.setReadBigInts(Context.get(fiber.context, Client.SafeIntegers)) + statement.setReturnArrays(true) + try { + return Effect.succeed(statement.all(...(params as SQLInputValue[])) as unknown as ReadonlyArray>) + } catch (cause) { + return Effect.fail( + new SqlError({ + reason: classifySqliteError(cause, { message: "Failed to execute statement", operation: "execute" }), + }), + ) + } + }) + + const connection = identity({ + execute(query, params, transformRows) { + return transformRows ? Effect.map(run(query, params), transformRows) : run(query, params) + }, + executeRaw(query, params) { + return run(query, params) + }, + executeValues(query, params) { + return runValues(query, params) + }, + executeUnprepared(query, params, transformRows) { + return this.execute(query, params, transformRows) + }, + executeStream() { + return Stream.die("executeStream not implemented") + }, + loadExtension: (path) => + Effect.try({ + try: () => native.loadExtension(path), + catch: (cause) => + new SqlError({ + reason: classifySqliteError(cause, { message: "Failed to load extension", operation: "loadExtension" }), + }), + }), + }) + + const semaphore = yield* Semaphore.make(1) + const acquirer = semaphore.withPermits(1)(Effect.succeed(connection)) + const transactionAcquirer = Effect.uninterruptibleMask((restore) => { + const fiber = Fiber.getCurrent()! + const scope = Context.getUnsafe(fiber.context, Scope.Scope) + return Effect.as(Effect.tap(restore(semaphore.take(1)), () => Scope.addFinalizer(scope, semaphore.release(1))), connection) + }) + + const client = Object.assign( + (yield* Client.make({ + acquirer, + compiler, + transactionAcquirer, + spanAttributes: [ + ...(options.spanAttributes ? Object.entries(options.spanAttributes) : []), + [ATTR_DB_SYSTEM_NAME, "sqlite"], + ], + transformRows, + })) as SqliteClient, + { + [TypeId]: TypeId, + config: options, + loadExtension: (path: string) => Effect.flatMap(acquirer, (_) => _.loadExtension(path)), + }, + ) + + return client + }) + +const nativeLayer = (config: Config) => + Layer.effect( + Sqlite.Native, + Effect.gen(function* () { + const native = new DatabaseSync(config.filename, { + readOnly: config.readonly, + timeout: config.timeout, + allowExtension: config.allowExtension, + enableForeignKeyConstraints: true, + open: true, + }) + yield* Effect.addFinalizer(() => Effect.sync(() => native.close())) + if (config.disableWAL !== true && config.readonly !== true) native.exec("PRAGMA journal_mode = WAL;") + return native + }), + ) + +const sqliteLayer = (config: Config) => Layer.effect(Client.SqlClient, make(config)) + +const drizzleLayer = Layer.effect( + Sqlite.Drizzle, + Effect.gen(function* () { + return drizzle({ client: (yield* Sqlite.Native) as DatabaseSync }) as unknown as Sqlite.DrizzleClient + }), +) + +export const layer = (config: Config) => + Layer.merge( + nativeLayer(config), + Layer.merge(sqliteLayer(config), drizzleLayer).pipe(Layer.provide(nativeLayer(config))), + ).pipe( + Layer.provide(Reactivity.layer), + ) diff --git a/packages/core/src/database/sqlite.ts b/packages/core/src/database/sqlite.ts new file mode 100644 index 0000000000..d2304a5473 --- /dev/null +++ b/packages/core/src/database/sqlite.ts @@ -0,0 +1,8 @@ +export * as Sqlite from "./sqlite" + +import { Context } from "effect" +import type { drizzle } from "drizzle-orm/bun-sqlite" + +export type DrizzleClient = ReturnType +export class Native extends Context.Service()("@opencode-ai/core/database/SqliteNative") {} +export class Drizzle extends Context.Service()("@opencode-ai/core/database/SqliteDrizzle") {} diff --git a/packages/core/src/event.ts b/packages/core/src/event.ts index 339fbddecf..105fb12dd1 100644 --- a/packages/core/src/event.ts +++ b/packages/core/src/event.ts @@ -1,6 +1,9 @@ export * as EventV2 from "./event" import { Context, Effect, Layer, Option, PubSub, Schema, Stream } from "effect" +import { eq } from "drizzle-orm" +import { Database } from "./database/database" +import { EventSequenceTable, EventTable } from "./event/sql" import { Location } from "./location" import { withStatics } from "./schema" import { Identifier } from "./util/identifier" @@ -13,8 +16,10 @@ export type ID = typeof ID.Type export type Definition = { readonly type: Type - readonly version?: number - readonly aggregate?: string + readonly sync?: { + readonly version: number + readonly aggregate: string + } readonly data: DataSchema } @@ -29,14 +34,41 @@ export type Payload = { readonly metadata?: Record } +export type Projector = (event: Payload) => Effect.Effect +type AnyProjector = (event: Payload) => Effect.Effect +export type Listener = (event: Payload) => Effect.Effect export type Sync = (event: Payload) => Effect.Effect +export type Unsubscribe = Effect.Effect + +export type SerializedEvent = { + readonly id: ID + readonly type: string + readonly seq: number + readonly aggregateID: string + readonly data: Record +} + +export class InvalidSyncEventError extends Schema.TaggedErrorClass()( + "EventV2.InvalidSyncEvent", + { + type: Schema.String, + message: Schema.String, + }, +) {} + +export function versionedType(type: string, version: number) { + return `${type}.${version}` +} export const registry = new Map() +const syncRegistry = new Map }>() export function define(input: { readonly type: Type - readonly version?: number - readonly aggregate?: string + readonly sync?: { + readonly version: number + readonly aggregate: string + } readonly schema: Fields }): Schema.Schema>>> & Definition> { const Data = Schema.Struct(input.schema) @@ -51,11 +83,18 @@ export function define= existing.sync.version) { + registry.set(input.type, definition) + } + if (input.sync) + syncRegistry.set( + versionedType(input.type, input.sync.version), + definition as Definition & { readonly sync: NonNullable }, + ) return definition as Schema.Schema>>> & Definition> } @@ -67,20 +106,30 @@ export function definitions() { export interface PublishOptions { readonly id?: ID readonly metadata?: Record + readonly location?: Location.Ref } -export type Unsubscribe = Effect.Effect - export interface Interface { readonly publish: ( definition: D, data: Data, options?: PublishOptions, ) => Effect.Effect> - readonly publishEvent: (event: Payload) => Effect.Effect> readonly subscribe: (definition: D) => Stream.Stream> readonly all: () => Stream.Stream readonly sync: (handler: Sync) => Effect.Effect + readonly listen: (listener: Listener) => Effect.Effect + readonly project: (definition: D, projector: Projector) => Effect.Effect + readonly replay: ( + event: SerializedEvent, + options?: { readonly publish?: boolean; readonly ownerID?: string }, + ) => Effect.Effect + readonly replayAll: ( + events: SerializedEvent[], + options?: { readonly publish?: boolean; readonly ownerID?: string }, + ) => Effect.Effect + readonly remove: (aggregateID: string) => Effect.Effect + readonly claim: (aggregateID: string, ownerID: string) => Effect.Effect } export class Service extends Context.Service()("@opencode/Event") {} @@ -90,7 +139,10 @@ export const layer = Layer.effect( Effect.gen(function* () { const all = yield* PubSub.unbounded() const typed = new Map>() + const projectors = new Map() + const listeners = new Array() const syncHandlers = new Array() + const { db } = yield* Database.Service const getOrCreate = (definition: Definition) => Effect.gen(function* () { @@ -108,11 +160,97 @@ export const layer = Layer.effect( }), ) + function commitSyncEvent( + event: Payload, + input?: { readonly seq: number; readonly aggregateID: string; readonly ownerID?: string }, + ) { + return Effect.gen(function* () { + const definition = registry.get(event.type) + const sync = definition?.sync + if (sync) { + if (event.version !== sync.version) { + yield* Effect.die( + new InvalidSyncEventError({ + type: event.type, + message: `Expected event version ${sync.version}, got ${event.version}`, + }), + ) + } + const aggregateID = (event.data as Record)[sync.aggregate] + if (typeof aggregateID !== "string") { + yield* Effect.die( + new InvalidSyncEventError({ + type: event.type, + message: `Expected string aggregate field ${sync.aggregate}`, + }), + ) + } else { + const list = projectors.get(event.type) ?? [] + yield* db + .transaction( + () => + Effect.gen(function* () { + const row = yield* db + .select({ seq: EventSequenceTable.seq, ownerID: EventSequenceTable.owner_id }) + .from(EventSequenceTable) + .where(eq(EventSequenceTable.aggregate_id, aggregateID)) + .get() + .pipe(Effect.orDie) + const latest = row?.seq ?? -1 + if (input && input.seq <= latest) return + if (input && row?.ownerID && row.ownerID !== input.ownerID) return + const seq = input?.seq ?? latest + 1 + if (input && seq !== latest + 1) { + yield* Effect.die( + new InvalidSyncEventError({ + type: event.type, + message: `Sequence mismatch for aggregate ${aggregateID}: expected ${latest + 1}, got ${seq}`, + }), + ) + } + for (const projector of list) { + yield* projector(event as Payload) + } + yield* db + .insert(EventSequenceTable) + .values([{ aggregate_id: aggregateID, seq, owner_id: input?.ownerID }]) + .onConflictDoUpdate({ + target: EventSequenceTable.aggregate_id, + set: { seq }, + }) + .run() + .pipe(Effect.orDie) + yield* db + .insert(EventTable) + .values([ + { + id: event.id, + aggregate_id: aggregateID, + seq, + type: versionedType(definition.type, sync.version), + data: event.data as Record, + }, + ]) + .run() + .pipe(Effect.orDie) + }), + { behavior: "immediate" }, + ) + .pipe(Effect.orDie) + } + } + }) + } + function publishEvent(event: Payload) { return Effect.gen(function* () { for (const sync of syncHandlers) { yield* sync(event as Payload) } + yield* commitSyncEvent(event as Payload) + for (const listener of listeners) { + yield* listener(event as Payload) + } const pubsub = typed.get(event.type) if (pubsub) yield* PubSub.publish(pubsub, event as Payload) yield* PubSub.publish(all, event as Payload) @@ -122,25 +260,116 @@ export const layer = Layer.effect( function publish(definition: D, data: Data, options?: PublishOptions) { return Effect.gen(function* () { - const location = Option.getOrUndefined(yield* Effect.serviceOption(Location.Service)) - const event = { + const serviceLocation = Option.getOrUndefined(yield* Effect.serviceOption(Location.Service)) + const location = options?.location ?? + (serviceLocation + ? { directory: serviceLocation.directory, workspaceID: serviceLocation.workspaceID } + : undefined) + return yield* publishEvent({ id: options?.id ?? ID.create(), ...(options?.metadata ? { metadata: options.metadata } : {}), type: definition.type, - ...(definition.version === undefined ? {} : { version: definition.version }), - ...(location ? { location: { directory: location.directory, workspaceID: location.workspaceID } } : {}), + ...(definition.sync === undefined ? {} : { version: definition.sync.version }), + ...(location ? { location } : {}), data, - } as Payload - return yield* publishEvent(event) + } as Payload) }) } + function replay(event: SerializedEvent, options?: { readonly publish?: boolean; readonly ownerID?: string }) { + return Effect.gen(function* () { + const definition = syncRegistry.get(event.type) + if (!definition) { + yield* Effect.die( + new InvalidSyncEventError({ type: event.type, message: `Unknown sync event type ${event.type}` }), + ) + } else { + const payload = { + id: event.id, + type: definition.type, + version: definition.sync.version, + data: event.data, + } as Payload + yield* commitSyncEvent(payload, { seq: event.seq, aggregateID: event.aggregateID, ownerID: options?.ownerID }) + if (options?.publish) { + for (const listener of listeners) { + yield* listener(payload) + } + const pubsub = typed.get(payload.type) + if (pubsub) yield* PubSub.publish(pubsub, payload) + yield* PubSub.publish(all, payload) + } + } + }) + } + + function replayAll(events: SerializedEvent[], options?: { readonly publish?: boolean; readonly ownerID?: string }) { + return Effect.gen(function* () { + const source = events[0]?.aggregateID + if (!source) return undefined + if (events.some((event) => event.aggregateID !== source)) { + yield* Effect.die( + new InvalidSyncEventError({ + type: events[0]?.type ?? "unknown", + message: "Replay events must belong to the same aggregate", + }), + ) + } + const start = events[0]?.seq ?? 0 + for (const [index, event] of events.entries()) { + const seq = start + index + if (event.seq !== seq) { + yield* Effect.die( + new InvalidSyncEventError({ + type: event.type, + message: `Replay sequence mismatch at index ${index}: expected ${seq}, got ${event.seq}`, + }), + ) + } + } + for (const event of events) { + yield* replay(event, options) + } + return source + }) + } + + function remove(aggregateID: string) { + return db + .transaction(() => + Effect.gen(function* () { + yield* db.delete(EventSequenceTable).where(eq(EventSequenceTable.aggregate_id, aggregateID)).run() + yield* db.delete(EventTable).where(eq(EventTable.aggregate_id, aggregateID)).run() + }), + ) + .pipe(Effect.orDie) + } + + function claim(aggregateID: string, ownerID: string) { + return db + .update(EventSequenceTable) + .set({ owner_id: ownerID }) + .where(eq(EventSequenceTable.aggregate_id, aggregateID)) + .run() + .pipe(Effect.orDie) + } + const subscribe = (definition: D): Stream.Stream> => Stream.unwrap(getOrCreate(definition).pipe(Effect.map((pubsub) => Stream.fromPubSub(pubsub)))).pipe( Stream.map((event) => event as Payload), ) const streamAll = (): Stream.Stream => Stream.fromPubSub(all) + + const listen = (listener: Listener): Effect.Effect => + Effect.sync(() => { + listeners.push(listener) + return Effect.sync(() => { + const index = listeners.indexOf(listener) + if (index >= 0) listeners.splice(index, 1) + }) + }) + const sync = (handler: Sync): Effect.Effect => Effect.sync(() => { syncHandlers.push(handler) @@ -150,8 +379,15 @@ export const layer = Layer.effect( }) }) - return Service.of({ publish, publishEvent, subscribe, all: streamAll, sync }) + const project = (definition: D, projector: Projector): Effect.Effect => + Effect.sync(() => { + const list = projectors.get(definition.type) ?? [] + list.push((event) => projector(event as Payload)) + projectors.set(definition.type, list) + }) + + return Service.of({ publish, subscribe, all: streamAll, sync, listen, project, replay, replayAll, remove, claim }) }), ) -export const defaultLayer = layer +export const defaultLayer = layer.pipe(Layer.provide(Database.defaultLayer)) diff --git a/packages/opencode/src/sync/event.sql.ts b/packages/core/src/event/sql.ts similarity index 86% rename from packages/opencode/src/sync/event.sql.ts rename to packages/core/src/event/sql.ts index 547a80f0f3..6bccc0fbb9 100644 --- a/packages/opencode/src/sync/event.sql.ts +++ b/packages/core/src/event/sql.ts @@ -1,4 +1,5 @@ import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core" +import type { EventV2 } from "../event" export const EventSequenceTable = sqliteTable("event_sequence", { aggregate_id: text().notNull().primaryKey(), @@ -7,7 +8,7 @@ export const EventSequenceTable = sqliteTable("event_sequence", { }) export const EventTable = sqliteTable("event", { - id: text().primaryKey(), + id: text().$type().primaryKey(), aggregate_id: text() .notNull() .references(() => EventSequenceTable.aggregate_id, { onDelete: "cascade" }), diff --git a/packages/core/src/id/id.ts b/packages/core/src/id/id.ts new file mode 100644 index 0000000000..847a5c0329 --- /dev/null +++ b/packages/core/src/id/id.ts @@ -0,0 +1,80 @@ +import { randomBytes } from "crypto" + +const prefixes = { + job: "job", + event: "evt", + session: "ses", + message: "msg", + permission: "per", + question: "que", + part: "prt", + pty: "pty", + tool: "tool", + workspace: "wrk", +} as const + +const LENGTH = 26 + +// State for monotonic ID generation +let lastTimestamp = 0 +let counter = 0 + +export function ascending(prefix: keyof typeof prefixes, given?: string) { + return generateID(prefix, "ascending", given) +} + +export function descending(prefix: keyof typeof prefixes, given?: string) { + return generateID(prefix, "descending", given) +} + +function generateID(prefix: keyof typeof prefixes, direction: "descending" | "ascending", given?: string): string { + if (!given) { + return create(prefixes[prefix], direction) + } + + if (!given.startsWith(prefixes[prefix])) { + throw new Error(`ID ${given} does not start with ${prefixes[prefix]}`) + } + return given +} + +function randomBase62(length: number): string { + const chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" + let result = "" + const bytes = randomBytes(length) + for (let i = 0; i < length; i++) { + result += chars[bytes[i] % 62] + } + return result +} + +export function create(prefix: string, direction: "descending" | "ascending", timestamp?: number): string { + const currentTimestamp = timestamp ?? Date.now() + + if (currentTimestamp !== lastTimestamp) { + lastTimestamp = currentTimestamp + counter = 0 + } + counter++ + + let now = BigInt(currentTimestamp) * BigInt(0x1000) + BigInt(counter) + + now = direction === "descending" ? ~now : now + + const timeBytes = Buffer.alloc(6) + for (let i = 0; i < 6; i++) { + timeBytes[i] = Number((now >> BigInt(40 - 8 * i)) & BigInt(0xff)) + } + + return prefix + "_" + timeBytes.toString("hex") + randomBase62(LENGTH - 12) +} + +/** Extract timestamp from an ascending ID. Does not work with descending IDs. */ +export function timestamp(id: string): number { + const prefix = id.split("_")[0] + const hex = id.slice(prefix.length + 1, prefix.length + 13) + const encoded = BigInt("0x" + hex) + return Number(encoded / BigInt(0x1000)) +} + +export * as Identifier from "./id" diff --git a/packages/core/src/location-layer.ts b/packages/core/src/location-layer.ts index 23655faef5..a43486fa21 100644 --- a/packages/core/src/location-layer.ts +++ b/packages/core/src/location-layer.ts @@ -1,19 +1,40 @@ import { Layer, LayerMap } from "effect" import { Location } from "./location" -import { Catalog } from "./catalog" -import { PluginBoot } from "./plugin/boot" +import { Policy } from "./policy" import { Config } from "./config" +import { PluginV2 } from "./plugin" +import { Catalog } from "./catalog" import { AgentV2 } from "./agent" +import { PluginBoot } from "./plugin/boot" +import { Project } from "./project" +import { EventV2 } from "./event" +import { Auth } from "./auth" +import { Npm } from "./npm" +import { ModelsDev } from "./models-dev" +import { AppFileSystem } from "./filesystem" +import { Global } from "./global" export class LocationServiceMap extends LayerMap.Service()("@opencode/example/LocationServiceMap", { lookup: (ref: Location.Ref) => { - const result = Layer.fresh( - Layer.mergeAll(Catalog.defaultLayer, PluginBoot.defaultLayer, Config.defaultLayer, AgentV2.defaultLayer).pipe( - Layer.provideMerge(Location.defaultLayer(ref)), - ), - ) - return result + const location = Location.layer(ref) + return Layer.mergeAll( + location, + Policy.locationLayer, + Config.locationLayer, + PluginV2.locationLayer, + Catalog.locationLayer, + AgentV2.locationLayer, + PluginBoot.locationLayer, + ).pipe(Layer.provideMerge(location), Layer.fresh) }, idleTimeToLive: "60 minutes", - dependencies: [], + dependencies: [ + Project.defaultLayer, + EventV2.defaultLayer, + Auth.defaultLayer, + Npm.defaultLayer, + ModelsDev.defaultLayer, + AppFileSystem.defaultLayer, + Global.defaultLayer, + ], }) {} diff --git a/packages/core/src/location.ts b/packages/core/src/location.ts index 68c9a8f791..9613885c97 100644 --- a/packages/core/src/location.ts +++ b/packages/core/src/location.ts @@ -36,5 +36,3 @@ export const layer = (ref: Ref) => }) }), ) - -export const defaultLayer = (ref: Ref) => layer(ref).pipe(Layer.provide(Project.defaultLayer)) diff --git a/packages/core/src/permission.ts b/packages/core/src/permission.ts index ec8038f713..07c7d8e7be 100644 --- a/packages/core/src/permission.ts +++ b/packages/core/src/permission.ts @@ -2,6 +2,17 @@ export * as PermissionV2 from "./permission" import { Schema } from "effect" import { Wildcard } from "./util/wildcard" +import { Identifier } from "./id/id" +import { Newtype } from "./schema" + +export class PermissionID extends Newtype()( + "PermissionID", + Schema.String.check(Schema.isStartsWith("per")), +) { + static ascending(id?: string): PermissionID { + return this.make(Identifier.ascending("permission", id)) + } +} export const Action = Schema.Literals(["allow", "deny", "ask"]).annotate({ identifier: "PermissionV2.Action" }) export type Action = typeof Action.Type diff --git a/packages/core/src/plugin.ts b/packages/core/src/plugin.ts index df8a40c1fa..1d854f18f5 100644 --- a/packages/core/src/plugin.ts +++ b/packages/core/src/plugin.ts @@ -26,9 +26,9 @@ type HookSpec = { } "account.switched": { input: { - serviceID: import("./account").AccountV2.ServiceID - from?: import("./account").AccountV2.ID - to?: import("./account").AccountV2.ID + serviceID: import("./auth").Auth.ServiceID + from?: import("./auth").Auth.ID + to?: import("./auth").Auth.ID } output: {} } @@ -169,7 +169,7 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer.pipe(Layer.provide(EventV2.defaultLayer)) +export const locationLayer = layer // opencode // sdcok diff --git a/packages/core/src/plugin/account.ts b/packages/core/src/plugin/account.ts index 4e77701f31..56b544480d 100644 --- a/packages/core/src/plugin/account.ts +++ b/packages/core/src/plugin/account.ts @@ -1,16 +1,18 @@ import { Effect, Scope, Stream } from "effect" -import { AccountV2 } from "../account" import { EventV2 } from "../event" import { PluginV2 } from "../plugin" +import { Auth } from "../auth" +// Depending on what account is active, enable matching providers for that +// service export const AccountPlugin = PluginV2.define({ id: PluginV2.ID.make("account"), effect: Effect.gen(function* () { - const accounts = yield* AccountV2.Service + const accounts = yield* Auth.Service const events = yield* EventV2.Service const scope = yield* Scope.Scope - yield* events.subscribe(AccountV2.Event.Switched).pipe( + yield* events.subscribe(Auth.Event.Switched).pipe( Stream.runForEach((event) => PluginV2.Service.use((plugin) => plugin.trigger("account.switched", event.data, {})).pipe(Effect.asVoid), ), @@ -20,7 +22,7 @@ export const AccountPlugin = PluginV2.define({ return { "catalog.transform": Effect.fn(function* (evt) { for (const item of evt.provider.list()) { - const account = yield* accounts.active(AccountV2.ServiceID.make(item.provider.id)).pipe(Effect.orDie) + const account = yield* accounts.active(Auth.ServiceID.make(item.provider.id)).pipe(Effect.orDie) if (!account) continue evt.provider.update(item.provider.id, (provider) => { provider.enabled = { diff --git a/packages/core/src/plugin/boot.ts b/packages/core/src/plugin/boot.ts index 4456ac1908..98004600e6 100644 --- a/packages/core/src/plugin/boot.ts +++ b/packages/core/src/plugin/boot.ts @@ -1,13 +1,14 @@ export * as PluginBoot from "./boot" import { Context, Deferred, Effect, Layer } from "effect" -import { AccountV2 } from "../account" +import { Auth } from "../auth" import { AgentV2 } from "../agent" import { Catalog } from "../catalog" import { Config } from "../config" import { ConfigAgentPlugin } from "../config/plugin/agent" import { EventV2 } from "../event" import { Location } from "../location" +import { ModelsDev } from "../models-dev" import { Npm } from "../npm" import { PluginV2 } from "../plugin" import { AccountPlugin } from "./account" @@ -21,13 +22,14 @@ type Plugin = { id: PluginV2.ID effect: PluginV2.Effect< | Catalog.Service - | AccountV2.Service + | Auth.Service | AgentV2.Service | Npm.Service | EventV2.Service | Location.Service | PluginV2.Service | Config.Service + | ModelsDev.Service > } @@ -42,10 +44,11 @@ export const layer = Layer.effect( Effect.gen(function* () { const catalog = yield* Catalog.Service const plugin = yield* PluginV2.Service - const accounts = yield* AccountV2.Service + const accounts = yield* Auth.Service const agents = yield* AgentV2.Service const config = yield* Config.Service const location = yield* Location.Service + const modelsDev = yield* ModelsDev.Service const npm = yield* Npm.Service const events = yield* EventV2.Service const done = yield* Deferred.make() @@ -55,10 +58,11 @@ export const layer = Layer.effect( id: input.id, effect: input.effect.pipe( Effect.provideService(Catalog.Service, catalog), - Effect.provideService(AccountV2.Service, accounts), + Effect.provideService(Auth.Service, accounts), Effect.provideService(AgentV2.Service, agents), Effect.provideService(Config.Service, config), Effect.provideService(Location.Service, location), + Effect.provideService(ModelsDev.Service, modelsDev), Effect.provideService(Npm.Service, npm), Effect.provideService(EventV2.Service, events), Effect.provideService(PluginV2.Service, plugin), @@ -90,12 +94,8 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer.pipe( - Layer.provide(Catalog.defaultLayer), - Layer.provide(EventV2.defaultLayer), - Layer.provide(PluginV2.defaultLayer), - Layer.provide(AccountV2.defaultLayer), - Layer.provide(AgentV2.defaultLayer), - Layer.provide(Config.defaultLayer), - Layer.provide(Npm.defaultLayer), +export const locationLayer = layer.pipe( + Layer.provideMerge(Catalog.locationLayer), + Layer.provideMerge(Config.locationLayer), + Layer.provideMerge(AgentV2.locationLayer), ) diff --git a/packages/core/src/plugin/models-dev.ts b/packages/core/src/plugin/models-dev.ts index 4733833fdb..7ee38ac6de 100644 --- a/packages/core/src/plugin/models-dev.ts +++ b/packages/core/src/plugin/models-dev.ts @@ -114,7 +114,7 @@ export const ModelsDevPlugin = PluginV2.define({ yield* refresh() yield* events.subscribe(ModelsDev.Event.Refreshed).pipe( Stream.runForEach(() => refresh()), - Effect.forkIn(scope, { startImmediately: true }), + Effect.forkScoped({ startImmediately: true }), ) - }).pipe(Effect.provide(ModelsDev.defaultLayer)), + }), }) diff --git a/packages/core/src/plugin/provider.ts b/packages/core/src/plugin/provider.ts index 1880787495..eb84a73aca 100644 --- a/packages/core/src/plugin/provider.ts +++ b/packages/core/src/plugin/provider.ts @@ -1 +1,67 @@ -export { ProviderPlugins } from "./provider/index" +import { AlibabaPlugin } from "./provider/alibaba" +import { AmazonBedrockPlugin } from "./provider/amazon-bedrock" +import { AnthropicPlugin } from "./provider/anthropic" +import { AzureCognitiveServicesPlugin, AzurePlugin } from "./provider/azure" +import { CerebrasPlugin } from "./provider/cerebras" +import { CloudflareAIGatewayPlugin } from "./provider/cloudflare-ai-gateway" +import { CloudflareWorkersAIPlugin } from "./provider/cloudflare-workers-ai" +import { CoherePlugin } from "./provider/cohere" +import { DeepInfraPlugin } from "./provider/deepinfra" +import { DynamicProviderPlugin } from "./provider/dynamic" +import { GatewayPlugin } from "./provider/gateway" +import { GithubCopilotPlugin } from "./provider/github-copilot" +import { GitLabPlugin } from "./provider/gitlab" +import { GooglePlugin } from "./provider/google" +import { GoogleVertexAnthropicPlugin, GoogleVertexPlugin } from "./provider/google-vertex" +import { GroqPlugin } from "./provider/groq" +import { KiloPlugin } from "./provider/kilo" +import { LLMGatewayPlugin } from "./provider/llmgateway" +import { MistralPlugin } from "./provider/mistral" +import { NvidiaPlugin } from "./provider/nvidia" +import { OpenAIPlugin } from "./provider/openai" +import { OpenAICompatiblePlugin } from "./provider/openai-compatible" +import { OpencodePlugin } from "./provider/opencode" +import { OpenRouterPlugin } from "./provider/openrouter" +import { PerplexityPlugin } from "./provider/perplexity" +import { SapAICorePlugin } from "./provider/sap-ai-core" +import { TogetherAIPlugin } from "./provider/togetherai" +import { VercelPlugin } from "./provider/vercel" +import { VenicePlugin } from "./provider/venice" +import { XAIPlugin } from "./provider/xai" +import { ZenmuxPlugin } from "./provider/zenmux" + +export const ProviderPlugins = [ + AlibabaPlugin, + AmazonBedrockPlugin, + AnthropicPlugin, + AzureCognitiveServicesPlugin, + AzurePlugin, + CerebrasPlugin, + CloudflareAIGatewayPlugin, + CloudflareWorkersAIPlugin, + CoherePlugin, + DeepInfraPlugin, + GatewayPlugin, + GithubCopilotPlugin, + GitLabPlugin, + GooglePlugin, + GoogleVertexAnthropicPlugin, + GoogleVertexPlugin, + GroqPlugin, + KiloPlugin, + LLMGatewayPlugin, + MistralPlugin, + NvidiaPlugin, + OpencodePlugin, + OpenAICompatiblePlugin, + OpenAIPlugin, + OpenRouterPlugin, + PerplexityPlugin, + SapAICorePlugin, + TogetherAIPlugin, + VercelPlugin, + VenicePlugin, + XAIPlugin, + ZenmuxPlugin, + DynamicProviderPlugin, +] diff --git a/packages/core/src/plugin/provider/index.ts b/packages/core/src/plugin/provider/index.ts deleted file mode 100644 index fd02d322a1..0000000000 --- a/packages/core/src/plugin/provider/index.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { AlibabaPlugin } from "./alibaba" -import { AmazonBedrockPlugin } from "./amazon-bedrock" -import { AnthropicPlugin } from "./anthropic" -import { AzureCognitiveServicesPlugin, AzurePlugin } from "./azure" -import { CerebrasPlugin } from "./cerebras" -import { CloudflareAIGatewayPlugin } from "./cloudflare-ai-gateway" -import { CloudflareWorkersAIPlugin } from "./cloudflare-workers-ai" -import { CoherePlugin } from "./cohere" -import { DeepInfraPlugin } from "./deepinfra" -import { DynamicProviderPlugin } from "./dynamic" -import { GatewayPlugin } from "./gateway" -import { GithubCopilotPlugin } from "./github-copilot" -import { GitLabPlugin } from "./gitlab" -import { GooglePlugin } from "./google" -import { GoogleVertexAnthropicPlugin, GoogleVertexPlugin } from "./google-vertex" -import { GroqPlugin } from "./groq" -import { KiloPlugin } from "./kilo" -import { LLMGatewayPlugin } from "./llmgateway" -import { MistralPlugin } from "./mistral" -import { NvidiaPlugin } from "./nvidia" -import { OpenAIPlugin } from "./openai" -import { OpenAICompatiblePlugin } from "./openai-compatible" -import { OpencodePlugin } from "./opencode" -import { OpenRouterPlugin } from "./openrouter" -import { PerplexityPlugin } from "./perplexity" -import { SapAICorePlugin } from "./sap-ai-core" -import { TogetherAIPlugin } from "./togetherai" -import { VercelPlugin } from "./vercel" -import { VenicePlugin } from "./venice" -import { XAIPlugin } from "./xai" -import { ZenmuxPlugin } from "./zenmux" - -export const ProviderPlugins = [ - AlibabaPlugin, - AmazonBedrockPlugin, - AnthropicPlugin, - AzureCognitiveServicesPlugin, - AzurePlugin, - CerebrasPlugin, - CloudflareAIGatewayPlugin, - CloudflareWorkersAIPlugin, - CoherePlugin, - DeepInfraPlugin, - GatewayPlugin, - GithubCopilotPlugin, - GitLabPlugin, - GooglePlugin, - GoogleVertexAnthropicPlugin, - GoogleVertexPlugin, - GroqPlugin, - KiloPlugin, - LLMGatewayPlugin, - MistralPlugin, - NvidiaPlugin, - OpencodePlugin, - OpenAICompatiblePlugin, - OpenAIPlugin, - OpenRouterPlugin, - PerplexityPlugin, - SapAICorePlugin, - TogetherAIPlugin, - VercelPlugin, - VenicePlugin, - XAIPlugin, - ZenmuxPlugin, - DynamicProviderPlugin, -] diff --git a/packages/core/src/policy.ts b/packages/core/src/policy.ts index 78bd74f1cd..20367f4d98 100644 --- a/packages/core/src/policy.ts +++ b/packages/core/src/policy.ts @@ -41,4 +41,4 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer +export const locationLayer = layer diff --git a/packages/core/src/project.ts b/packages/core/src/project.ts index 90638873d7..f71b828246 100644 --- a/packages/core/src/project.ts +++ b/packages/core/src/project.ts @@ -1,3 +1,4 @@ +export * as ProjectV2 from "./project" export * as Project from "./project" import { Context, Effect, Layer, Schema } from "effect" diff --git a/packages/opencode/src/project/project.sql.ts b/packages/core/src/project/sql.ts similarity index 75% rename from packages/opencode/src/project/project.sql.ts rename to packages/core/src/project/sql.ts index 2d486114a3..1588446cfb 100644 --- a/packages/opencode/src/project/project.sql.ts +++ b/packages/core/src/project/sql.ts @@ -1,9 +1,9 @@ import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core" -import { Timestamps } from "../storage/schema.sql" -import type { ProjectID } from "./schema" +import { Timestamps } from "../database/schema.sql" +import { ProjectV2 } from "../project" export const ProjectTable = sqliteTable("project", { - id: text().$type().primaryKey(), + id: text().$type().primaryKey(), worktree: text().notNull(), vcs: text(), name: text(), diff --git a/packages/core/src/provider.ts b/packages/core/src/provider.ts index 7ba2172ada..1c237d3ecf 100644 --- a/packages/core/src/provider.ts +++ b/packages/core/src/provider.ts @@ -22,6 +22,9 @@ export const ID = Schema.String.pipe( ) export type ID = typeof ID.Type +export const ModelID = Schema.String.pipe(Schema.brand("ModelID")) +export type ModelID = typeof ModelID.Type + const OpenAIResponses = Schema.Struct({ type: Schema.Literal("openai/responses"), url: Schema.String, diff --git a/packages/core/src/schema.ts b/packages/core/src/schema.ts index 523a4eace5..b5cee90a57 100644 --- a/packages/core/src/schema.ts +++ b/packages/core/src/schema.ts @@ -1,11 +1,5 @@ import { Option, Schema, SchemaGetter } from "effect" -export const AbsolutePath = Schema.String.pipe(Schema.brand("AbsolutePath")) -export type AbsolutePath = typeof AbsolutePath.Type - -export const RelativePath = Schema.String.pipe(Schema.brand("RelativePath")) -export type RelativePath = typeof RelativePath.Type - /** * Integer greater than zero. */ @@ -16,6 +10,18 @@ export const PositiveInt = Schema.Int.check(Schema.isGreaterThan(0)) */ export const NonNegativeInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)) +/** + * Relative file path (e.g., `src/components/Button.tsx`). + */ +export const RelativePath = Schema.String.pipe(Schema.brand("RelativePath")) +export type RelativePath = Schema.Schema.Type + +/** + * Absolute file path (e.g., `/home/user/projects/myapp/src/main.ts`). + */ +export const AbsolutePath = Schema.String.pipe(Schema.brand("AbsolutePath")) +export type AbsolutePath = Schema.Schema.Type + /** * Optional public JSON field that can hold explicit `undefined` on the type * side but encodes it as an omitted key, matching legacy `JSON.stringify`. diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index 756531e328..3dc2683853 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -1,13 +1,337 @@ -export * as Session from "./session" +export * as SessionV2 from "./session" +export * from "./session/schema" -import { Schema } from "effect" -import { withStatics } from "./schema" -import { Identifier } from "./util/identifier" +import { DateTime, Effect, Layer, Schema, Context } from "effect" +import { and, asc, desc, eq, gt, gte, like, lt, or, type SQL } from "drizzle-orm" +import { ProjectV2 } from "./project" +import { WorkspaceV2 } from "./workspace" +import { ModelV2 } from "./model" +import { Location } from "./location" +import { SessionMessage } from "./session/message" +import type { Prompt } from "./session/prompt" +import { EventV2 } from "./event" +import { ProviderV2 } from "./provider" +import { Database } from "./database/database" +import { SessionProjector } from "./session/projector" +import { SessionMessageTable, SessionTable } from "./session/sql" +import { SessionSchema } from "./session/schema" +import { AbsolutePath, RelativePath } from "./schema" -export const ID = Schema.String.check(Schema.isStartsWith("ses")).pipe( - Schema.brand("SessionID"), - withStatics((schema) => ({ - descending: (id?: string) => schema.make(id ?? "ses_" + Identifier.descending()), - })), +// get project -> project.locations +// +// get all sessions +// + +// - by project +// - by subpath +// - by workspace (home is special) + +export const ListCursor = Schema.Struct({ + id: SessionSchema.ID, + time: Schema.Finite, + direction: Schema.Literals(["previous", "next"]), +}) +export type ListCursor = typeof ListCursor.Type + +const ListInputBase = { + workspaceID: WorkspaceV2.ID.pipe(Schema.optional), + search: Schema.String.pipe(Schema.optional), + limit: Schema.Int.pipe(Schema.optional), + order: Schema.Literals(["asc", "desc"]).pipe(Schema.optional), + cursor: ListCursor.pipe(Schema.optional), +} + +export const ListInput = Schema.Union([ + Schema.Struct({ + ...ListInputBase, + }), + Schema.Struct({ + ...ListInputBase, + directory: AbsolutePath, + }), + Schema.Struct({ + ...ListInputBase, + project: ProjectV2.ID, + subpath: RelativePath.pipe(Schema.optional), + }), +]) +export type ListInput = typeof ListInput.Type + +type CreateInput = { + id?: SessionSchema.ID + agent?: string + model?: ModelV2.Ref + location: Location.Ref +} + +type MoveInput = { + sessionID: SessionSchema.ID + location: Location.Ref +} + +type CompactInput = { + sessionID: SessionSchema.ID + prompt?: Prompt +} + +export class NotFoundError extends Schema.TaggedErrorClass()("Session.NotFoundError", { + sessionID: SessionSchema.ID, +}) {} + +export class OperationUnavailableError extends Schema.TaggedErrorClass()( + "Session.OperationUnavailableError", + { + operation: Schema.Literals(["prompt", "compact", "wait"]), + }, +) {} + +export class MessageDecodeError extends Schema.TaggedErrorClass()("Session.MessageDecodeError", { + sessionID: SessionSchema.ID, + messageID: SessionMessage.ID, +}) {} + +export type Error = NotFoundError | MessageDecodeError | OperationUnavailableError + +export interface Interface { + readonly list: (input?: ListInput) => Effect.Effect + readonly create: (input?: CreateInput) => Effect.Effect + readonly move: (input: MoveInput) => Effect.Effect + readonly get: (sessionID: SessionSchema.ID) => Effect.Effect + readonly messages: (input: { + sessionID: SessionSchema.ID + limit?: number + order?: "asc" | "desc" + cursor?: { + id: SessionMessage.ID + time: number + direction: "previous" | "next" + } + }) => Effect.Effect + readonly context: ( + sessionID: SessionSchema.ID, + ) => Effect.Effect + readonly switchAgent: (input: { sessionID: SessionSchema.ID; agent: string }) => Effect.Effect + readonly switchModel: (input: { sessionID: SessionSchema.ID; model: ModelV2.Ref }) => Effect.Effect + readonly prompt: (input: { + id?: EventV2.ID + sessionID: SessionSchema.ID + prompt: Prompt + delivery?: SessionSchema.Delivery + resume?: boolean + }) => Effect.Effect + readonly shell: (input: { + id?: EventV2.ID + sessionID: SessionSchema.ID + command: string + delivery?: SessionSchema.Delivery + resume?: boolean + }) => Effect.Effect + readonly skill: (input: { + id?: EventV2.ID + sessionID: SessionSchema.ID + skill: string + delivery?: SessionSchema.Delivery + resume?: boolean + }) => Effect.Effect + readonly compact: (input: CompactInput) => Effect.Effect + readonly wait: (id: SessionSchema.ID) => Effect.Effect + readonly resume: (sessionID: SessionSchema.ID) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/v2/Session") {} + +function fromRow(row: typeof SessionTable.$inferSelect): SessionSchema.Info { + return new SessionSchema.Info({ + id: SessionSchema.ID.make(row.id), + projectID: ProjectV2.ID.make(row.project_id), + workspaceID: row.workspace_id ? WorkspaceV2.ID.make(row.workspace_id) : undefined, + title: row.title, + parentID: row.parent_id ? SessionSchema.ID.make(row.parent_id) : undefined, + path: row.path ?? "", + agent: row.agent ?? undefined, + model: row.model + ? { + id: ModelV2.ID.make(row.model.id), + providerID: ProviderV2.ID.make(row.model.providerID), + variant: ModelV2.VariantID.make(row.model.variant ?? "default"), + } + : undefined, + cost: row.cost, + tokens: { + input: row.tokens_input, + output: row.tokens_output, + reasoning: row.tokens_reasoning, + cache: { + read: row.tokens_cache_read, + write: row.tokens_cache_write, + }, + }, + time: { + created: DateTime.makeUnsafe(row.time_created), + updated: DateTime.makeUnsafe(row.time_updated), + archived: row.time_archived ? DateTime.makeUnsafe(row.time_archived) : undefined, + }, + }) +} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const db = (yield* Database.Service).db + const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Message) + + const decode = (row: typeof SessionMessageTable.$inferSelect) => + decodeMessage({ ...row.data, id: row.id, type: row.type }).pipe( + Effect.mapError( + () => + new MessageDecodeError({ + sessionID: SessionSchema.ID.make(row.session_id), + messageID: SessionMessage.ID.make(row.id), + }), + ), + ) + + const result = Service.of({ + create: Effect.fn("V2Session.create")(function* () { + return {} as SessionSchema.Info + }), + get: Effect.fn("V2Session.get")(function* (sessionID) { + const row = yield* db.select().from(SessionTable).where(eq(SessionTable.id, sessionID)).get().pipe(Effect.orDie) + if (!row) return yield* new NotFoundError({ sessionID }) + return fromRow(row) + }), + list: Effect.fn("V2Session.list")(function* (input = {}) { + const direction = input.cursor?.direction ?? "next" + const requestedOrder = input.order ?? "desc" + const order = direction === "previous" ? (requestedOrder === "asc" ? "desc" : "asc") : requestedOrder + const sortColumn = SessionTable.time_updated + const conditions: SQL[] = [] + if ("directory" in input) conditions.push(eq(SessionTable.directory, input.directory)) + if (input.workspaceID) conditions.push(eq(SessionTable.workspace_id, input.workspaceID)) + if ("project" in input) conditions.push(eq(SessionTable.project_id, input.project)) + if (input.search) conditions.push(like(SessionTable.title, `%${input.search}%`)) + if (input.cursor) { + conditions.push( + order === "asc" + ? or( + gt(sortColumn, input.cursor.time), + and(eq(sortColumn, input.cursor.time), gt(SessionTable.id, input.cursor.id)), + )! + : or( + lt(sortColumn, input.cursor.time), + and(eq(sortColumn, input.cursor.time), lt(SessionTable.id, input.cursor.id)), + )!, + ) + } + const query = db + .select() + .from(SessionTable) + .where(conditions.length > 0 ? and(...conditions) : undefined) + .orderBy( + order === "asc" ? asc(sortColumn) : desc(sortColumn), + order === "asc" ? asc(SessionTable.id) : desc(SessionTable.id), + ) + const rows = yield* (input.limit === undefined ? query.all() : query.limit(input.limit).all()).pipe( + Effect.orDie, + ) + return (direction === "previous" ? rows.toReversed() : rows).map((row) => fromRow(row)) + }), + messages: Effect.fn("V2Session.messages")(function* (input) { + yield* result.get(input.sessionID) + const direction = input.cursor?.direction ?? "next" + const requestedOrder = input.order ?? "desc" + const order = direction === "previous" ? (requestedOrder === "asc" ? "desc" : "asc") : requestedOrder + 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 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 = yield* (input.limit === undefined ? query.all() : query.limit(input.limit).all()).pipe( + Effect.orDie, + ) + return yield* Effect.forEach(direction === "previous" ? rows.toReversed() : rows, decode) + }), + context: Effect.fn("V2Session.context")(function* (sessionID) { + yield* result.get(sessionID) + const compaction = yield* 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() + .pipe(Effect.orDie) + const rows = yield* 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() + .pipe(Effect.orDie) + return yield* Effect.forEach(rows, decode) + }), + prompt: Effect.fn("V2Session.prompt")(function* (input) { + yield* result.get(input.sessionID) + return yield* Effect.fail(new OperationUnavailableError({ operation: "prompt" })) + }), + shell: Effect.fn("V2Session.shell")(function* () {}), + skill: Effect.fn("V2Session.skill")(function* () {}), + switchAgent: Effect.fn("V2Session.switchAgent")(function* () {}), + switchModel: Effect.fn("V2Session.switchModel")(function* () {}), + compact: Effect.fn("V2Session.compact")(function* (input) { + yield* result.get(input.sessionID) + return yield* new OperationUnavailableError({ operation: "compact" }) + }), + wait: Effect.fn("V2Session.wait")(function* (sessionID) { + yield* result.get(sessionID) + return yield* new OperationUnavailableError({ operation: "wait" }) + }), + resume: Effect.fn("V2Session.resume")(function* () {}), + move: Effect.fn("V2Session.move")(function* () {}), + }) + + return result + }), +) + +export const defaultLayer = layer.pipe( + Layer.provide(SessionProjector.defaultLayer), + Layer.provide(Database.defaultLayer), + Layer.orDie, ) -export type ID = typeof ID.Type diff --git a/packages/core/src/session-event.ts b/packages/core/src/session/event.ts similarity index 95% rename from packages/core/src/session-event.ts rename to packages/core/src/session/event.ts index a98d9cc051..c8b4aac503 100644 --- a/packages/core/src/session-event.ts +++ b/packages/core/src/session/event.ts @@ -1,11 +1,11 @@ import { Schema } from "effect" -import { EventV2 } from "./event" -import { ModelV2 } from "./model" -import { NonNegativeInt } from "./schema" -import { Session } from "./session" -import { FileAttachment, Prompt } from "./session-prompt" -import { ToolOutput } from "./tool-output" -import { V2Schema } from "./v2-schema" +import { EventV2 } from "../event" +import { ModelV2 } from "../model" +import { NonNegativeInt } from "../schema" +import { ToolOutput } from "../tool-output" +import { V2Schema } from "../v2-schema" +import { FileAttachment, Prompt } from "./prompt" +import { SessionSchema } from "./schema" export { FileAttachment } @@ -20,12 +20,14 @@ export type Source = typeof Source.Type const Base = { timestamp: V2Schema.DateTimeUtcFromMillis, - sessionID: Session.ID, + sessionID: SessionSchema.ID, } const options = { - aggregate: "sessionID", - version: 1, + sync: { + aggregate: "sessionID", + version: 1, + }, } as const export const UnknownError = Schema.Struct({ @@ -395,8 +397,7 @@ export const All = Schema.Union( mode: "oneOf", }, ).pipe(Schema.toTaggedUnion("type")) - export type Event = typeof All.Type export type Type = Event["type"] -export * as SessionEvent from "./session-event" +export * as SessionEvent from "./event" diff --git a/packages/core/src/session/legacy.ts b/packages/core/src/session/legacy.ts new file mode 100644 index 0000000000..a1896a9ded --- /dev/null +++ b/packages/core/src/session/legacy.ts @@ -0,0 +1,625 @@ +export * as SessionLegacy from "./legacy" + +import { Effect, Schema, Types } from "effect" +import { EventV2 } from "../event" +import { PermissionV2 } from "../permission" +import { ProjectV2 } from "../project" +import { ProviderV2 } from "../provider" +import { optionalOmitUndefined, withStatics } from "../schema" +import { Identifier } from "../util/identifier" +import { NonNegativeInt } from "../schema" +import { NamedError } from "../util/error" +import { SessionSchema } from "./schema" +import { WorkspaceV2 } from "../workspace" + +export const MessageID = Schema.String.check(Schema.isStartsWith("msg")).pipe( + Schema.brand("MessageID"), + withStatics((schema) => ({ ascending: (id?: string) => schema.make(id ?? "msg_" + Identifier.ascending()) })), +) +export type MessageID = typeof MessageID.Type + +export const PartID = Schema.String.check(Schema.isStartsWith("prt")).pipe( + Schema.brand("PartID"), + withStatics((schema) => ({ ascending: (id?: string) => schema.make(id ?? "prt_" + Identifier.ascending()) })), +) +export type PartID = typeof PartID.Type + +export const OutputLengthError = NamedError.create("MessageOutputLengthError", {}) + +export const AuthError = NamedError.create("ProviderAuthError", { + providerID: Schema.String, + message: Schema.String, +}) + +export const AbortedError = NamedError.create("MessageAbortedError", { message: Schema.String }) +export const StructuredOutputError = NamedError.create("StructuredOutputError", { + message: Schema.String, + retries: NonNegativeInt, +}) +export const APIError = NamedError.create("APIError", { + message: Schema.String, + statusCode: Schema.optional(NonNegativeInt), + isRetryable: Schema.Boolean, + responseHeaders: Schema.optional(Schema.Record(Schema.String, Schema.String)), + responseBody: Schema.optional(Schema.String), + metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)), +}) +export type APIError = Schema.Schema.Type +export const ContextOverflowError = NamedError.create("ContextOverflowError", { + message: Schema.String, + responseBody: Schema.optional(Schema.String), +}) + +export class OutputFormatText extends Schema.Class("OutputFormatText")({ + type: Schema.Literal("text"), +}) {} + +export class OutputFormatJsonSchema extends Schema.Class("OutputFormatJsonSchema")({ + type: Schema.Literal("json_schema"), + schema: Schema.Record(Schema.String, Schema.Any).annotate({ identifier: "JSONSchema" }), + retryCount: NonNegativeInt.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed(2))), +}) {} + +export const Format = Schema.Union([OutputFormatText, OutputFormatJsonSchema]).annotate({ + discriminator: "type", + identifier: "OutputFormat", +}) +export type OutputFormat = Schema.Schema.Type + +const partBase = { + id: PartID, + sessionID: SessionSchema.ID, + messageID: MessageID, +} + +export const SnapshotPart = Schema.Struct({ + ...partBase, + type: Schema.Literal("snapshot"), + snapshot: Schema.String, +}).annotate({ identifier: "SnapshotPart" }) +export type SnapshotPart = Types.DeepMutable> + +export const PatchPart = Schema.Struct({ + ...partBase, + type: Schema.Literal("patch"), + hash: Schema.String, + files: Schema.Array(Schema.String), +}).annotate({ identifier: "PatchPart" }) +export type PatchPart = Types.DeepMutable> + +export const TextPart = Schema.Struct({ + ...partBase, + type: Schema.Literal("text"), + text: Schema.String, + synthetic: Schema.optional(Schema.Boolean), + ignored: Schema.optional(Schema.Boolean), + time: Schema.optional( + Schema.Struct({ + start: NonNegativeInt, + end: Schema.optional(NonNegativeInt), + }), + ), + metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)), +}).annotate({ identifier: "TextPart" }) +export type TextPart = Types.DeepMutable> + +export const ReasoningPart = Schema.Struct({ + ...partBase, + type: Schema.Literal("reasoning"), + text: Schema.String, + metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)), + time: Schema.Struct({ + start: NonNegativeInt, + end: Schema.optional(NonNegativeInt), + }), +}).annotate({ identifier: "ReasoningPart" }) +export type ReasoningPart = Types.DeepMutable> + +const filePartSourceBase = { + text: Schema.Struct({ + value: Schema.String, + start: Schema.Finite, + end: Schema.Finite, + }).annotate({ identifier: "FilePartSourceText" }), +} + +export const Range = Schema.Struct({ + start: Schema.Struct({ line: NonNegativeInt, character: NonNegativeInt }), + end: Schema.Struct({ line: NonNegativeInt, character: NonNegativeInt }), +}).annotate({ identifier: "Range" }) +export type Range = typeof Range.Type + +export const FileSource = Schema.Struct({ + ...filePartSourceBase, + type: Schema.Literal("file"), + path: Schema.String, +}).annotate({ identifier: "FileSource" }) + +export const SymbolSource = Schema.Struct({ + ...filePartSourceBase, + type: Schema.Literal("symbol"), + path: Schema.String, + range: Range, + name: Schema.String, + kind: NonNegativeInt, +}).annotate({ identifier: "SymbolSource" }) + +export const ResourceSource = Schema.Struct({ + ...filePartSourceBase, + type: Schema.Literal("resource"), + clientName: Schema.String, + uri: Schema.String, +}).annotate({ identifier: "ResourceSource" }) + +export const FilePartSource = Schema.Union([FileSource, SymbolSource, ResourceSource]).annotate({ + discriminator: "type", + identifier: "FilePartSource", +}) + +export const FilePart = Schema.Struct({ + ...partBase, + type: Schema.Literal("file"), + mime: Schema.String, + filename: Schema.optional(Schema.String), + url: Schema.String, + source: Schema.optional(FilePartSource), +}).annotate({ identifier: "FilePart" }) +export type FilePart = Types.DeepMutable> + +export const AgentPart = Schema.Struct({ + ...partBase, + type: Schema.Literal("agent"), + name: Schema.String, + source: Schema.optional( + Schema.Struct({ + value: Schema.String, + start: NonNegativeInt, + end: NonNegativeInt, + }), + ), +}).annotate({ identifier: "AgentPart" }) +export type AgentPart = Types.DeepMutable> + +export const CompactionPart = Schema.Struct({ + ...partBase, + type: Schema.Literal("compaction"), + auto: Schema.Boolean, + overflow: Schema.optional(Schema.Boolean), + tail_start_id: Schema.optional(MessageID), +}).annotate({ identifier: "CompactionPart" }) +export type CompactionPart = Types.DeepMutable> + +export const SubtaskPart = Schema.Struct({ + ...partBase, + type: Schema.Literal("subtask"), + prompt: Schema.String, + description: Schema.String, + agent: Schema.String, + model: Schema.optional( + Schema.Struct({ + providerID: ProviderV2.ID, + modelID: ProviderV2.ModelID, + }), + ), + command: Schema.optional(Schema.String), +}).annotate({ identifier: "SubtaskPart" }) +export type SubtaskPart = Types.DeepMutable> + +export const RetryPart = Schema.Struct({ + ...partBase, + type: Schema.Literal("retry"), + attempt: NonNegativeInt, + error: APIError.EffectSchema, + time: Schema.Struct({ + created: NonNegativeInt, + }), +}).annotate({ identifier: "RetryPart" }) +export type RetryPart = Omit>, "error"> & { + error: APIError +} + +export const StepStartPart = Schema.Struct({ + ...partBase, + type: Schema.Literal("step-start"), + snapshot: Schema.optional(Schema.String), +}).annotate({ identifier: "StepStartPart" }) +export type StepStartPart = Types.DeepMutable> + +export const StepFinishPart = Schema.Struct({ + ...partBase, + type: Schema.Literal("step-finish"), + reason: Schema.String, + snapshot: Schema.optional(Schema.String), + cost: Schema.Finite, + tokens: Schema.Struct({ + total: Schema.optional(Schema.Finite), + input: Schema.Finite, + output: Schema.Finite, + reasoning: Schema.Finite, + cache: Schema.Struct({ + read: Schema.Finite, + write: Schema.Finite, + }), + }), +}).annotate({ identifier: "StepFinishPart" }) +export type StepFinishPart = Types.DeepMutable> + +export const ToolStatePending = Schema.Struct({ + status: Schema.Literal("pending"), + input: Schema.Record(Schema.String, Schema.Any), + raw: Schema.String, +}).annotate({ identifier: "ToolStatePending" }) +export type ToolStatePending = Types.DeepMutable> + +export const ToolStateRunning = Schema.Struct({ + status: Schema.Literal("running"), + input: Schema.Record(Schema.String, Schema.Any), + title: Schema.optional(Schema.String), + metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)), + time: Schema.Struct({ + start: NonNegativeInt, + }), +}).annotate({ identifier: "ToolStateRunning" }) +export type ToolStateRunning = Types.DeepMutable> + +export const ToolStateCompleted = Schema.Struct({ + status: Schema.Literal("completed"), + input: Schema.Record(Schema.String, Schema.Any), + output: Schema.String, + title: Schema.String, + metadata: Schema.Record(Schema.String, Schema.Any), + time: Schema.Struct({ + start: NonNegativeInt, + end: NonNegativeInt, + compacted: Schema.optional(NonNegativeInt), + }), + attachments: Schema.optional(Schema.Array(FilePart)), +}).annotate({ identifier: "ToolStateCompleted" }) +export type ToolStateCompleted = Types.DeepMutable> + +export const ToolStateError = Schema.Struct({ + status: Schema.Literal("error"), + input: Schema.Record(Schema.String, Schema.Any), + error: Schema.String, + metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)), + time: Schema.Struct({ + start: NonNegativeInt, + end: NonNegativeInt, + }), +}).annotate({ identifier: "ToolStateError" }) +export type ToolStateError = Types.DeepMutable> + +export const ToolState = Schema.Union([ + ToolStatePending, + ToolStateRunning, + ToolStateCompleted, + ToolStateError, +]).annotate({ + discriminator: "status", + identifier: "ToolState", +}) +export type ToolState = ToolStatePending | ToolStateRunning | ToolStateCompleted | ToolStateError + +export const ToolPart = Schema.Struct({ + ...partBase, + type: Schema.Literal("tool"), + callID: Schema.String, + tool: Schema.String, + state: ToolState, + metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)), +}).annotate({ identifier: "ToolPart" }) +export type ToolPart = Omit>, "state"> & { + state: ToolState +} + +const messageBase = { + id: MessageID, + sessionID: partBase.sessionID, +} + +const FileDiff = Schema.Struct({ + file: Schema.optional(Schema.String), + patch: Schema.optional(Schema.String), + additions: Schema.Finite, + deletions: Schema.Finite, + status: Schema.optional(Schema.Literals(["added", "deleted", "modified"])), +}).annotate({ identifier: "SnapshotFileDiff" }) + +export const User = Schema.Struct({ + ...messageBase, + role: Schema.Literal("user"), + time: Schema.Struct({ + created: NonNegativeInt, + }), + format: Schema.optional(Format), + summary: Schema.optional( + Schema.Struct({ + title: Schema.optional(Schema.String), + body: Schema.optional(Schema.String), + diffs: Schema.Array(FileDiff), + }), + ), + agent: Schema.String, + model: Schema.Struct({ + providerID: ProviderV2.ID, + modelID: ProviderV2.ModelID, + variant: Schema.optional(Schema.String), + }), + system: Schema.optional(Schema.String), + tools: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)), +}).annotate({ identifier: "UserMessage" }) +export type User = Types.DeepMutable> + +export const Part = Schema.Union([ + TextPart, + SubtaskPart, + ReasoningPart, + FilePart, + ToolPart, + StepStartPart, + StepFinishPart, + SnapshotPart, + PatchPart, + AgentPart, + RetryPart, + CompactionPart, +]).annotate({ discriminator: "type", identifier: "Part" }) +export type Part = + | TextPart + | SubtaskPart + | ReasoningPart + | FilePart + | ToolPart + | StepStartPart + | StepFinishPart + | SnapshotPart + | PatchPart + | AgentPart + | RetryPart + | CompactionPart + +const AssistantErrorSchema = Schema.Union([ + AuthError.EffectSchema, + NamedError.Unknown.EffectSchema, + OutputLengthError.EffectSchema, + AbortedError.EffectSchema, + StructuredOutputError.EffectSchema, + ContextOverflowError.EffectSchema, + APIError.EffectSchema, +]).annotate({ discriminator: "name" }) +type AssistantError = Schema.Schema.Type + +export const TextPartInput = Schema.Struct({ + id: Schema.optional(PartID), + type: Schema.Literal("text"), + text: Schema.String, + synthetic: Schema.optional(Schema.Boolean), + ignored: Schema.optional(Schema.Boolean), + time: Schema.optional( + Schema.Struct({ + start: NonNegativeInt, + end: Schema.optional(NonNegativeInt), + }), + ), + metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)), +}).annotate({ identifier: "TextPartInput" }) +export type TextPartInput = Types.DeepMutable> + +export const FilePartInput = Schema.Struct({ + id: Schema.optional(PartID), + type: Schema.Literal("file"), + mime: Schema.String, + filename: Schema.optional(Schema.String), + url: Schema.String, + source: Schema.optional(FilePartSource), +}).annotate({ identifier: "FilePartInput" }) +export type FilePartInput = Types.DeepMutable> + +export const AgentPartInput = Schema.Struct({ + id: Schema.optional(PartID), + type: Schema.Literal("agent"), + name: Schema.String, + source: Schema.optional( + Schema.Struct({ + value: Schema.String, + start: NonNegativeInt, + end: NonNegativeInt, + }), + ), +}).annotate({ identifier: "AgentPartInput" }) +export type AgentPartInput = Types.DeepMutable> + +export const SubtaskPartInput = Schema.Struct({ + id: Schema.optional(PartID), + type: Schema.Literal("subtask"), + prompt: Schema.String, + description: Schema.String, + agent: Schema.String, + model: Schema.optional( + Schema.Struct({ + providerID: ProviderV2.ID, + modelID: ProviderV2.ModelID, + }), + ), + command: Schema.optional(Schema.String), +}).annotate({ identifier: "SubtaskPartInput" }) +export type SubtaskPartInput = Types.DeepMutable> + +export const Assistant = Schema.Struct({ + ...messageBase, + role: Schema.Literal("assistant"), + time: Schema.Struct({ + created: NonNegativeInt, + completed: Schema.optional(NonNegativeInt), + }), + error: Schema.optional(AssistantErrorSchema), + parentID: MessageID, + modelID: ProviderV2.ModelID, + providerID: ProviderV2.ID, + mode: Schema.String, + agent: Schema.String, + path: Schema.Struct({ + cwd: Schema.String, + root: Schema.String, + }), + summary: Schema.optional(Schema.Boolean), + cost: Schema.Finite, + tokens: Schema.Struct({ + total: Schema.optional(Schema.Finite), + input: Schema.Finite, + output: Schema.Finite, + reasoning: Schema.Finite, + cache: Schema.Struct({ + read: Schema.Finite, + write: Schema.Finite, + }), + }), + structured: Schema.optional(Schema.Any), + variant: Schema.optional(Schema.String), + finish: Schema.optional(Schema.String), +}).annotate({ identifier: "AssistantMessage" }) +export type Assistant = Omit>, "error"> & { + error?: AssistantError +} + +export const Info = Schema.Union([User, Assistant]).annotate({ discriminator: "role", identifier: "Message" }) +export type Info = User | Assistant + +export const WithParts = Schema.Struct({ + info: Info, + parts: Schema.Array(Part), +}) +export type WithParts = { + info: Info + parts: Part[] +} + +const options = { + sync: { + aggregate: "sessionID", + version: 1, + }, +} as const + +const SessionSummary = Schema.Struct({ + additions: Schema.Finite, + deletions: Schema.Finite, + files: Schema.Finite, + diffs: optionalOmitUndefined(Schema.Array(FileDiff)), +}) + +const SessionTokens = Schema.Struct({ + input: Schema.Finite, + output: Schema.Finite, + reasoning: Schema.Finite, + cache: Schema.Struct({ + read: Schema.Finite, + write: Schema.Finite, + }), +}) + +const SessionShare = Schema.Struct({ + url: Schema.String, +}) + +const SessionRevert = Schema.Struct({ + messageID: MessageID, + partID: optionalOmitUndefined(PartID), + snapshot: optionalOmitUndefined(Schema.String), + diff: optionalOmitUndefined(Schema.String), +}) + +const SessionModel = Schema.Struct({ + id: ProviderV2.ModelID, + providerID: ProviderV2.ID, + variant: optionalOmitUndefined(Schema.String), +}) + +export const SessionInfo = Schema.Struct({ + id: SessionSchema.ID, + slug: Schema.String, + projectID: ProjectV2.ID, + workspaceID: optionalOmitUndefined(WorkspaceV2.ID), + directory: Schema.String, + path: optionalOmitUndefined(Schema.String), + parentID: optionalOmitUndefined(SessionSchema.ID), + summary: optionalOmitUndefined(SessionSummary), + cost: optionalOmitUndefined(Schema.Finite), + tokens: optionalOmitUndefined(SessionTokens), + share: optionalOmitUndefined(SessionShare), + title: Schema.String, + agent: optionalOmitUndefined(Schema.String), + model: optionalOmitUndefined(SessionModel), + version: Schema.String, + metadata: optionalOmitUndefined(Schema.Record(Schema.String, Schema.Any)), + time: Schema.Struct({ + created: NonNegativeInt, + updated: NonNegativeInt, + compacting: optionalOmitUndefined(NonNegativeInt), + archived: optionalOmitUndefined(Schema.Finite), + }), + permission: optionalOmitUndefined(PermissionV2.Ruleset), + revert: optionalOmitUndefined(SessionRevert), +}).annotate({ identifier: "Session" }) +export type SessionInfo = typeof SessionInfo.Type + +export const Event = { + Created: EventV2.define({ + type: "session.created", + ...options, + schema: { + sessionID: SessionSchema.ID, + info: SessionInfo, + }, + }), + Updated: EventV2.define({ + type: "session.updated", + ...options, + schema: { + sessionID: SessionSchema.ID, + info: SessionInfo, + }, + }), + Deleted: EventV2.define({ + type: "session.deleted", + ...options, + schema: { + sessionID: SessionSchema.ID, + info: SessionInfo, + }, + }), + MessageUpdated: EventV2.define({ + type: "message.updated", + ...options, + schema: { + sessionID: SessionSchema.ID, + info: Info, + }, + }), + MessageRemoved: EventV2.define({ + type: "message.removed", + ...options, + schema: { + sessionID: SessionSchema.ID, + messageID: MessageID, + }, + }), + PartUpdated: EventV2.define({ + type: "message.part.updated", + ...options, + schema: { + sessionID: SessionSchema.ID, + part: Part, + time: Schema.Finite, + }, + }), + PartRemoved: EventV2.define({ + type: "message.part.removed", + ...options, + schema: { + sessionID: SessionSchema.ID, + messageID: MessageID, + partID: PartID, + }, + }), +} diff --git a/packages/core/src/session-message-updater.ts b/packages/core/src/session/message-updater.ts similarity index 58% rename from packages/core/src/session-message-updater.ts rename to packages/core/src/session/message-updater.ts index bbdf59c555..99fc3243c7 100644 --- a/packages/core/src/session-message-updater.ts +++ b/packages/core/src/session/message-updater.ts @@ -1,23 +1,23 @@ import { produce, type WritableDraft } from "immer" -import { SessionEvent } from "./session-event" -import { SessionMessage } from "./session-message" +import { Effect } from "effect" +import { SessionEvent } from "./event" +import { SessionMessage } from "./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 interface Adapter { + readonly getCurrentAssistant: () => Effect.Effect + readonly getCurrentCompaction: () => Effect.Effect + readonly getCurrentShell: (callID: string) => Effect.Effect + readonly updateAssistant: (assistant: SessionMessage.Assistant) => Effect.Effect + readonly updateCompaction: (compaction: SessionMessage.Compaction) => Effect.Effect + readonly updateShell: (shell: SessionMessage.Shell) => Effect.Effect + readonly appendMessage: (message: SessionMessage.Message) => Effect.Effect } -export function memory(state: MemoryState): Adapter { +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") @@ -26,55 +26,65 @@ export function memory(state: MemoryState): Adapter { return { getCurrentAssistant() { - const index = activeAssistantIndex() - if (index < 0) return - const assistant = state.messages[index] - return assistant?.type === "assistant" ? assistant : undefined + return Effect.sync(() => { + 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 + return Effect.sync(() => { + 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 + return Effect.sync(() => { + 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 + return Effect.sync(() => { + 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 + return Effect.sync(() => { + 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 + return Effect.sync(() => { + 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 + return Effect.sync(() => { + state.messages.push(message) + }) }, } } -export function update(adapter: Adapter, event: SessionEvent.Event): Result { - const currentAssistant = adapter.getCurrentAssistant() +export function update(adapter: Adapter, event: SessionEvent.Event) { type DraftAssistant = WritableDraft type DraftTool = WritableDraft type DraftText = WritableDraft @@ -91,9 +101,10 @@ export function update(adapter: Adapter, event: SessionEvent.Eve const latestReasoning = (assistant: DraftAssistant | undefined, reasoningID: string) => assistant?.content.findLast((item): item is DraftReasoning => item.type === "reasoning" && item.id === reasoningID) - SessionEvent.All.match(event, { + return Effect.gen(function* () { + yield* SessionEvent.All.match(event, { "session.next.agent.switched": (event) => { - adapter.appendMessage( + return adapter.appendMessage( new SessionMessage.AgentSwitched({ id: event.id, type: "agent-switched", @@ -104,7 +115,7 @@ export function update(adapter: Adapter, event: SessionEvent.Eve ) }, "session.next.model.switched": (event) => { - adapter.appendMessage( + return adapter.appendMessage( new SessionMessage.ModelSwitched({ id: event.id, type: "model-switched", @@ -115,7 +126,7 @@ export function update(adapter: Adapter, event: SessionEvent.Eve ) }, "session.next.prompted": (event) => { - adapter.appendMessage( + return adapter.appendMessage( new SessionMessage.User({ id: event.id, type: "user", @@ -129,7 +140,7 @@ export function update(adapter: Adapter, event: SessionEvent.Eve ) }, "session.next.synthetic": (event) => { - adapter.appendMessage( + return adapter.appendMessage( new SessionMessage.Synthetic({ sessionID: event.data.sessionID, text: event.data.text, @@ -140,7 +151,7 @@ export function update(adapter: Adapter, event: SessionEvent.Eve ) }, "session.next.shell.started": (event) => { - adapter.appendMessage( + return adapter.appendMessage( new SessionMessage.Shell({ id: event.id, type: "shell", @@ -153,39 +164,46 @@ export function update(adapter: Adapter, event: SessionEvent.Eve ) }, "session.next.shell.ended": (event) => { - const currentShell = adapter.getCurrentShell(event.data.callID) + return Effect.gen(function* () { + const currentShell = yield* adapter.getCurrentShell(event.data.callID) if (currentShell) { - adapter.updateShell( + yield* 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 + return Effect.gen(function* () { + const currentAssistant = yield* adapter.getCurrentAssistant() + if (currentAssistant) { + yield* adapter.updateAssistant( + produce(currentAssistant, (draft) => { + draft.time.completed = event.data.timestamp + }), + ) + } + yield* 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, }), ) - } - 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) => { + return Effect.gen(function* () { + const currentAssistant = yield* adapter.getCurrentAssistant() if (currentAssistant) { - adapter.updateAssistant( + yield* adapter.updateAssistant( produce(currentAssistant, (draft) => { draft.time.completed = event.data.timestamp draft.finish = event.data.finish @@ -195,10 +213,13 @@ export function update(adapter: Adapter, event: SessionEvent.Eve }), ) } + }) }, "session.next.step.failed": (event) => { + return Effect.gen(function* () { + const currentAssistant = yield* adapter.getCurrentAssistant() if (currentAssistant) { - adapter.updateAssistant( + yield* adapter.updateAssistant( produce(currentAssistant, (draft) => { draft.time.completed = event.data.timestamp draft.finish = "error" @@ -206,62 +227,71 @@ export function update(adapter: Adapter, event: SessionEvent.Eve }), ) } + }) }, "session.next.text.started": () => { + return Effect.gen(function* () { + const currentAssistant = yield* adapter.getCurrentAssistant() if (currentAssistant) { - adapter.updateAssistant( + yield* adapter.updateAssistant( produce(currentAssistant, (draft) => { - draft.content.push({ - type: "text", - text: "", - }) + draft.content.push(new SessionMessage.AssistantText({ type: "text", text: "" }) as DraftText) }), ) } + }) }, "session.next.text.delta": (event) => { + return Effect.gen(function* () { + const currentAssistant = yield* adapter.getCurrentAssistant() if (currentAssistant) { - adapter.updateAssistant( + yield* adapter.updateAssistant( produce(currentAssistant, (draft) => { const match = latestText(draft) if (match) match.text += event.data.delta }), ) } + }) }, "session.next.text.ended": (event) => { + return Effect.gen(function* () { + const currentAssistant = yield* adapter.getCurrentAssistant() if (currentAssistant) { - adapter.updateAssistant( + yield* adapter.updateAssistant( produce(currentAssistant, (draft) => { const match = latestText(draft) if (match) match.text = event.data.text }), ) } + }) }, "session.next.tool.input.started": (event) => { + return Effect.gen(function* () { + const currentAssistant = yield* adapter.getCurrentAssistant() if (currentAssistant) { - adapter.updateAssistant( + yield* 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: "", - }, - }) + draft.content.push( + new SessionMessage.AssistantTool({ + type: "tool", + id: event.data.callID, + name: event.data.name, + time: { created: event.data.timestamp }, + state: new SessionMessage.ToolStatePending({ status: "pending", input: "" }), + }) as DraftTool, + ) }), ) } + }) }, "session.next.tool.input.delta": (event) => { + return Effect.gen(function* () { + const currentAssistant = yield* adapter.getCurrentAssistant() if (currentAssistant) { - adapter.updateAssistant( + yield* 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) @@ -269,11 +299,14 @@ export function update(adapter: Adapter, event: SessionEvent.Eve }), ) } + }) }, - "session.next.tool.input.ended": () => {}, + "session.next.tool.input.ended": () => Effect.void, "session.next.tool.called": (event) => { + return Effect.gen(function* () { + const currentAssistant = yield* adapter.getCurrentAssistant() if (currentAssistant) { - adapter.updateAssistant( + yield* adapter.updateAssistant( produce(currentAssistant, (draft) => { const match = latestTool(draft, event.data.callID) if (match) { @@ -289,10 +322,13 @@ export function update(adapter: Adapter, event: SessionEvent.Eve }), ) } + }) }, "session.next.tool.progress": (event) => { + return Effect.gen(function* () { + const currentAssistant = yield* adapter.getCurrentAssistant() if (currentAssistant) { - adapter.updateAssistant( + yield* adapter.updateAssistant( produce(currentAssistant, (draft) => { const match = latestTool(draft, event.data.callID) if (match && match.state.status === "running") { @@ -302,10 +338,13 @@ export function update(adapter: Adapter, event: SessionEvent.Eve }), ) } + }) }, "session.next.tool.success": (event) => { + return Effect.gen(function* () { + const currentAssistant = yield* adapter.getCurrentAssistant() if (currentAssistant) { - adapter.updateAssistant( + yield* adapter.updateAssistant( produce(currentAssistant, (draft) => { const match = latestTool(draft, event.data.callID) if (match && match.state.status === "running") { @@ -321,10 +360,13 @@ export function update(adapter: Adapter, event: SessionEvent.Eve }), ) } + }) }, "session.next.tool.failed": (event) => { + return Effect.gen(function* () { + const currentAssistant = yield* adapter.getCurrentAssistant() if (currentAssistant) { - adapter.updateAssistant( + yield* adapter.updateAssistant( produce(currentAssistant, (draft) => { const match = latestTool(draft, event.data.callID) if (match && match.state.status === "running") { @@ -341,43 +383,55 @@ export function update(adapter: Adapter, event: SessionEvent.Eve }), ) } + }) }, "session.next.reasoning.started": (event) => { + return Effect.gen(function* () { + const currentAssistant = yield* adapter.getCurrentAssistant() if (currentAssistant) { - adapter.updateAssistant( + yield* adapter.updateAssistant( produce(currentAssistant, (draft) => { - draft.content.push({ - type: "reasoning", - id: event.data.reasoningID, - text: "", - }) + draft.content.push( + new SessionMessage.AssistantReasoning({ + type: "reasoning", + id: event.data.reasoningID, + text: "", + }) as DraftReasoning, + ) }), ) } + }) }, "session.next.reasoning.delta": (event) => { + return Effect.gen(function* () { + const currentAssistant = yield* adapter.getCurrentAssistant() if (currentAssistant) { - adapter.updateAssistant( + yield* adapter.updateAssistant( produce(currentAssistant, (draft) => { const match = latestReasoning(draft, event.data.reasoningID) if (match) match.text += event.data.delta }), ) } + }) }, "session.next.reasoning.ended": (event) => { + return Effect.gen(function* () { + const currentAssistant = yield* adapter.getCurrentAssistant() if (currentAssistant) { - adapter.updateAssistant( + yield* adapter.updateAssistant( produce(currentAssistant, (draft) => { const match = latestReasoning(draft, event.data.reasoningID) if (match) match.text = event.data.text }), ) } + }) }, - "session.next.retried": () => {}, + "session.next.retried": () => Effect.void, "session.next.compaction.started": (event) => { - adapter.appendMessage( + return adapter.appendMessage( new SessionMessage.Compaction({ id: event.id, type: "compaction", @@ -389,29 +443,32 @@ export function update(adapter: Adapter, event: SessionEvent.Eve ) }, "session.next.compaction.delta": (event) => { - const currentCompaction = adapter.getCurrentCompaction() + return Effect.gen(function* () { + const currentCompaction = yield* adapter.getCurrentCompaction() if (currentCompaction) { - adapter.updateCompaction( + yield* adapter.updateCompaction( produce(currentCompaction, (draft) => { draft.summary += event.data.text }), ) } + }) }, "session.next.compaction.ended": (event) => { - const currentCompaction = adapter.getCurrentCompaction() + return Effect.gen(function* () { + const currentCompaction = yield* adapter.getCurrentCompaction() if (currentCompaction) { - adapter.updateCompaction( + yield* 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" +export * as SessionMessageUpdater from "./message-updater" diff --git a/packages/core/src/session-message.ts b/packages/core/src/session/message.ts similarity index 95% rename from packages/core/src/session-message.ts rename to packages/core/src/session/message.ts index 73b6dd7da2..9de73a17bb 100644 --- a/packages/core/src/session-message.ts +++ b/packages/core/src/session/message.ts @@ -1,10 +1,12 @@ +export * as SessionMessage from "./message" + 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 "./v2-schema" -import { ModelV2 } from "./model" +import { EventV2 } from "../event" +import { ModelV2 } from "../model" +import { ToolOutput } from "../tool-output" +import { V2Schema } from "../v2-schema" +import { SessionEvent } from "./event" +import { Prompt } from "./prompt" export const ID = EventV2.ID export type ID = Schema.Schema.Type @@ -169,5 +171,3 @@ export const Message = Schema.Union([AgentSwitched, ModelSwitched, User, Synthet export type Message = Schema.Schema.Type export type Type = Message["type"] - -export * as SessionMessage from "./session-message" diff --git a/packages/core/src/session/projector.ts b/packages/core/src/session/projector.ts new file mode 100644 index 0000000000..3044aee52d --- /dev/null +++ b/packages/core/src/session/projector.ts @@ -0,0 +1,456 @@ +export * as SessionProjector from "./projector" + +import { and, eq, sql } from "drizzle-orm" +import { DateTime, Effect, Layer, Schema } from "effect" +import { Database } from "../database/database" +import { EventV2 } from "../event" +import { SessionEvent } from "./event" +import { SessionLegacy } from "./legacy" +import { WorkspaceTable } from "../control-plane/workspace.sql" +import { SessionMessage } from "./message" +import { SessionMessageUpdater } from "./message-updater" +import { MessageTable, PartTable, SessionMessageTable, SessionTable } from "./sql" +import type { DeepMutable } from "../schema" + +type DatabaseService = Database.Interface["db"] + +const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Message) +const encodeMessage = Schema.encodeSync(SessionMessage.Message) + +type Usage = { + cost: number + tokens: { + input: number + output: number + reasoning: number + cache: { read: number; write: number } + } +} + +function usage(part: (typeof SessionLegacy.Event.PartUpdated.Type)["data"]["part"] | unknown): Usage | undefined { + if (typeof part !== "object" || part === null) return undefined + const value = part as Record + if (value.type !== "step-finish") return undefined + if (!("cost" in value) || !("tokens" in value)) return undefined + return { cost: value.cost as Usage["cost"], tokens: value.tokens as Usage["tokens"] } +} + +function sessionRow(info: SessionLegacy.SessionInfo): typeof SessionTable.$inferInsert { + return { + id: info.id, + project_id: info.projectID, + workspace_id: info.workspaceID ?? null, + parent_id: info.parentID, + slug: info.slug, + 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, + summary_deletions: info.summary?.deletions, + summary_files: info.summary?.files, + summary_diffs: info.summary?.diffs ? [...info.summary.diffs] : undefined, + metadata: info.metadata, + cost: info.cost ?? 0, + tokens_input: (info.tokens ?? { input: 0 }).input, + tokens_output: (info.tokens ?? { output: 0 }).output, + tokens_reasoning: (info.tokens ?? { reasoning: 0 }).reasoning, + tokens_cache_read: (info.tokens ?? { cache: { read: 0 } }).cache.read, + tokens_cache_write: (info.tokens ?? { cache: { write: 0 } }).cache.write, + revert: info.revert ?? null, + permission: info.permission ? [...info.permission] : undefined, + time_created: info.time.created, + time_updated: info.time.updated, + time_compacting: info.time.compacting, + time_archived: info.time.archived, + } +} + +function messageData( + info: (typeof SessionLegacy.Event.MessageUpdated.Type)["data"]["info"], +): typeof MessageTable.$inferInsert.data { + const { id: _, sessionID: __, ...rest } = info + return rest as DeepMutable +} + +function partData( + part: (typeof SessionLegacy.Event.PartUpdated.Type)["data"]["part"], +): typeof PartTable.$inferInsert.data { + const { id: _, messageID: __, sessionID: ___, ...rest } = part + return rest as DeepMutable +} + +function applyUsage( + db: DatabaseService, + sessionID: (typeof SessionLegacy.Event.MessageUpdated.Type)["data"]["sessionID"], + value: Usage, + sign = 1, +) { + return db + .update(SessionTable) + .set({ + cost: sql`${SessionTable.cost} + ${value.cost * sign}`, + tokens_input: sql`${SessionTable.tokens_input} + ${value.tokens.input * sign}`, + tokens_output: sql`${SessionTable.tokens_output} + ${value.tokens.output * sign}`, + tokens_reasoning: sql`${SessionTable.tokens_reasoning} + ${value.tokens.reasoning * sign}`, + tokens_cache_read: sql`${SessionTable.tokens_cache_read} + ${value.tokens.cache.read * sign}`, + tokens_cache_write: sql`${SessionTable.tokens_cache_write} + ${value.tokens.cache.write * sign}`, + time_updated: sql`${SessionTable.time_updated}`, + }) + .where(eq(SessionTable.id, sessionID)) + .run() + .pipe(Effect.orDie) +} + +function run(db: DatabaseService, event: SessionEvent.Event) { + return Effect.gen(function* () { + const adapter: SessionMessageUpdater.Adapter = { + getCurrentAssistant() { + return Effect.gen(function* () { + const rows = yield* db + .select() + .from(SessionMessageTable) + .where( + and(eq(SessionMessageTable.session_id, event.data.sessionID), eq(SessionMessageTable.type, "assistant")), + ) + .all() + .pipe(Effect.orDie) + return rows + .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 Effect.gen(function* () { + const rows = yield* db + .select() + .from(SessionMessageTable) + .where( + and(eq(SessionMessageTable.session_id, event.data.sessionID), eq(SessionMessageTable.type, "compaction")), + ) + .all() + .pipe(Effect.orDie) + return rows + .map((row) => decodeMessage({ ...row.data, id: row.id, type: row.type })) + .find((message): message is SessionMessage.Compaction => message.type === "compaction") + }) + }, + getCurrentShell(callID) { + return Effect.gen(function* () { + const rows = yield* db + .select() + .from(SessionMessageTable) + .where(and(eq(SessionMessageTable.session_id, event.data.sessionID), eq(SessionMessageTable.type, "shell"))) + .all() + .pipe(Effect.orDie) + return rows + .map((row) => decodeMessage({ ...row.data, id: row.id, type: row.type })) + .find((message): message is SessionMessage.Shell => message.type === "shell" && message.callID === callID) + }) + }, + updateAssistant(message) { + return Effect.gen(function* () { + const encoded = encodeMessage(message) + const { id, type, ...data } = encoded + yield* db + .insert(SessionMessageTable) + .values([ + { + id: SessionMessage.ID.make(id), + session_id: event.data.sessionID, + type, + time_created: DateTime.toEpochMillis(message.time.created), + data, + }, + ]) + .onConflictDoUpdate({ + target: SessionMessageTable.id, + set: { + type, + time_created: DateTime.toEpochMillis(message.time.created), + data, + }, + }) + .run() + .pipe(Effect.orDie) + }) + }, + updateCompaction(message) { + return Effect.gen(function* () { + const encoded = encodeMessage(message) + const { id, type, ...data } = encoded + yield* db + .insert(SessionMessageTable) + .values([ + { + id: SessionMessage.ID.make(id), + session_id: event.data.sessionID, + type, + time_created: DateTime.toEpochMillis(message.time.created), + data, + }, + ]) + .onConflictDoUpdate({ + target: SessionMessageTable.id, + set: { + type, + time_created: DateTime.toEpochMillis(message.time.created), + data, + }, + }) + .run() + .pipe(Effect.orDie) + }) + }, + updateShell(message) { + return Effect.gen(function* () { + const encoded = encodeMessage(message) + const { id, type, ...data } = encoded + yield* db + .insert(SessionMessageTable) + .values([ + { + id: SessionMessage.ID.make(id), + session_id: event.data.sessionID, + type, + time_created: DateTime.toEpochMillis(message.time.created), + data, + }, + ]) + .onConflictDoUpdate({ + target: SessionMessageTable.id, + set: { + type, + time_created: DateTime.toEpochMillis(message.time.created), + data, + }, + }) + .run() + .pipe(Effect.orDie) + }) + }, + appendMessage(message) { + return Effect.gen(function* () { + const encoded = encodeMessage(message) + const { id, type, ...data } = encoded + yield* db + .insert(SessionMessageTable) + .values([ + { + id: SessionMessage.ID.make(id), + session_id: event.data.sessionID, + type, + time_created: DateTime.toEpochMillis(message.time.created), + data, + }, + ]) + .onConflictDoUpdate({ + target: SessionMessageTable.id, + set: { + type, + time_created: DateTime.toEpochMillis(message.time.created), + data, + }, + }) + .run() + .pipe(Effect.orDie) + }) + }, + } + yield* SessionMessageUpdater.update(adapter, event) + }) +} + +export const layer = Layer.effectDiscard( + Effect.gen(function* () { + const events = yield* EventV2.Service + const { db } = yield* Database.Service + yield* events.project(SessionLegacy.Event.Created, (event) => + Effect.gen(function* () { + yield* db.insert(SessionTable).values(sessionRow(event.data.info)).run().pipe(Effect.orDie) + if (event.data.info.workspaceID) { + yield* db + .update(WorkspaceTable) + .set({ time_used: Date.now() }) + .where(eq(WorkspaceTable.id, event.data.info.workspaceID)) + .run() + .pipe(Effect.orDie) + } + }), + ) + yield* events.project(SessionLegacy.Event.Updated, (event) => + db + .update(SessionTable) + .set(sessionRow(event.data.info)) + .where(eq(SessionTable.id, event.data.sessionID)) + .run() + .pipe(Effect.orDie), + ) + yield* events.project(SessionLegacy.Event.Deleted, (event) => + db.delete(SessionTable).where(eq(SessionTable.id, event.data.sessionID)).run().pipe(Effect.orDie), + ) + yield* events.project(SessionLegacy.Event.MessageUpdated, (event) => + Effect.gen(function* () { + const time_created = event.data.info.time.created + const id = event.data.info.id + const sessionID = event.data.info.sessionID + const data = messageData(event.data.info) + yield* db + .insert(MessageTable) + .values({ id, session_id: sessionID, time_created, data }) + .onConflictDoUpdate({ target: MessageTable.id, set: { data } }) + .run() + .pipe(Effect.orDie) + }), + ) + yield* events.project(SessionLegacy.Event.MessageRemoved, (event) => + Effect.gen(function* () { + const rows = yield* db + .select() + .from(PartTable) + .where(and(eq(PartTable.message_id, event.data.messageID), eq(PartTable.session_id, event.data.sessionID))) + .all() + .pipe(Effect.orDie) + for (const row of rows) { + const previous = usage(row.data) + if (previous) yield* applyUsage(db, event.data.sessionID, previous, -1) + } + yield* db + .delete(MessageTable) + .where(and(eq(MessageTable.id, event.data.messageID), eq(MessageTable.session_id, event.data.sessionID))) + .run() + .pipe(Effect.orDie) + }), + ) + yield* events.project(SessionLegacy.Event.PartRemoved, (event) => + Effect.gen(function* () { + const row = yield* db + .select() + .from(PartTable) + .where(and(eq(PartTable.id, event.data.partID), eq(PartTable.session_id, event.data.sessionID))) + .get() + .pipe(Effect.orDie) + const previous = row && usage(row.data) + if (previous) yield* applyUsage(db, event.data.sessionID, previous, -1) + yield* db + .delete(PartTable) + .where(and(eq(PartTable.id, event.data.partID), eq(PartTable.session_id, event.data.sessionID))) + .run() + .pipe(Effect.orDie) + }), + ) + yield* events.project(SessionLegacy.Event.PartUpdated, (event) => + Effect.gen(function* () { + const id = event.data.part.id + const messageID = event.data.part.messageID + const sessionID = event.data.part.sessionID + const data = partData(event.data.part) + const row = yield* db.select().from(PartTable).where(eq(PartTable.id, id)).get().pipe(Effect.orDie) + yield* db + .insert(PartTable) + .values({ id, message_id: messageID, session_id: sessionID, time_created: event.data.time, data }) + .onConflictDoUpdate({ target: PartTable.id, set: { data } }) + .run() + .pipe(Effect.orDie) + const previous = row && usage(row.data) + const next = usage(event.data.part) + if (previous) yield* applyUsage(db, row.session_id, previous, -1) + if (next) yield* applyUsage(db, sessionID, next) + }), + ) + // session.next.* projectors are disabled while the v2 message projection is stabilized. + // The events still publish through EventV2 and fan out through the opencode bridge. + // yield* events.project(SessionEvent.AgentSwitched, (event) => + // Effect.gen(function* () { + // const message = Schema.encodeSync(SessionMessage.AgentSwitched)( + // new SessionMessage.AgentSwitched({ + // id: event.id, + // type: "agent-switched", + // metadata: event.metadata, + // agent: event.data.agent, + // time: { created: event.data.timestamp }, + // }), + // ) + // const data = { metadata: message.metadata, agent: message.agent, time: message.time } + // yield* db + // .update(SessionTable) + // .set({ agent: event.data.agent, time_updated: DateTime.toEpochMillis(event.data.timestamp) }) + // .where(eq(SessionTable.id, event.data.sessionID)) + // .run() + // .pipe(Effect.orDie) + // yield* db + // .insert(SessionMessageTable) + // .values([ + // { + // id: SessionMessage.ID.make(event.id), + // session_id: event.data.sessionID, + // type: "agent-switched", + // time_created: DateTime.toEpochMillis(event.data.timestamp), + // data, + // }, + // ]) + // .run() + // .pipe(Effect.orDie) + // }), + // ) + // yield* events.project(SessionEvent.ModelSwitched, (event) => + // Effect.gen(function* () { + // const message = Schema.encodeSync(SessionMessage.ModelSwitched)( + // new SessionMessage.ModelSwitched({ + // id: event.id, + // type: "model-switched", + // metadata: event.metadata, + // model: event.data.model, + // time: { created: event.data.timestamp }, + // }), + // ) + // const data = { metadata: message.metadata, model: message.model, time: message.time } + // yield* db + // .update(SessionTable) + // .set({ model: event.data.model, time_updated: DateTime.toEpochMillis(event.data.timestamp) }) + // .where(eq(SessionTable.id, event.data.sessionID)) + // .run() + // .pipe(Effect.orDie) + // yield* db + // .insert(SessionMessageTable) + // .values([ + // { + // id: SessionMessage.ID.make(event.id), + // session_id: event.data.sessionID, + // type: "model-switched", + // time_created: DateTime.toEpochMillis(event.data.timestamp), + // data, + // }, + // ]) + // .run() + // .pipe(Effect.orDie) + // }), + // ) + // yield* events.project(SessionEvent.Prompted, (event) => run(db, event)) + // yield* events.project(SessionEvent.Synthetic, (event) => run(db, event)) + // yield* events.project(SessionEvent.Shell.Started, (event) => run(db, event)) + // yield* events.project(SessionEvent.Shell.Ended, (event) => run(db, event)) + // yield* events.project(SessionEvent.Step.Started, (event) => run(db, event)) + // yield* events.project(SessionEvent.Step.Ended, (event) => run(db, event)) + // yield* events.project(SessionEvent.Step.Failed, (event) => run(db, event)) + // yield* events.project(SessionEvent.Text.Started, (event) => run(db, event)) + // yield* events.project(SessionEvent.Text.Ended, (event) => run(db, event)) + // yield* events.project(SessionEvent.Tool.Input.Started, (event) => run(db, event)) + // yield* events.project(SessionEvent.Tool.Input.Ended, (event) => run(db, event)) + // yield* events.project(SessionEvent.Tool.Called, (event) => run(db, event)) + // yield* events.project(SessionEvent.Tool.Success, (event) => run(db, event)) + // yield* events.project(SessionEvent.Tool.Failed, (event) => run(db, event)) + // yield* events.project(SessionEvent.Reasoning.Started, (event) => run(db, event)) + // yield* events.project(SessionEvent.Reasoning.Ended, (event) => run(db, event)) + // yield* events.project(SessionEvent.Retried, (event) => run(db, event)) + // yield* events.project(SessionEvent.Compaction.Started, (event) => run(db, event)) + // yield* events.project(SessionEvent.Compaction.Ended, (event) => run(db, event)) + }), +) + +export const defaultLayer = layer.pipe(Layer.provide(EventV2.defaultLayer), Layer.provide(Database.defaultLayer)) diff --git a/packages/core/src/session-prompt.ts b/packages/core/src/session/prompt.ts similarity index 100% rename from packages/core/src/session-prompt.ts rename to packages/core/src/session/prompt.ts diff --git a/packages/core/src/session/schema.ts b/packages/core/src/session/schema.ts new file mode 100644 index 0000000000..8562a097e5 --- /dev/null +++ b/packages/core/src/session/schema.ts @@ -0,0 +1,59 @@ +export * as SessionSchema from "./schema" + +import { Schema } from "effect" +import { Location } from "../location" +import { ModelV2 } from "../model" +import { ProjectV2 } from "../project" +import { RelativePath, optionalOmitUndefined, withStatics } from "../schema" +import { WorkspaceV2 } from "../workspace" +import { Identifier } from "../util/identifier" +import { V2Schema } from "../v2-schema" + +export const Delivery = Schema.Literals(["immediate", "deferred"]).annotate({ + identifier: "Session.Delivery", +}) +export type Delivery = Schema.Schema.Type + +export const DefaultDelivery = "immediate" satisfies Delivery + +export const ID = Schema.String.check(Schema.isStartsWith("ses")).pipe( + Schema.brand("SessionID"), + withStatics((schema) => ({ + descending: (id?: string) => schema.make(id ?? "ses_" + Identifier.descending()), + })), +) +export type ID = typeof ID.Type + +export const LegacyInfo = Schema.Struct({ + id: ID, + location: Location.Ref, + subpath: RelativePath, // derived from location + project: ProjectV2.ID, // derived from location +}) +export type LegacyInfo = typeof LegacyInfo.Type + +export class Info extends Schema.Class("Session.Info")({ + id: ID, + parentID: optionalOmitUndefined(ID), + projectID: ProjectV2.ID, + workspaceID: optionalOmitUndefined(WorkspaceV2.ID), + path: optionalOmitUndefined(Schema.String), + agent: optionalOmitUndefined(Schema.String), + model: ModelV2.Ref.pipe(optionalOmitUndefined), + cost: Schema.Finite, + tokens: Schema.Struct({ + input: Schema.Finite, + output: Schema.Finite, + reasoning: Schema.Finite, + cache: Schema.Struct({ + read: Schema.Finite, + write: Schema.Finite, + }), + }), + time: Schema.Struct({ + created: V2Schema.DateTimeUtcFromMillis, + updated: V2Schema.DateTimeUtcFromMillis, + archived: optionalOmitUndefined(V2Schema.DateTimeUtcFromMillis), + }), + title: Schema.String, +}) {} diff --git a/packages/opencode/src/session/session.sql.ts b/packages/core/src/session/sql.ts similarity index 75% rename from packages/opencode/src/session/session.sql.ts rename to packages/core/src/session/sql.ts index 948deca4c0..cc474cd960 100644 --- a/packages/opencode/src/session/session.sql.ts +++ b/packages/core/src/session/sql.ts @@ -1,28 +1,28 @@ import { sqliteTable, text, integer, index, primaryKey, real } from "drizzle-orm/sqlite-core" -import { ProjectTable } from "../project/project.sql" -import type { MessageV2 } from "./message-v2" -import type { SessionMessage } from "@opencode-ai/core/session-message" +import { ProjectTable } from "../project/sql" +import type { SessionMessage } from "./message" import type { Snapshot } from "../snapshot" -import type { Permission } from "../permission" -import type { ProjectID } from "../project/schema" -import type { SessionID, MessageID, PartID } from "./schema" -import type { WorkspaceID } from "../control-plane/schema" -import { Timestamps } from "../storage/schema.sql" +import { PermissionV2 } from "../permission" +import { ProjectV2 } from "../project" +import type { SessionSchema } from "./schema" +import type { MessageID, PartID, Info as LegacyMessageInfo, Part as LegacyMessagePart } from "./legacy" +import { WorkspaceV2 } from "../workspace" +import { Timestamps } from "../database/schema.sql" -type PartData = Omit -type InfoData = T extends unknown ? Omit : never type SessionMessageData = Omit<(typeof SessionMessage.Message)["Encoded"], "type" | "id"> +type LegacyMessageData = Omit +type LegacyPartData = Omit export const SessionTable = sqliteTable( "session", { - id: text().$type().primaryKey(), + id: text().$type().primaryKey(), project_id: text() - .$type() + .$type() .notNull() .references(() => ProjectTable.id, { onDelete: "cascade" }), - workspace_id: text().$type(), - parent_id: text().$type(), + workspace_id: text().$type(), + parent_id: text().$type(), slug: text().notNull(), directory: text().notNull(), path: text(), @@ -41,7 +41,7 @@ export const SessionTable = sqliteTable( tokens_cache_read: integer().notNull().default(0), tokens_cache_write: integer().notNull().default(0), revert: text({ mode: "json" }).$type<{ messageID: MessageID; partID?: PartID; snapshot?: string; diff?: string }>(), - permission: text({ mode: "json" }).$type(), + permission: text({ mode: "json" }).$type(), agent: text(), model: text({ mode: "json" }).$type<{ id: string @@ -64,11 +64,11 @@ export const MessageTable = sqliteTable( { id: text().$type().primaryKey(), session_id: text() - .$type() + .$type() .notNull() .references(() => SessionTable.id, { onDelete: "cascade" }), ...Timestamps, - data: text({ mode: "json" }).notNull().$type(), + data: text({ mode: "json" }).notNull().$type(), }, (table) => [index("message_session_time_created_id_idx").on(table.session_id, table.time_created, table.id)], ) @@ -81,9 +81,9 @@ export const PartTable = sqliteTable( .$type() .notNull() .references(() => MessageTable.id, { onDelete: "cascade" }), - session_id: text().$type().notNull(), + session_id: text().$type().notNull(), ...Timestamps, - data: text({ mode: "json" }).notNull().$type(), + data: text({ mode: "json" }).notNull().$type(), }, (table) => [ index("part_message_id_id_idx").on(table.message_id, table.id), @@ -95,7 +95,7 @@ export const TodoTable = sqliteTable( "todo", { session_id: text() - .$type() + .$type() .notNull() .references(() => SessionTable.id, { onDelete: "cascade" }), content: text().notNull(), @@ -115,7 +115,7 @@ export const SessionMessageTable = sqliteTable( { id: text().$type().primaryKey(), session_id: text() - .$type() + .$type() .notNull() .references(() => SessionTable.id, { onDelete: "cascade" }), type: text().$type().notNull(), @@ -134,5 +134,5 @@ export const PermissionTable = sqliteTable("permission", { .primaryKey() .references(() => ProjectTable.id, { onDelete: "cascade" }), ...Timestamps, - data: text({ mode: "json" }).notNull().$type(), + data: text({ mode: "json" }).notNull().$type(), }) diff --git a/packages/opencode/src/share/share.sql.ts b/packages/core/src/share/sql.ts similarity index 75% rename from packages/opencode/src/share/share.sql.ts rename to packages/core/src/share/sql.ts index f337e106a5..a7a08d0c02 100644 --- a/packages/opencode/src/share/share.sql.ts +++ b/packages/core/src/share/sql.ts @@ -1,6 +1,6 @@ import { sqliteTable, text } from "drizzle-orm/sqlite-core" -import { SessionTable } from "../session/session.sql" -import { Timestamps } from "../storage/schema.sql" +import { SessionTable } from "../session/sql" +import { Timestamps } from "../database/schema.sql" export const SessionShareTable = sqliteTable("session_share", { session_id: text() diff --git a/packages/core/src/snapshot.ts b/packages/core/src/snapshot.ts new file mode 100644 index 0000000000..b39c0f7f01 --- /dev/null +++ b/packages/core/src/snapshot.ts @@ -0,0 +1,9 @@ +export namespace Snapshot { + export type FileDiff = { + file?: string + patch?: string + additions: number + deletions: number + status?: "added" | "deleted" | "modified" + } +} diff --git a/packages/core/src/workspace.ts b/packages/core/src/workspace.ts new file mode 100644 index 0000000000..30d33abbee --- /dev/null +++ b/packages/core/src/workspace.ts @@ -0,0 +1,18 @@ +export * as WorkspaceV2 from "./workspace" + +import { Schema } from "effect" +import { withStatics } from "./schema" +import { Identifier } from "./util/identifier" + +export const ID = Schema.String.check(Schema.isStartsWith("wrk")).pipe( + Schema.brand("WorkspaceV2.ID"), + withStatics((schema) => ({ + ascending: (id?: string) => { + if (!id) return schema.make("wrk_" + Identifier.ascending()) + if (!id.startsWith("wrk")) throw new Error(`ID ${id} does not start with wrk`) + return schema.make(id) + }, + create: () => schema.make("wrk_" + Identifier.ascending()), + })), +) +export type ID = typeof ID.Type diff --git a/packages/core/test/account.test.ts b/packages/core/test/account.test.ts index 1e50fd340a..4e69df25d3 100644 --- a/packages/core/test/account.test.ts +++ b/packages/core/test/account.test.ts @@ -2,7 +2,7 @@ import path from "path" import { describe, expect } from "bun:test" import { produce } from "immer" import { Effect, Fiber, Layer, Option, Stream } from "effect" -import { AccountV2 } from "@opencode-ai/core/account" +import { Auth } from "@opencode-ai/core/auth" import { Catalog } from "@opencode-ai/core/catalog" import { EventV2 } from "@opencode-ai/core/event" import { AppFileSystem } from "@opencode-ai/core/filesystem" @@ -14,7 +14,7 @@ import { ProviderV2 } from "@opencode-ai/core/provider" import { tmpdir } from "./fixture/tmpdir" import { testEffect } from "./lib/effect" -const it = testEffect(PluginV2.defaultLayer) +const it = testEffect(PluginV2.locationLayer.pipe(Layer.provide(EventV2.defaultLayer))) function context( records: { provider: ProviderV2.Info; models: Map }[], @@ -56,7 +56,7 @@ function context( } function testLayer(dir: string) { - return AccountV2.layer.pipe( + return Auth.layer.pipe( Layer.provide(AppFileSystem.defaultLayer), Layer.provideMerge(EventV2.defaultLayer), Layer.provide( @@ -74,7 +74,7 @@ function testLayer(dir: string) { ) } -describe("AccountV2", () => { +describe("Auth", () => { it.live("emits account lifecycle events", () => Effect.acquireRelease( Effect.promise(() => tmpdir()), @@ -82,23 +82,23 @@ describe("AccountV2", () => { ).pipe( Effect.flatMap((tmp) => Effect.gen(function* () { - const accounts = yield* AccountV2.Service + const accounts = yield* Auth.Service const eventSvc = yield* EventV2.Service const addedFiber = yield* eventSvc - .subscribe(AccountV2.Event.Added) + .subscribe(Auth.Event.Added) .pipe(Stream.take(2), Stream.runCollect, Effect.forkScoped) const switchedFiber = yield* eventSvc - .subscribe(AccountV2.Event.Switched) + .subscribe(Auth.Event.Switched) .pipe(Stream.take(3), Stream.runCollect, Effect.forkScoped) const removedFiber = yield* eventSvc - .subscribe(AccountV2.Event.Removed) + .subscribe(Auth.Event.Removed) .pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped) yield* Effect.yieldNow const first = yield* accounts.create({ - serviceID: AccountV2.ServiceID.make("provider"), - credential: new AccountV2.ApiKeyCredential({ type: "api", key: "raw-key" }), + serviceID: Auth.ServiceID.make("provider"), + credential: new Auth.ApiKeyCredential({ type: "api", key: "raw-key" }), }) expect(first).toBeDefined() if (!first) return @@ -113,8 +113,8 @@ describe("AccountV2", () => { if (updated?.credential.type === "api") expect(updated.credential.key).toBe("raw-key") const second = yield* accounts.create({ - serviceID: AccountV2.ServiceID.make("provider"), - credential: new AccountV2.ApiKeyCredential({ type: "api", key: "second-key" }), + serviceID: Auth.ServiceID.make("provider"), + credential: new Auth.ApiKeyCredential({ type: "api", key: "second-key" }), }) expect(second).toBeDefined() if (!second) return @@ -125,9 +125,9 @@ describe("AccountV2", () => { const removed = Array.from(yield* Fiber.join(removedFiber)) expect(added.map((event) => event.data.account.id)).toEqual([first.id, second.id]) expect(switched.map((event) => event.data)).toEqual([ - { serviceID: AccountV2.ServiceID.make("provider"), from: undefined, to: first.id }, - { serviceID: AccountV2.ServiceID.make("provider"), from: first.id, to: second.id }, - { serviceID: AccountV2.ServiceID.make("provider"), from: second.id, to: first.id }, + { serviceID: Auth.ServiceID.make("provider"), from: undefined, to: first.id }, + { serviceID: Auth.ServiceID.make("provider"), from: first.id, to: second.id }, + { serviceID: Auth.ServiceID.make("provider"), from: second.id, to: first.id }, ]) expect(removed[0]?.data.account.id).toBe(second.id) }).pipe(Effect.provide(testLayer(tmp.path))), @@ -142,25 +142,25 @@ describe("AccountV2", () => { ).pipe( Effect.flatMap((tmp) => Effect.gen(function* () { - const accounts = yield* AccountV2.Service + const accounts = yield* Auth.Service const eventSvc = yield* EventV2.Service const switchedFiber = yield* eventSvc - .subscribe(AccountV2.Event.Switched) + .subscribe(Auth.Event.Switched) .pipe(Stream.take(3), Stream.runCollect, Effect.forkScoped) yield* Effect.yieldNow const first = yield* accounts.create({ - serviceID: AccountV2.ServiceID.make("provider"), - credential: new AccountV2.ApiKeyCredential({ type: "api", key: "first-key" }), + serviceID: Auth.ServiceID.make("provider"), + credential: new Auth.ApiKeyCredential({ type: "api", key: "first-key" }), }) const second = yield* accounts.create({ - serviceID: AccountV2.ServiceID.make("provider"), - credential: new AccountV2.ApiKeyCredential({ type: "api", key: "second-key" }), + serviceID: Auth.ServiceID.make("provider"), + credential: new Auth.ApiKeyCredential({ type: "api", key: "second-key" }), }) const third = yield* accounts.create({ - serviceID: AccountV2.ServiceID.make("provider"), - credential: new AccountV2.ApiKeyCredential({ type: "api", key: "third-key" }), + serviceID: Auth.ServiceID.make("provider"), + credential: new Auth.ApiKeyCredential({ type: "api", key: "third-key" }), }) expect(first).toBeDefined() @@ -168,11 +168,11 @@ describe("AccountV2", () => { expect(third).toBeDefined() if (!first || !second || !third) return - expect((yield* accounts.active(AccountV2.ServiceID.make("provider")))?.id).toBe(third.id) + expect((yield* accounts.active(Auth.ServiceID.make("provider")))?.id).toBe(third.id) expect(Array.from(yield* Fiber.join(switchedFiber)).map((event) => event.data)).toEqual([ - { serviceID: AccountV2.ServiceID.make("provider"), from: undefined, to: first.id }, - { serviceID: AccountV2.ServiceID.make("provider"), from: first.id, to: second.id }, - { serviceID: AccountV2.ServiceID.make("provider"), from: second.id, to: third.id }, + { serviceID: Auth.ServiceID.make("provider"), from: undefined, to: first.id }, + { serviceID: Auth.ServiceID.make("provider"), from: first.id, to: second.id }, + { serviceID: Auth.ServiceID.make("provider"), from: second.id, to: third.id }, ]) }).pipe(Effect.provide(testLayer(tmp.path))), ), @@ -186,7 +186,7 @@ describe("AccountV2", () => { ).pipe( Effect.flatMap((tmp) => Effect.gen(function* () { - const accounts = yield* AccountV2.Service + const accounts = yield* Auth.Service const plugin = yield* PluginV2.Service const records = [ { @@ -215,7 +215,7 @@ describe("AccountV2", () => { yield* plugin.add({ ...AccountPlugin, effect: AccountPlugin.effect.pipe( - Effect.provideService(AccountV2.Service, accounts), + Effect.provideService(Auth.Service, accounts), Effect.provideService(Catalog.Service, catalog), Effect.provideService(EventV2.Service, eventSvc), Effect.provideService(PluginV2.Service, plugin), @@ -224,8 +224,8 @@ describe("AccountV2", () => { yield* Effect.yieldNow const first = yield* accounts.create({ - serviceID: AccountV2.ServiceID.make("provider"), - credential: new AccountV2.ApiKeyCredential({ type: "api", key: "first-key" }), + serviceID: Auth.ServiceID.make("provider"), + credential: new Auth.ApiKeyCredential({ type: "api", key: "first-key" }), }) expect(first).toBeDefined() if (!first) return @@ -233,15 +233,15 @@ describe("AccountV2", () => { expect(updates).toEqual([ { id: ProviderV2.ID.make("provider"), - enabled: { via: "account", service: AccountV2.ServiceID.make("provider") }, + enabled: { via: "account", service: Auth.ServiceID.make("provider") }, apiKey: "first-key", }, ]) updates.length = 0 const second = yield* accounts.create({ - serviceID: AccountV2.ServiceID.make("provider"), - credential: new AccountV2.ApiKeyCredential({ type: "api", key: "second-key" }), + serviceID: Auth.ServiceID.make("provider"), + credential: new Auth.ApiKeyCredential({ type: "api", key: "second-key" }), }) expect(second).toBeDefined() if (!second) return @@ -249,7 +249,7 @@ describe("AccountV2", () => { expect(updates).toEqual([ { id: ProviderV2.ID.make("provider"), - enabled: { via: "account", service: AccountV2.ServiceID.make("provider") }, + enabled: { via: "account", service: Auth.ServiceID.make("provider") }, apiKey: "second-key", }, ]) @@ -260,7 +260,7 @@ describe("AccountV2", () => { expect(updates).toEqual([ { id: ProviderV2.ID.make("provider"), - enabled: { via: "account", service: AccountV2.ServiceID.make("provider") }, + enabled: { via: "account", service: Auth.ServiceID.make("provider") }, apiKey: "first-key", }, ]) @@ -271,7 +271,7 @@ describe("AccountV2", () => { expect(updates).toEqual([ { id: ProviderV2.ID.make("provider"), - enabled: { via: "account", service: AccountV2.ServiceID.make("provider") }, + enabled: { via: "account", service: Auth.ServiceID.make("provider") }, apiKey: "second-key", }, ]) diff --git a/packages/core/test/agent.test.ts b/packages/core/test/agent.test.ts index c59449e938..2aef203351 100644 --- a/packages/core/test/agent.test.ts +++ b/packages/core/test/agent.test.ts @@ -3,7 +3,7 @@ import { Effect, Exit, Scope } from "effect" import { AgentV2 } from "@opencode-ai/core/agent" import { testEffect } from "./lib/effect" -const it = testEffect(AgentV2.defaultLayer) +const it = testEffect(AgentV2.locationLayer) describe("AgentV2", () => { it.effect("starts without agents", () => diff --git a/packages/core/test/catalog.test.ts b/packages/core/test/catalog.test.ts index 9736e2a739..d2314adc61 100644 --- a/packages/core/test/catalog.test.ts +++ b/packages/core/test/catalog.test.ts @@ -16,10 +16,8 @@ const locationLayer = Layer.succeed( Location.Service.of(location({ directory: AbsolutePath.make("test") })), ) const it = testEffect( - Catalog.layer.pipe( + Catalog.locationLayer.pipe( Layer.provideMerge(EventV2.defaultLayer), - Layer.provideMerge(PluginV2.defaultLayer), - Layer.provideMerge(Policy.defaultLayer), Layer.provideMerge(locationLayer), ), ) @@ -166,6 +164,32 @@ describe("CatalogV2", () => { }), ) + it.effect("ignores plugin additions from another location", () => + Effect.gen(function* () { + const events = yield* EventV2.Service + const plugin = yield* PluginV2.Service + let invoked = 0 + + yield* plugin.add({ + id: PluginV2.ID.make("test-transform"), + effect: Effect.succeed({ + "catalog.transform": () => Effect.sync(() => invoked++), + }), + }) + yield* Effect.yieldNow + expect(invoked).toBe(1) + + yield* events.publish( + PluginV2.Event.Added, + { id: PluginV2.ID.make("test-transform") }, + { location: { directory: AbsolutePath.make("other") } }, + ) + yield* Effect.yieldNow + + expect(invoked).toBe(1) + }), + ) + it.effect("resolves provider and model option merges", () => Effect.gen(function* () { const catalog = yield* Catalog.Service diff --git a/packages/core/test/config/agent.test.ts b/packages/core/test/config/agent.test.ts index 0d8562313b..34ca99b097 100644 --- a/packages/core/test/config/agent.test.ts +++ b/packages/core/test/config/agent.test.ts @@ -6,7 +6,7 @@ import { ConfigAgentPlugin } from "@opencode-ai/core/config/plugin/agent" import { PermissionV2 } from "@opencode-ai/core/permission" import { testEffect } from "../lib/effect" -const it = testEffect(AgentV2.defaultLayer) +const it = testEffect(AgentV2.locationLayer) const decode = Schema.decodeUnknownSync(Config.Info) describe("ConfigAgentPlugin.Plugin", () => { diff --git a/packages/core/test/config/config.test.ts b/packages/core/test/config/config.test.ts index 21f9e3e3d1..47e0206d46 100644 --- a/packages/core/test/config/config.test.ts +++ b/packages/core/test/config/config.test.ts @@ -22,10 +22,9 @@ function testLayer( projectDirectory = directory, vcs?: Project.Vcs, ) { - return Config.layer.pipe( + return Config.locationLayer.pipe( Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Global.layerWith({ config: globalDirectory })), - Layer.provideMerge(Policy.defaultLayer), Layer.provide( Layer.succeed( Location.Service, diff --git a/packages/core/test/database-migration.test.ts b/packages/core/test/database-migration.test.ts new file mode 100644 index 0000000000..e0e1b3f689 --- /dev/null +++ b/packages/core/test/database-migration.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, test } from "bun:test" +import { $ } from "bun" +import { fileURLToPath } from "url" +import { SqliteClient } from "@effect/sql-sqlite-bun" +import { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite" +import { Effect } from "effect" +import { sql } from "drizzle-orm" +import { DatabaseMigration } from "@opencode-ai/core/database/migration" +import sessionUsageMigration from "@opencode-ai/core/database/migration/20260510033149_session_usage" +import type { SqlClient as SqlClientService } from "effect/unstable/sql/SqlClient" + +const run = (effect: Effect.Effect) => + Effect.runPromise(effect.pipe(Effect.provide(SqliteClient.layer({ filename: ":memory:", disableWAL: true })), Effect.scoped)) + +const makeDb = EffectDrizzleSqlite.makeWithDefaults() + +describe("DatabaseMigration", () => { + if (process.platform === "linux") { + test("declared schema has no ungenerated migrations", async () => { + const result = await $`bun ${fileURLToPath(new URL("../script/migration.ts", import.meta.url))} --check`.quiet().nothrow() + expect(result.exitCode, result.stderr.toString()).toBe(0) + expect(result.stdout.toString()).toContain("No schema changes, nothing to migrate") + }, 30_000) + } + + test("applies tracked migrations to an empty database", async () => { + await run( + Effect.gen(function* () { + const db = yield* makeDb + yield* DatabaseMigration.apply(db) + + expect(yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session'`)).toEqual({ + name: "session", + }) + expect(yield* db.get(sql`SELECT count(*) as count FROM migration`)).toEqual({ count: 21 }) + }), + ) + }) + + test("runs session usage backfill in order with schema changes", async () => { + await run( + Effect.gen(function* () { + const db = yield* makeDb + yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY, time_updated integer NOT NULL)`) + yield* db.run(sql`CREATE TABLE message (id text PRIMARY KEY, session_id text NOT NULL, data text NOT NULL)`) + yield* db.run(sql`INSERT INTO session (id, time_updated) VALUES ('session_1', 1)`) + yield* db.run( + sql`INSERT INTO message (id, session_id, data) VALUES ('message_1', 'session_1', '{"role":"assistant","cost":1.25,"tokens":{"input":2,"output":3,"reasoning":4,"cache":{"read":5,"write":6}}}')`, + ) + + yield* DatabaseMigration.applyOnly(db, [sessionUsageMigration]) + + expect( + yield* db.get( + sql`SELECT cost, tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, tokens_cache_write FROM session WHERE id = 'session_1'`, + ), + ).toEqual({ + cost: 1.25, + tokens_input: 2, + tokens_output: 3, + tokens_reasoning: 4, + tokens_cache_read: 5, + tokens_cache_write: 6, + }) + }), + ) + }) + + test("imports existing drizzle migration state", async () => { + await run( + Effect.gen(function* () { + const db = yield* makeDb + yield* db.run(sql`CREATE TABLE __drizzle_migrations (id INTEGER PRIMARY KEY, hash text NOT NULL, created_at numeric, name text, applied_at TEXT)`) + yield* db.run(sql` + INSERT INTO __drizzle_migrations (hash, created_at, name, applied_at) + VALUES ('hash', 1, '20260127222353_familiar_lady_ursula', ${new Date().toISOString()}) + `) + + yield* DatabaseMigration.applyOnly(db, []) + + expect(yield* db.get(sql`SELECT id FROM migration`)).toEqual({ id: "20260127222353_familiar_lady_ursula" }) + }), + ) + }) + + test("skips drizzle import when migration table already has state", async () => { + await run( + Effect.gen(function* () { + const db = yield* makeDb + yield* db.run(sql`CREATE TABLE migration (id TEXT PRIMARY KEY, time_completed INTEGER NOT NULL)`) + yield* db.run(sql`INSERT INTO migration (id, time_completed) VALUES ('existing', 1)`) + yield* db.run(sql`CREATE TABLE __drizzle_migrations (id INTEGER PRIMARY KEY, hash text NOT NULL, created_at numeric, name text, applied_at TEXT)`) + yield* db.run(sql` + INSERT INTO __drizzle_migrations (hash, created_at, name, applied_at) + VALUES ('hash', 1, '20260127222353_familiar_lady_ursula', ${new Date().toISOString()}) + `) + + yield* DatabaseMigration.applyOnly(db, []) + + expect(yield* db.all(sql`SELECT id FROM migration ORDER BY id`)).toEqual([{ id: "existing" }]) + }), + ) + }) +}) diff --git a/packages/core/test/event.test.ts b/packages/core/test/event.test.ts index f5cce54de4..53d2330899 100644 --- a/packages/core/test/event.test.ts +++ b/packages/core/test/event.test.ts @@ -1,8 +1,11 @@ import { describe, expect } from "bun:test" import { Effect, Fiber, Layer, Schema, Stream } from "effect" import { EventV2 } from "@opencode-ai/core/event" +import { Database } from "@opencode-ai/core/database/database" +import { EventSequenceTable, EventTable } from "@opencode-ai/core/event/sql" import { Location } from "@opencode-ai/core/location" import { AbsolutePath } from "@opencode-ai/core/schema" +import { eq } from "drizzle-orm" import { location } from "./fixture/location" import { testEffect } from "./lib/effect" @@ -10,8 +13,9 @@ const locationLayer = Layer.succeed( Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("project"), workspaceID: "workspace" })), ) -const it = testEffect(EventV2.layer.pipe(Layer.provideMerge(locationLayer))) -const itWithoutLocation = testEffect(EventV2.layer) +const eventLayer = Layer.mergeAll(EventV2.defaultLayer, Database.defaultLayer) +const it = testEffect(eventLayer.pipe(Layer.provideMerge(locationLayer))) +const itWithoutLocation = testEffect(eventLayer) const Message = EventV2.define({ type: "test.message", @@ -20,6 +24,30 @@ const Message = EventV2.define({ }, }) +const SyncMessage = EventV2.define({ + type: "test.sync", + sync: { + version: 1, + aggregate: "id", + }, + schema: { + id: Schema.String, + text: Schema.String, + }, +}) + +const SyncSent = EventV2.define({ + type: "test.sent", + sync: { + version: 1, + aggregate: "messageID", + }, + schema: { + messageID: Schema.String, + text: Schema.String, + }, +}) + const GlobalMessage = EventV2.define({ type: "test.global", schema: { @@ -29,8 +57,12 @@ const GlobalMessage = EventV2.define({ const VersionedMessage = EventV2.define({ type: "test.versioned", - version: 2, + sync: { + version: 2, + aggregate: "id", + }, schema: { + id: Schema.String, text: Schema.String, }, }) @@ -65,7 +97,7 @@ describe("EventV2", () => { it.effect("publishes definition version", () => Effect.gen(function* () { const events = yield* EventV2.Service - const event = yield* events.publish(VersionedMessage, { text: "hello" }) + const event = yield* events.publish(VersionedMessage, { id: "one", text: "hello" }) expect(event.type).toBe("test.versioned") expect(event.version).toBe(2) @@ -78,6 +110,23 @@ describe("EventV2", () => { }), ) + it.effect("keeps the latest sync definition in the registry", () => + Effect.sync(() => { + const latest = EventV2.define({ + type: "test.out-of-order", + sync: { version: 2, aggregate: "id" }, + schema: { id: Schema.String }, + }) + EventV2.define({ + type: "test.out-of-order", + sync: { version: 1, aggregate: "id" }, + schema: { id: Schema.String }, + }) + + expect(EventV2.registry.get("test.out-of-order")).toBe(latest) + }), + ) + it.effect("publishes to typed and wildcard subscriptions", () => Effect.gen(function* () { const events = yield* EventV2.Service @@ -91,25 +140,25 @@ describe("EventV2", () => { }), ) - it.effect("runs sync handlers inline", () => + it.effect("runs projectors inline", () => Effect.gen(function* () { const events = yield* EventV2.Service const received = new Array() - const unsubscribe = yield* events.sync((event) => + yield* events.project(SyncMessage, (event) => Effect.sync(() => { received.push(event) }), ) - const event = yield* events.publish(Message, { text: "hello" }) - yield* unsubscribe - yield* events.publish(Message, { text: "after unsubscribe" }) + const event = yield* events.publish(SyncMessage, { id: "one", text: "hello" }) + yield* events.publish(SyncMessage, { id: "one", text: "after unsubscribe" }) - expect(received).toEqual([event]) + expect(received[0]).toEqual(event) + expect(received[1]?.data).toEqual({ id: "one", text: "after unsubscribe" }) }), ) - it.effect("runs sync handlers before publishing to streams", () => + it.effect("runs projectors before publishing to streams", () => Effect.gen(function* () { const events = yield* EventV2.Service const received = new Array() @@ -118,17 +167,380 @@ describe("EventV2", () => { Stream.runForEach(() => Effect.sync(() => received.push("stream"))), Effect.forkScoped, ) - yield* events.sync((event) => + yield* events.project(SyncMessage, (event) => Effect.sync(() => { received.push(event.type) }), ) yield* Effect.yieldNow - yield* events.publish(Message, { text: "hello" }) + yield* events.publish(SyncMessage, { id: "one", text: "hello" }) yield* Fiber.join(fiber) - expect(received).toEqual([Message.type, "stream"]) + expect(received).toEqual([SyncMessage.type, "stream"]) + }), + ) + + it.effect("runs listeners inline after projectors", () => + Effect.gen(function* () { + const events = yield* EventV2.Service + const received = new Array() + yield* events.project(SyncMessage, () => + Effect.sync(() => { + received.push("projector") + }), + ) + const unsubscribe = yield* events.listen(() => + Effect.sync(() => { + received.push("listener") + }), + ) + + yield* events.publish(SyncMessage, { id: "one", text: "hello" }) + yield* unsubscribe + yield* events.publish(SyncMessage, { id: "one", text: "after unsubscribe" }) + + expect(received).toEqual(["projector", "listener", "projector"]) + }), + ) + + it.effect("inserts sync event rows on publish", () => + Effect.gen(function* () { + const events = yield* EventV2.Service + const { db } = yield* Database.Service + const aggregateID = EventV2.ID.create() + + yield* events.publish(SyncMessage, { id: aggregateID, text: "first" }) + const rows = yield* db.select().from(EventTable).where(eq(EventTable.aggregate_id, aggregateID)).all().pipe(Effect.orDie) + + expect(rows).toHaveLength(1) + expect(rows[0]?.type).toBe(EventV2.versionedType(SyncMessage.type, 1)) + expect(rows[0]?.aggregate_id).toBe(aggregateID) + }), + ) + + it.effect("increments sync event seq per aggregate", () => + Effect.gen(function* () { + const events = yield* EventV2.Service + const { db } = yield* Database.Service + const aggregateID = EventV2.ID.create() + + yield* events.publish(SyncMessage, { id: aggregateID, text: "first" }) + yield* events.publish(SyncMessage, { id: aggregateID, text: "second" }) + const rows = yield* db.select().from(EventTable).where(eq(EventTable.aggregate_id, aggregateID)).all().pipe(Effect.orDie) + + expect(rows.map((row) => row.seq)).toEqual([0, 1]) + }), + ) + + it.effect("uses custom sync aggregate field", () => + Effect.gen(function* () { + const events = yield* EventV2.Service + const { db } = yield* Database.Service + const aggregateID = EventV2.ID.create() + + yield* events.publish(SyncSent, { messageID: aggregateID, text: "sent" }) + const rows = yield* db.select().from(EventTable).where(eq(EventTable.aggregate_id, aggregateID)).all().pipe(Effect.orDie) + + expect(rows).toHaveLength(1) + expect(rows[0]?.aggregate_id).toBe(aggregateID) + }), + ) + + it.effect("replays sync events through projectors", () => + Effect.gen(function* () { + const events = yield* EventV2.Service + const received = new Array() + yield* events.project(SyncMessage, (event) => + Effect.sync(() => { + received.push(event) + }), + ) + const aggregateID = EventV2.ID.create() + + yield* events.replay({ + id: EventV2.ID.create(), + type: EventV2.versionedType(SyncMessage.type, 1), + seq: 0, + aggregateID, + data: { id: aggregateID, text: "hello" }, + }) + + expect(received[0]?.type).toBe(SyncMessage.type) + expect(received[0]?.data).toEqual({ id: aggregateID, text: "hello" }) + }), + ) + + it.effect("replay inserts external event rows", () => + Effect.gen(function* () { + const events = yield* EventV2.Service + const { db } = yield* Database.Service + const aggregateID = EventV2.ID.create() + + yield* events.replay({ + id: EventV2.ID.create(), + type: EventV2.versionedType(SyncMessage.type, 1), + seq: 0, + aggregateID, + data: { id: aggregateID, text: "replayed" }, + }) + const rows = yield* db.select().from(EventTable).where(eq(EventTable.aggregate_id, aggregateID)).all().pipe(Effect.orDie) + + expect(rows).toHaveLength(1) + expect(rows[0]?.aggregate_id).toBe(aggregateID) + }), + ) + + it.effect("replay defects on sequence mismatch", () => + Effect.gen(function* () { + const events = yield* EventV2.Service + const aggregateID = EventV2.ID.create() + + yield* events.replay({ + id: EventV2.ID.create(), + type: EventV2.versionedType(SyncMessage.type, 1), + seq: 0, + aggregateID, + data: { id: aggregateID, text: "first" }, + }) + const exit = yield* events + .replay({ + id: EventV2.ID.create(), + type: EventV2.versionedType(SyncMessage.type, 1), + seq: 5, + aggregateID, + data: { id: aggregateID, text: "bad" }, + }) + .pipe(Effect.exit) + + expect(String(exit)).toContain("Sequence mismatch") + }), + ) + + it.effect("replay defects on unknown event type", () => + Effect.gen(function* () { + const events = yield* EventV2.Service + const exit = yield* events + .replay({ + id: EventV2.ID.create(), + type: "unknown.event.1", + seq: 0, + aggregateID: EventV2.ID.create(), + data: {}, + }) + .pipe(Effect.exit) + + expect(String(exit)).toContain("Unknown sync event type") + }), + ) + + it.effect("replayAll validates contiguous aggregate events", () => + Effect.gen(function* () { + const events = yield* EventV2.Service + const aggregateID = EventV2.ID.create() + const source = yield* events.replayAll([ + { + id: EventV2.ID.create(), + type: EventV2.versionedType(SyncMessage.type, 1), + seq: 0, + aggregateID, + data: { id: aggregateID, text: "one" }, + }, + { + id: EventV2.ID.create(), + type: EventV2.versionedType(SyncMessage.type, 1), + seq: 1, + aggregateID, + data: { id: aggregateID, text: "two" }, + }, + ]) + + expect(source).toBe(aggregateID) + }), + ) + + it.effect("replayAll accepts later chunks after the first batch", () => + Effect.gen(function* () { + const events = yield* EventV2.Service + const { db } = yield* Database.Service + const aggregateID = EventV2.ID.create() + + const one = yield* events.replayAll([ + { + id: EventV2.ID.create(), + type: EventV2.versionedType(SyncMessage.type, 1), + seq: 0, + aggregateID, + data: { id: aggregateID, text: "one" }, + }, + { + id: EventV2.ID.create(), + type: EventV2.versionedType(SyncMessage.type, 1), + seq: 1, + aggregateID, + data: { id: aggregateID, text: "two" }, + }, + ]) + const two = yield* events.replayAll([ + { + id: EventV2.ID.create(), + type: EventV2.versionedType(SyncMessage.type, 1), + seq: 2, + aggregateID, + data: { id: aggregateID, text: "three" }, + }, + { + id: EventV2.ID.create(), + type: EventV2.versionedType(SyncMessage.type, 1), + seq: 3, + aggregateID, + data: { id: aggregateID, text: "four" }, + }, + ]) + const rows = yield* db.select().from(EventTable).where(eq(EventTable.aggregate_id, aggregateID)).all().pipe(Effect.orDie) + + expect(one).toBe(aggregateID) + expect(two).toBe(aggregateID) + expect(rows.map((row) => row.seq)).toEqual([0, 1, 2, 3]) + }), + ) + + it.effect("claim fences replay owners", () => + Effect.gen(function* () { + const events = yield* EventV2.Service + const received = new Array() + const aggregateID = EventV2.ID.create() + yield* events.publish(SyncMessage, { id: aggregateID, text: "seed" }) + yield* events.claim(aggregateID, "owner-a") + yield* events.project(SyncMessage, (event) => + Effect.sync(() => { + received.push(event) + }), + ) + + yield* events.replay( + { + id: EventV2.ID.create(), + type: EventV2.versionedType(SyncMessage.type, 1), + seq: 1, + aggregateID, + data: { id: aggregateID, text: "ignored" }, + }, + { ownerID: "owner-b" }, + ) + + expect(received).toHaveLength(0) + }), + ) + + it.effect("replay with owner claims an unowned sequence", () => + Effect.gen(function* () { + const events = yield* EventV2.Service + const { db } = yield* Database.Service + const aggregateID = EventV2.ID.create() + + yield* events.replay( + { + id: EventV2.ID.create(), + type: EventV2.versionedType(SyncMessage.type, 1), + seq: 0, + aggregateID, + data: { id: aggregateID, text: "owned" }, + }, + { ownerID: "owner-1" }, + ) + const row = yield* db + .select({ seq: EventSequenceTable.seq, ownerID: EventSequenceTable.owner_id }) + .from(EventSequenceTable) + .where(eq(EventSequenceTable.aggregate_id, aggregateID)) + .get() + .pipe(Effect.orDie) + + expect(row).toEqual({ seq: 0, ownerID: "owner-1" }) + }), + ) + + it.effect("replay from a different owner leaves claimed sequence unchanged", () => + Effect.gen(function* () { + const events = yield* EventV2.Service + const { db } = yield* Database.Service + const aggregateID = EventV2.ID.create() + + yield* events.replay( + { + id: EventV2.ID.create(), + type: EventV2.versionedType(SyncMessage.type, 1), + seq: 0, + aggregateID, + data: { id: aggregateID, text: "first" }, + }, + { ownerID: "owner-1" }, + ) + yield* events.replay( + { + id: EventV2.ID.create(), + type: EventV2.versionedType(SyncMessage.type, 1), + seq: 1, + aggregateID, + data: { id: aggregateID, text: "ignored" }, + }, + { ownerID: "owner-2" }, + ) + const rows = yield* db.select().from(EventTable).where(eq(EventTable.aggregate_id, aggregateID)).all().pipe(Effect.orDie) + const sequence = yield* db + .select({ seq: EventSequenceTable.seq, ownerID: EventSequenceTable.owner_id }) + .from(EventSequenceTable) + .where(eq(EventSequenceTable.aggregate_id, aggregateID)) + .get() + .pipe(Effect.orDie) + + expect(rows).toHaveLength(1) + expect(sequence).toEqual({ seq: 0, ownerID: "owner-1" }) + }), + ) + + it.effect("claim updates the event sequence owner", () => + Effect.gen(function* () { + const events = yield* EventV2.Service + const { db } = yield* Database.Service + const aggregateID = EventV2.ID.create() + + yield* events.publish(SyncMessage, { id: aggregateID, text: "claimed" }) + yield* events.claim(aggregateID, "owner-1") + yield* events.claim(aggregateID, "owner-2") + const row = yield* db + .select({ seq: EventSequenceTable.seq, ownerID: EventSequenceTable.owner_id }) + .from(EventSequenceTable) + .where(eq(EventSequenceTable.aggregate_id, aggregateID)) + .get() + .pipe(Effect.orDie) + + expect(row).toEqual({ seq: 0, ownerID: "owner-2" }) + }), + ) + + it.effect("remove clears sync event sequence", () => + Effect.gen(function* () { + const events = yield* EventV2.Service + const received = new Array() + const aggregateID = EventV2.ID.create() + yield* events.publish(SyncMessage, { id: aggregateID, text: "seed" }) + yield* events.remove(aggregateID) + yield* events.project(SyncMessage, (event) => + Effect.sync(() => { + received.push(event) + }), + ) + + yield* events.replay({ + id: EventV2.ID.create(), + type: EventV2.versionedType(SyncMessage.type, 1), + seq: 0, + aggregateID, + data: { id: aggregateID, text: "replayed" }, + }) + + expect(received[0]?.data).toEqual({ id: aggregateID, text: "replayed" }) }), ) }) diff --git a/packages/core/test/plugin/provider-azure.test.ts b/packages/core/test/plugin/provider-azure.test.ts index 18670d6901..1b917c5af8 100644 --- a/packages/core/test/plugin/provider-azure.test.ts +++ b/packages/core/test/plugin/provider-azure.test.ts @@ -1,11 +1,10 @@ import { describe, expect } from "bun:test" import { Effect, Layer } from "effect" -import { AccountV2 } from "@opencode-ai/core/account" +import { Auth } from "@opencode-ai/core/auth" import { Catalog } from "@opencode-ai/core/catalog" import { EventV2 } from "@opencode-ai/core/event" import { Location } from "@opencode-ai/core/location" import { PluginV2 } from "@opencode-ai/core/plugin" -import { Policy } from "@opencode-ai/core/policy" import { AccountPlugin } from "@opencode-ai/core/plugin/account" import { AzurePlugin } from "@opencode-ai/core/plugin/provider/azure" import { ProviderV2 } from "@opencode-ai/core/provider" @@ -15,11 +14,9 @@ import { testEffect } from "../lib/effect" import { fakeSelectorSdk, it, model, npmLayer, provider, withEnv } from "./provider-helper" const itWithAccount = testEffect( - Catalog.layer.pipe( - Layer.provideMerge(PluginV2.defaultLayer), - Layer.provideMerge(AccountV2.defaultLayer), + Catalog.locationLayer.pipe( + Layer.provideMerge(Auth.defaultLayer), Layer.provideMerge(EventV2.defaultLayer), - Layer.provide(Policy.defaultLayer), Layer.provideMerge( Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("test") }))), ), @@ -79,12 +76,12 @@ describe("AzurePlugin", () => { () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - const accounts = yield* AccountV2.Service + const accounts = yield* Auth.Service const catalog = yield* Catalog.Service const events = yield* EventV2.Service yield* accounts.create({ - serviceID: AccountV2.ServiceID.make("azure"), - credential: new AccountV2.ApiKeyCredential({ + serviceID: Auth.ServiceID.make("azure"), + credential: new Auth.ApiKeyCredential({ type: "api", key: "key", metadata: { resourceName: "from-account" }, @@ -93,7 +90,7 @@ describe("AzurePlugin", () => { yield* plugin.add({ ...AccountPlugin, effect: AccountPlugin.effect.pipe( - Effect.provideService(AccountV2.Service, accounts), + Effect.provideService(Auth.Service, accounts), Effect.provideService(Catalog.Service, catalog), Effect.provideService(EventV2.Service, events), Effect.provideService(PluginV2.Service, plugin), diff --git a/packages/core/test/plugin/provider-cloudflare-workers-ai.test.ts b/packages/core/test/plugin/provider-cloudflare-workers-ai.test.ts index e6c6618599..980269b37e 100644 --- a/packages/core/test/plugin/provider-cloudflare-workers-ai.test.ts +++ b/packages/core/test/plugin/provider-cloudflare-workers-ai.test.ts @@ -1,12 +1,11 @@ import { describe, expect } from "bun:test" import { Effect, Layer } from "effect" -import { AccountV2 } from "@opencode-ai/core/account" +import { Auth } from "@opencode-ai/core/auth" import { Catalog } from "@opencode-ai/core/catalog" import { Location } from "@opencode-ai/core/location" import { EventV2 } from "@opencode-ai/core/event" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" -import { Policy } from "@opencode-ai/core/policy" import { AccountPlugin } from "@opencode-ai/core/plugin/account" import { CloudflareWorkersAIPlugin } from "@opencode-ai/core/plugin/provider/cloudflare-workers-ai" import { ProviderV2 } from "@opencode-ai/core/provider" @@ -16,11 +15,9 @@ import { testEffect } from "../lib/effect" import { fakeSelectorSdk, it, model, npmLayer, withEnv } from "./provider-helper" const itWithAccount = testEffect( - Catalog.layer.pipe( - Layer.provideMerge(PluginV2.defaultLayer), - Layer.provideMerge(AccountV2.defaultLayer), + Catalog.locationLayer.pipe( + Layer.provideMerge(Auth.defaultLayer), Layer.provideMerge(EventV2.defaultLayer), - Layer.provide(Policy.defaultLayer), Layer.provideMerge( Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("test") }))), ), @@ -131,12 +128,12 @@ describe("CloudflareWorkersAIPlugin", () => { () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - const accounts = yield* AccountV2.Service + const accounts = yield* Auth.Service const catalog = yield* Catalog.Service const events = yield* EventV2.Service yield* accounts.create({ - serviceID: AccountV2.ServiceID.make("cloudflare-workers-ai"), - credential: new AccountV2.ApiKeyCredential({ + serviceID: Auth.ServiceID.make("cloudflare-workers-ai"), + credential: new Auth.ApiKeyCredential({ type: "api", key: "account-key", metadata: { accountId: "account-acct" }, @@ -145,7 +142,7 @@ describe("CloudflareWorkersAIPlugin", () => { yield* plugin.add({ ...AccountPlugin, effect: AccountPlugin.effect.pipe( - Effect.provideService(AccountV2.Service, accounts), + Effect.provideService(Auth.Service, accounts), Effect.provideService(Catalog.Service, catalog), Effect.provideService(EventV2.Service, events), Effect.provideService(PluginV2.Service, plugin), diff --git a/packages/core/test/plugin/provider-deepinfra.test.ts b/packages/core/test/plugin/provider-deepinfra.test.ts index 9a9cb861ea..2341272365 100644 --- a/packages/core/test/plugin/provider-deepinfra.test.ts +++ b/packages/core/test/plugin/provider-deepinfra.test.ts @@ -1,12 +1,15 @@ import { describe, expect, mock } from "bun:test" import { Effect, Layer } from "effect" import { AISDK } from "@opencode-ai/core/aisdk" +import { EventV2 } from "@opencode-ai/core/event" import { PluginV2 } from "@opencode-ai/core/plugin" import { DeepInfraPlugin } from "@opencode-ai/core/plugin/provider/deepinfra" import { testEffect } from "../lib/effect" import { it, model } from "./provider-helper" -const itAISDK = testEffect(Layer.provideMerge(AISDK.layer, PluginV2.defaultLayer)) +const itAISDK = testEffect( + Layer.provideMerge(AISDK.layer, PluginV2.locationLayer.pipe(Layer.provide(EventV2.defaultLayer))), +) const deepinfraOptions: Record[] = [] const deepinfraLanguageModels: string[] = [] diff --git a/packages/core/test/plugin/provider-dynamic.test.ts b/packages/core/test/plugin/provider-dynamic.test.ts index c15568eebd..d0e7c80e7d 100644 --- a/packages/core/test/plugin/provider-dynamic.test.ts +++ b/packages/core/test/plugin/provider-dynamic.test.ts @@ -6,6 +6,7 @@ import os from "os" import path from "path" import { fileURLToPath } from "url" import { AISDK } from "@opencode-ai/core/aisdk" +import { EventV2 } from "@opencode-ai/core/event" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" import { DynamicProviderPlugin } from "@opencode-ai/core/plugin/provider/dynamic" @@ -13,7 +14,9 @@ import { testEffect } from "../lib/effect" import { fixtureProvider, it, model, npmLayer } from "./provider-helper" const fixtureProviderPath = fileURLToPath(fixtureProvider) -const itWithAISDK = testEffect(AISDK.layer.pipe(Layer.provideMerge(PluginV2.defaultLayer))) +const itWithAISDK = testEffect( + AISDK.layer.pipe(Layer.provideMerge(PluginV2.locationLayer.pipe(Layer.provide(EventV2.defaultLayer)))), +) function npmEntrypointLayer(entrypoint: Option.Option) { return Layer.succeed( diff --git a/packages/core/test/plugin/provider-gitlab.test.ts b/packages/core/test/plugin/provider-gitlab.test.ts index f9efa294f5..b2cba8e465 100644 --- a/packages/core/test/plugin/provider-gitlab.test.ts +++ b/packages/core/test/plugin/provider-gitlab.test.ts @@ -1,15 +1,15 @@ import { describe, expect, mock } from "bun:test" import { Effect, Layer } from "effect" -import { AccountV2 } from "@opencode-ai/core/account" +import { Auth } from "@opencode-ai/core/auth" import { Catalog } from "@opencode-ai/core/catalog" import { EventV2 } from "@opencode-ai/core/event" import { Location } from "@opencode-ai/core/location" import { PluginV2 } from "@opencode-ai/core/plugin" -import { Policy } from "@opencode-ai/core/policy" import { AccountPlugin } from "@opencode-ai/core/plugin/account" import { GitLabPlugin } from "@opencode-ai/core/plugin/provider/gitlab" import { ProviderV2 } from "@opencode-ai/core/provider" import { AbsolutePath } from "@opencode-ai/core/schema" +import { location } from "../fixture/location" import { testEffect } from "../lib/effect" import { it, model, npmLayer, withEnv } from "./provider-helper" @@ -29,15 +29,13 @@ void mock.module("gitlab-ai-provider", () => ({ })) const itWithAccount = testEffect( - Layer.mergeAll( - Catalog.defaultLayer, - PluginV2.defaultLayer, - AccountV2.defaultLayer, - EventV2.defaultLayer, - npmLayer, - ).pipe( - Layer.provide(Policy.defaultLayer), - Layer.provide(Location.defaultLayer({ directory: AbsolutePath.make("/") })), + Catalog.locationLayer.pipe( + Layer.provideMerge(Auth.defaultLayer), + Layer.provideMerge(EventV2.defaultLayer), + Layer.provideMerge( + Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("/") }))), + ), + Layer.provideMerge(npmLayer), ), ) @@ -167,17 +165,17 @@ describe("GitLabPlugin", () => { Effect.gen(function* () { gitlabSDKOptions.length = 0 const plugin = yield* PluginV2.Service - const accounts = yield* AccountV2.Service + const accounts = yield* Auth.Service const catalog = yield* Catalog.Service const events = yield* EventV2.Service yield* accounts.create({ - serviceID: AccountV2.ServiceID.make("gitlab"), - credential: new AccountV2.ApiKeyCredential({ type: "api", key: "account-token" }), + serviceID: Auth.ServiceID.make("gitlab"), + credential: new Auth.ApiKeyCredential({ type: "api", key: "account-token" }), }) yield* plugin.add({ ...AccountPlugin, effect: AccountPlugin.effect.pipe( - Effect.provideService(AccountV2.Service, accounts), + Effect.provideService(Auth.Service, accounts), Effect.provideService(Catalog.Service, catalog), Effect.provideService(EventV2.Service, events), Effect.provideService(PluginV2.Service, plugin), @@ -210,12 +208,12 @@ describe("GitLabPlugin", () => { Effect.gen(function* () { gitlabSDKOptions.length = 0 const plugin = yield* PluginV2.Service - const accounts = yield* AccountV2.Service + const accounts = yield* Auth.Service const catalog = yield* Catalog.Service const events = yield* EventV2.Service yield* accounts.create({ - serviceID: AccountV2.ServiceID.make("gitlab"), - credential: new AccountV2.OAuthCredential({ + serviceID: Auth.ServiceID.make("gitlab"), + credential: new Auth.OAuthCredential({ type: "oauth", refresh: "refresh-token", access: "account-oauth-token", @@ -225,7 +223,7 @@ describe("GitLabPlugin", () => { yield* plugin.add({ ...AccountPlugin, effect: AccountPlugin.effect.pipe( - Effect.provideService(AccountV2.Service, accounts), + Effect.provideService(Auth.Service, accounts), Effect.provideService(Catalog.Service, catalog), Effect.provideService(EventV2.Service, events), Effect.provideService(PluginV2.Service, plugin), diff --git a/packages/core/test/plugin/provider-google.test.ts b/packages/core/test/plugin/provider-google.test.ts index fdb7bf75ee..8844208e4b 100644 --- a/packages/core/test/plugin/provider-google.test.ts +++ b/packages/core/test/plugin/provider-google.test.ts @@ -1,13 +1,16 @@ import { describe, expect } from "bun:test" import { Effect, Layer } from "effect" import { AISDK } from "@opencode-ai/core/aisdk" +import { EventV2 } from "@opencode-ai/core/event" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" import { GooglePlugin } from "@opencode-ai/core/plugin/provider/google" import { testEffect } from "../lib/effect" import { it, model } from "./provider-helper" -const itWithAISDK = testEffect(AISDK.layer.pipe(Layer.provideMerge(PluginV2.defaultLayer))) +const itWithAISDK = testEffect( + AISDK.layer.pipe(Layer.provideMerge(PluginV2.locationLayer.pipe(Layer.provide(EventV2.defaultLayer)))), +) describe("GooglePlugin", () => { it.effect("creates a Google Generative AI SDK for @ai-sdk/google using the provider ID as SDK name", () => diff --git a/packages/core/test/plugin/provider-groq.test.ts b/packages/core/test/plugin/provider-groq.test.ts index 579d70da59..0eb3f538b8 100644 --- a/packages/core/test/plugin/provider-groq.test.ts +++ b/packages/core/test/plugin/provider-groq.test.ts @@ -2,13 +2,16 @@ import { describe, expect } from "bun:test" import { createGroq } from "@ai-sdk/groq" import { Effect, Layer } from "effect" import { AISDK } from "@opencode-ai/core/aisdk" +import { EventV2 } from "@opencode-ai/core/event" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" import { GroqPlugin } from "@opencode-ai/core/plugin/provider/groq" import { it, model } from "./provider-helper" import { testEffect } from "../lib/effect" -const aisdkIt = testEffect(AISDK.layer.pipe(Layer.provideMerge(PluginV2.defaultLayer))) +const aisdkIt = testEffect( + AISDK.layer.pipe(Layer.provideMerge(PluginV2.locationLayer.pipe(Layer.provide(EventV2.defaultLayer)))), +) describe("GroqPlugin", () => { it.effect("creates a Groq SDK for @ai-sdk/groq", () => diff --git a/packages/core/test/plugin/provider-helper.ts b/packages/core/test/plugin/provider-helper.ts index a6d25ac187..f99510ac70 100644 --- a/packages/core/test/plugin/provider-helper.ts +++ b/packages/core/test/plugin/provider-helper.ts @@ -7,7 +7,6 @@ import { EventV2 } from "@opencode-ai/core/event" import { Location } from "@opencode-ai/core/location" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" -import { Policy } from "@opencode-ai/core/policy" import { ProviderV2 } from "@opencode-ai/core/provider" import { AbsolutePath } from "@opencode-ai/core/schema" import { location } from "../fixture/location" @@ -48,10 +47,8 @@ export const catalogLayer = Layer.succeed( ) export const it = testEffect( - Catalog.layer.pipe( - Layer.provideMerge(PluginV2.defaultLayer), + Catalog.locationLayer.pipe( Layer.provideMerge(EventV2.defaultLayer), - Layer.provide(Policy.defaultLayer), Layer.provideMerge(locationLayer), Layer.provideMerge(npmLayer), ), diff --git a/packages/core/test/plugin/provider-opencode.test.ts b/packages/core/test/plugin/provider-opencode.test.ts index 4f4dd6abef..4054880711 100644 --- a/packages/core/test/plugin/provider-opencode.test.ts +++ b/packages/core/test/plugin/provider-opencode.test.ts @@ -1,11 +1,11 @@ import { describe, expect } from "bun:test" import { DateTime, Effect, Layer, Option } from "effect" import { Catalog } from "@opencode-ai/core/catalog" +import { EventV2 } from "@opencode-ai/core/event" import { Location } from "@opencode-ai/core/location" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" import { OpencodePlugin } from "@opencode-ai/core/plugin/provider/opencode" -import { Policy } from "@opencode-ai/core/policy" import { ProviderV2 } from "@opencode-ai/core/provider" import { AbsolutePath } from "@opencode-ai/core/schema" import { location } from "../fixture/location" @@ -227,7 +227,7 @@ describe("OpencodePlugin", () => { expect(Option.getOrUndefined(selected)?.id).toBe(ModelV2.ID.make("gpt-5-nano")) }).pipe( - Effect.provide(Catalog.defaultLayer.pipe(Layer.provide(Policy.defaultLayer), Layer.provide(locationLayer))), + Effect.provide(Catalog.locationLayer.pipe(Layer.provide(EventV2.defaultLayer), Layer.provide(locationLayer))), ), ) }) diff --git a/packages/core/test/plugin/provider-xai.test.ts b/packages/core/test/plugin/provider-xai.test.ts index 63af32dae7..de4953b060 100644 --- a/packages/core/test/plugin/provider-xai.test.ts +++ b/packages/core/test/plugin/provider-xai.test.ts @@ -1,5 +1,6 @@ import { describe, expect } from "bun:test" -import { Effect } from "effect" +import { Effect, Layer } from "effect" +import { EventV2 } from "@opencode-ai/core/event" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" import { XAIPlugin } from "@opencode-ai/core/plugin/provider/xai" @@ -7,7 +8,7 @@ import { ProviderV2 } from "@opencode-ai/core/provider" import { testEffect } from "../lib/effect" import { fakeSelectorSdk } from "./provider-helper" -const it = testEffect(PluginV2.defaultLayer) +const it = testEffect(PluginV2.locationLayer.pipe(Layer.provide(EventV2.defaultLayer))) const model = new ModelV2.Info({ ...ModelV2.Info.empty(ProviderV2.ID.make("xai"), ModelV2.ID.make("grok-4")), diff --git a/packages/core/test/policy.test.ts b/packages/core/test/policy.test.ts index c331b5585f..42736eb7d8 100644 --- a/packages/core/test/policy.test.ts +++ b/packages/core/test/policy.test.ts @@ -7,7 +7,7 @@ import { location } from "./fixture/location" import { testEffect } from "./lib/effect" const it = testEffect( - Policy.defaultLayer.pipe( + Policy.locationLayer.pipe( Layer.provide( Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("test") }))), ), diff --git a/packages/core/test/project.test.ts b/packages/core/test/project.test.ts index b1ae5b45c3..94ea40f672 100644 --- a/packages/core/test/project.test.ts +++ b/packages/core/test/project.test.ts @@ -3,16 +3,16 @@ import { $ } from "bun" import fs from "fs/promises" import path from "path" import { Effect } from "effect" -import { Project } from "@opencode-ai/core/project" +import { ProjectV2 } from "@opencode-ai/core/project" import { AbsolutePath } from "@opencode-ai/core/schema" import { Hash } from "@opencode-ai/core/util/hash" import { tmpdir } from "./fixture/tmpdir" import { testEffect } from "./lib/effect" -const it = testEffect(Project.defaultLayer) +const it = testEffect(ProjectV2.defaultLayer) function remoteID(remote: string) { - return Project.ID.make(Hash.fast(`git-remote:${remote}`)) + return ProjectV2.ID.make(Hash.fast(`git-remote:${remote}`)) } function abs(value: string) { @@ -44,11 +44,11 @@ describe("ProjectV2.resolve", () => { Effect.promise(() => tmpdir()), (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), ) - const project = yield* Project.Service + const project = yield* ProjectV2.Service const result = yield* project.resolve(abs(tmp.path)) - expect(result.id).toBe(Project.ID.make("global")) + expect(result.id).toBe(ProjectV2.ID.make("global")) expect(path.resolve(result.directory)).toBe(path.parse(tmp.path).root) expect(result.previous).toBeUndefined() expect(result.vcs).toBeUndefined() @@ -62,11 +62,11 @@ describe("ProjectV2.resolve", () => { (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), ) yield* Effect.promise(() => initRepo(tmp.path)) - const project = yield* Project.Service + const project = yield* ProjectV2.Service const result = yield* project.resolve(abs(tmp.path)) - expect(result.id).toBe(Project.ID.make("global")) + expect(result.id).toBe(ProjectV2.ID.make("global")) expect(result.directory).toBe(yield* real(tmp.path)) expect(result.previous).toBeUndefined() expect(result.vcs?.type).toBe("git") @@ -80,11 +80,11 @@ describe("ProjectV2.resolve", () => { (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), ) yield* Effect.promise(() => initRepo(tmp.path, { commit: true })) - const project = yield* Project.Service + const project = yield* ProjectV2.Service const result = yield* project.resolve(abs(tmp.path)) - expect(result.id).toBe(Project.ID.make(yield* Effect.promise(() => rootCommit(tmp.path)))) + expect(result.id).toBe(ProjectV2.ID.make(yield* Effect.promise(() => rootCommit(tmp.path)))) expect(result.directory).toBe(yield* real(tmp.path)) expect(result.previous).toBeUndefined() expect(result.vcs?.type).toBe("git") @@ -98,12 +98,12 @@ describe("ProjectV2.resolve", () => { (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), ) yield* Effect.promise(() => initRepo(tmp.path, { commit: true, remote: "git@github.com:Acme/App.git" })) - const project = yield* Project.Service + const project = yield* ProjectV2.Service const result = yield* project.resolve(abs(tmp.path)) expect(result.id).toBe(remoteID("github.com/Acme/App")) - expect(result.id).not.toBe(Project.ID.make(yield* Effect.promise(() => rootCommit(tmp.path)))) + expect(result.id).not.toBe(ProjectV2.ID.make(yield* Effect.promise(() => rootCommit(tmp.path)))) expect(result.directory).toBe(yield* real(tmp.path)) expect(result.vcs?.type).toBe("git") }), @@ -121,7 +121,7 @@ describe("ProjectV2.resolve", () => { ) yield* Effect.promise(() => initRepo(ssh.path, { commit: true, remote: "git@github.com:owner/repo.git" })) yield* Effect.promise(() => initRepo(https.path, { commit: true, remote: "https://github.com/owner/repo.git" })) - const project = yield* Project.Service + const project = yield* ProjectV2.Service const a = yield* project.resolve(abs(ssh.path)) const b = yield* project.resolve(abs(https.path)) @@ -138,11 +138,11 @@ describe("ProjectV2.resolve", () => { (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), ) yield* Effect.promise(() => initRepo(tmp.path, { commit: true, remote: `file://${tmp.path}` })) - const project = yield* Project.Service + const project = yield* ProjectV2.Service const result = yield* project.resolve(abs(tmp.path)) - expect(result.id).toBe(Project.ID.make(yield* Effect.promise(() => rootCommit(tmp.path)))) + expect(result.id).toBe(ProjectV2.ID.make(yield* Effect.promise(() => rootCommit(tmp.path)))) }), ) @@ -154,11 +154,11 @@ describe("ProjectV2.resolve", () => { ) yield* Effect.promise(() => initRepo(tmp.path, { commit: true, remote: "git@github.com:owner/repo.git" })) yield* Effect.promise(() => Bun.write(path.join(tmp.path, ".git", "opencode"), "old-id")) - const project = yield* Project.Service + const project = yield* ProjectV2.Service const result = yield* project.resolve(abs(tmp.path)) - expect(result.previous).toBe(Project.ID.make("old-id")) + expect(result.previous).toBe(ProjectV2.ID.make("old-id")) expect(result.id).toBe(remoteID("github.com/owner/repo")) }), ) @@ -170,7 +170,7 @@ describe("ProjectV2.resolve", () => { (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), ) yield* Effect.promise(() => initRepo(tmp.path, { commit: true, remote: "git@github.com:owner/repo.git" })) - const project = yield* Project.Service + const project = yield* ProjectV2.Service yield* project.resolve(abs(tmp.path)) @@ -186,7 +186,7 @@ describe("ProjectV2.resolve", () => { ) yield* Effect.promise(() => initRepo(tmp.path, { commit: true })) yield* Effect.promise(() => fs.mkdir(path.join(tmp.path, "a", "b"), { recursive: true })) - const project = yield* Project.Service + const project = yield* ProjectV2.Service const result = yield* project.resolve(abs(path.join(tmp.path, "a", "b"))) @@ -207,12 +207,12 @@ describe("ProjectV2.resolve", () => { yield* Effect.promise(() => initRepo(tmp.path, { commit: true, remote: "git@github.com:owner/repo.git" })) yield* Effect.promise(() => Bun.write(path.join(tmp.path, ".git", "opencode"), "old-id")) yield* Effect.promise(() => $`git worktree add ${worktree} -b test-${Date.now()}`.cwd(tmp.path).quiet()) - const project = yield* Project.Service + const project = yield* ProjectV2.Service const result = yield* project.resolve(abs(worktree)) expect(result.directory).toBe(yield* real(worktree)) - expect(result.previous).toBe(Project.ID.make("old-id")) + expect(result.previous).toBe(ProjectV2.ID.make("old-id")) expect(result.id).toBe(remoteID("github.com/owner/repo")) expect(result.vcs?.type).toBe("git") }), diff --git a/packages/effect-sqlite-node/package.json b/packages/effect-sqlite-node/package.json new file mode 100644 index 0000000000..74671bb5b4 --- /dev/null +++ b/packages/effect-sqlite-node/package.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "version": "1.15.10", + "name": "@opencode-ai/effect-sqlite-node", + "type": "module", + "license": "MIT", + "private": true, + "scripts": { + "typecheck": "tsgo --noEmit" + }, + "exports": { + ".": "./src/index.ts" + }, + "devDependencies": { + "@tsconfig/bun": "catalog:", + "@types/node": "catalog:", + "@typescript/native-preview": "catalog:" + }, + "dependencies": { + "effect": "catalog:" + } +} diff --git a/packages/effect-sqlite-node/src/index.ts b/packages/effect-sqlite-node/src/index.ts new file mode 100644 index 0000000000..8720d88cf0 --- /dev/null +++ b/packages/effect-sqlite-node/src/index.ts @@ -0,0 +1,166 @@ +export * as NodeSqliteClient from "./index" + +import { DatabaseSync, type SQLInputValue } from "node:sqlite" +import { identity } from "effect/Function" +import * as Context from "effect/Context" +import * as Effect from "effect/Effect" +import * as Fiber from "effect/Fiber" +import * as Layer from "effect/Layer" +import * as Scope from "effect/Scope" +import * as Semaphore from "effect/Semaphore" +import * as Stream from "effect/Stream" +import * as Reactivity from "effect/unstable/reactivity/Reactivity" +import * as Client from "effect/unstable/sql/SqlClient" +import type { Connection } from "effect/unstable/sql/SqlConnection" +import { classifySqliteError, SqlError } from "effect/unstable/sql/SqlError" +import * as Statement from "effect/unstable/sql/Statement" + +const ATTR_DB_SYSTEM_NAME = "db.system.name" + +export const TypeId: TypeId = "~@opencode-ai/effect-sqlite-node/NodeSqliteClient" +export type TypeId = "~@opencode-ai/effect-sqlite-node/NodeSqliteClient" + +export interface SqliteClient extends Client.SqlClient { + readonly [TypeId]: TypeId + readonly config: SqliteClientConfig + readonly loadExtension: (path: string) => Effect.Effect + readonly updateValues: never +} + +export const SqliteClient = Context.Service("@opencode-ai/effect-sqlite-node/NodeSqliteClient") + +export interface SqliteClientConfig { + readonly filename: string + readonly readonly?: boolean | undefined + readonly create?: boolean | undefined + readonly readwrite?: boolean | undefined + readonly disableWAL?: boolean | undefined + readonly timeout?: number | undefined + readonly allowExtension?: boolean | undefined + readonly spanAttributes?: Record | undefined + readonly transformResultNames?: ((str: string) => string) | undefined + readonly transformQueryNames?: ((str: string) => string) | undefined +} + +interface SqliteConnection extends Connection { + readonly loadExtension: (path: string) => Effect.Effect +} + +export const make = ( + options: SqliteClientConfig, +): Effect.Effect => + Effect.gen(function* () { + const compiler = Statement.makeCompilerSqlite(options.transformQueryNames) + const transformRows = options.transformResultNames + ? Statement.defaultTransforms(options.transformResultNames).array + : undefined + + const makeConnection = Effect.gen(function* () { + const db = new DatabaseSync(options.filename, { + readOnly: options.readonly, + timeout: options.timeout, + allowExtension: options.allowExtension, + enableForeignKeyConstraints: true, + open: true, + }) + yield* Effect.addFinalizer(() => Effect.sync(() => db.close())) + + if (options.disableWAL !== true && options.readonly !== true) { + db.exec("PRAGMA journal_mode = WAL;") + } + + const run = (sql: string, params: ReadonlyArray = []) => + Effect.withFiber>, SqlError>((fiber) => { + const statement = db.prepare(sql) + statement.setReadBigInts(Context.get(fiber.context, Client.SafeIntegers)) + try { + return Effect.succeed(statement.all(...(params as SQLInputValue[])) as Array>) + } catch (cause) { + return Effect.fail( + new SqlError({ + reason: classifySqliteError(cause, { message: "Failed to execute statement", operation: "execute" }), + }), + ) + } + }) + + const runValues = (sql: string, params: ReadonlyArray = []) => + Effect.withFiber>, SqlError>((fiber) => { + const statement = db.prepare(sql) + statement.setReadBigInts(Context.get(fiber.context, Client.SafeIntegers)) + statement.setReturnArrays(true) + try { + return Effect.succeed( + statement.all(...(params as SQLInputValue[])) as unknown as ReadonlyArray>, + ) + } catch (cause) { + return Effect.fail( + new SqlError({ + reason: classifySqliteError(cause, { message: "Failed to execute statement", operation: "execute" }), + }), + ) + } + }) + + return identity({ + execute(sql, params, transformRows) { + return transformRows ? Effect.map(run(sql, params), transformRows) : run(sql, params) + }, + executeRaw(sql, params) { + return run(sql, params) + }, + executeValues(sql, params) { + return runValues(sql, params) + }, + executeUnprepared(sql, params, transformRows) { + return this.execute(sql, params, transformRows) + }, + executeStream() { + return Stream.die("executeStream not implemented") + }, + loadExtension: (path) => + Effect.try({ + try: () => db.loadExtension(path), + catch: (cause) => + new SqlError({ + reason: classifySqliteError(cause, { message: "Failed to load extension", operation: "loadExtension" }), + }), + }), + }) + }) + + const semaphore = yield* Semaphore.make(1) + const connection = yield* makeConnection + const acquirer = semaphore.withPermits(1)(Effect.succeed(connection)) + const transactionAcquirer = Effect.uninterruptibleMask((restore) => { + const fiber = Fiber.getCurrent()! + const scope = Context.getUnsafe(fiber.context, Scope.Scope) + return Effect.as( + Effect.tap(restore(semaphore.take(1)), () => Scope.addFinalizer(scope, semaphore.release(1))), + connection, + ) + }) + + return Object.assign( + (yield* Client.make({ + acquirer, + compiler, + transactionAcquirer, + spanAttributes: [ + ...(options.spanAttributes ? Object.entries(options.spanAttributes) : []), + [ATTR_DB_SYSTEM_NAME, "sqlite"], + ], + transformRows, + })) as SqliteClient, + { + [TypeId]: TypeId as TypeId, + config: options, + loadExtension: (path: string) => Effect.flatMap(acquirer, (_) => _.loadExtension(path)), + }, + ) + }) + +export const layer = (config: SqliteClientConfig): Layer.Layer => + Layer.effectContext( + Effect.map(make(config), (client) => Context.make(SqliteClient, client).pipe(Context.add(Client.SqlClient, client))), + ).pipe(Layer.provide(Reactivity.layer)) diff --git a/packages/effect-sqlite-node/tsconfig.json b/packages/effect-sqlite-node/tsconfig.json new file mode 100644 index 0000000000..2bc480ffbb --- /dev/null +++ b/packages/effect-sqlite-node/tsconfig.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "@tsconfig/bun/tsconfig.json", + "compilerOptions": { + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "noUncheckedIndexedAccess": false, + "plugins": [ + { + "name": "@effect/language-service", + "transform": "@effect/language-service/transform", + "namespaceImportPackages": ["effect", "@effect/*"] + } + ] + } +} diff --git a/packages/llm/src/protocols/openai-responses.ts b/packages/llm/src/protocols/openai-responses.ts index ad673a263f..cc29f50190 100644 --- a/packages/llm/src/protocols/openai-responses.ts +++ b/packages/llm/src/protocols/openai-responses.ts @@ -320,7 +320,9 @@ const lowerToolResultOutput = Effect.fn("OpenAIResponses.lowerToolResultOutput") // Text/json/error results are encoded as a plain string for backward // compatibility with existing cassettes and provider expectations. if (part.result.type !== "content") return ProviderShared.toolResultText(part) - return yield* Effect.forEach(part.result.value, lowerToolResultContentItem) + // Preserve the narrowed array element type when compiled through a consumer package. + const content: ReadonlyArray = part.result.value + return yield* Effect.forEach(content, lowerToolResultContentItem) }) const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (request: LLMRequest) { @@ -427,6 +429,7 @@ const lowerOptions = Effect.fn("OpenAIResponses.lowerOptions")(function* (reques const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request: LLMRequest) { const generation = request.generation + const options = yield* lowerOptions(request) return { model: request.model.id, input: yield* lowerMessages(request), @@ -436,7 +439,7 @@ const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request: max_output_tokens: generation?.maxTokens, temperature: generation?.temperature, top_p: generation?.topP, - ...(yield* lowerOptions(request)), + ...options, } }) diff --git a/packages/opencode/AGENTS.md b/packages/opencode/AGENTS.md index d367f44083..f07170c585 100644 --- a/packages/opencode/AGENTS.md +++ b/packages/opencode/AGENTS.md @@ -2,12 +2,8 @@ ## Database -- **Schema**: Drizzle schema lives in `src/**/*.sql.ts`. -- **Naming**: tables and columns use snake*case; join columns are `_id`; indexes are `*\_idx`. -- **Migrations**: generated by Drizzle Kit using `drizzle.config.ts` (schema: `./src/**/*.sql.ts`, output: `./migration`). -- **Command**: `bun run db generate --name `. -- **Output**: creates `migration/_/migration.sql` and `snapshot.json`. -- **Tests**: migration tests should read the per-folder layout (no `_journal.json`). +- **Schema**: Drizzle schema lives in `packages/core/src/**/*.sql.ts`. +- **Migrations**: database migrations live in `packages/core` and are applied by core. ## Development server diff --git a/packages/opencode/package.json b/packages/opencode/package.json index e60d5530ac..b56bdf187a 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -15,8 +15,7 @@ "build": "bun run script/build.ts", "fix-node-pty": "bun run script/fix-node-pty.ts", "dev": "bun run --conditions=browser ./src/index.ts", - "dev:temporary": "bun run --conditions=browser ./src/temporary.ts", - "db": "bun drizzle-kit" + "dev:temporary": "bun run --conditions=browser ./src/temporary.ts" }, "bin": { "opencode": "./bin/opencode" @@ -62,7 +61,6 @@ "@types/which": "3.0.4", "@types/yargs": "17.0.33", "@typescript/native-preview": "catalog:", - "drizzle-kit": "catalog:", "drizzle-orm": "catalog:", "prettier": "3.6.2", "typescript": "catalog:", diff --git a/packages/opencode/script/build-node.ts b/packages/opencode/script/build-node.ts index 0f0d55b46a..e6a4171f70 100755 --- a/packages/opencode/script/build-node.ts +++ b/packages/opencode/script/build-node.ts @@ -1,7 +1,6 @@ #!/usr/bin/env bun import { Script } from "@opencode-ai/script" -import fs from "fs" import path from "path" import { fileURLToPath } from "url" @@ -13,36 +12,6 @@ process.chdir(dir) const generated = await import("./generate.ts") -// Load migrations from migration directories -const migrationDirs = ( - await fs.promises.readdir(path.join(dir, "migration"), { - withFileTypes: true, - }) -) - .filter((entry) => entry.isDirectory() && /^\d{4}\d{2}\d{2}\d{2}\d{2}\d{2}/.test(entry.name)) - .map((entry) => entry.name) - .sort() - -const migrations = await Promise.all( - migrationDirs.map(async (name) => { - const file = path.join(dir, "migration", name, "migration.sql") - const sql = await Bun.file(file).text() - const match = /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})/.exec(name) - const timestamp = match - ? Date.UTC( - Number(match[1]), - Number(match[2]) - 1, - Number(match[3]), - Number(match[4]), - Number(match[5]), - Number(match[6]), - ) - : 0 - return { sql, timestamp, name } - }), -) -console.log(`Loaded ${migrations.length} migrations`) - await Bun.build({ target: "node", entrypoints: ["./src/node.ts"], @@ -51,7 +20,6 @@ await Bun.build({ sourcemap: "linked", external: ["jsonc-parser", "@lydell/node-pty"], define: { - OPENCODE_MIGRATIONS: JSON.stringify(migrations), OPENCODE_MODELS_DEV: generated.modelsData, OPENCODE_CHANNEL: `'${Script.channel}'`, }, diff --git a/packages/opencode/script/build.ts b/packages/opencode/script/build.ts index 33db38d84c..c93ae46d11 100755 --- a/packages/opencode/script/build.ts +++ b/packages/opencode/script/build.ts @@ -17,36 +17,6 @@ const generated = await import("./generate.ts") import { Script } from "@opencode-ai/script" import pkg from "../package.json" -// Load migrations from migration directories -const migrationDirs = ( - await fs.promises.readdir(path.join(dir, "migration"), { - withFileTypes: true, - }) -) - .filter((entry) => entry.isDirectory() && /^\d{4}\d{2}\d{2}\d{2}\d{2}\d{2}/.test(entry.name)) - .map((entry) => entry.name) - .sort() - -const migrations = await Promise.all( - migrationDirs.map(async (name) => { - const file = path.join(dir, "migration", name, "migration.sql") - const sql = await Bun.file(file).text() - const match = /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})/.exec(name) - const timestamp = match - ? Date.UTC( - Number(match[1]), - Number(match[2]) - 1, - Number(match[3]), - Number(match[4]), - Number(match[5]), - Number(match[6]), - ) - : 0 - return { sql, timestamp, name } - }), -) -console.log(`Loaded ${migrations.length} migrations`) - const singleFlag = process.argv.includes("--single") const baselineFlag = process.argv.includes("--baseline") const skipInstall = process.argv.includes("--skip-install") @@ -217,7 +187,6 @@ for (const item of targets) { entrypoints: ["./src/index.ts", parserWorker, workerPath, ...(embeddedFileMap ? ["opencode-web-ui.gen.ts"] : [])], define: { OPENCODE_VERSION: `'${Script.version}'`, - OPENCODE_MIGRATIONS: JSON.stringify(migrations), OPENCODE_MODELS_DEV: generated.modelsData, OTUI_TREE_SITTER_WORKER_PATH: bunfsRoot + workerRelativePath, OPENCODE_WORKER_PATH: workerPath, diff --git a/packages/opencode/script/check-migrations.ts b/packages/opencode/script/check-migrations.ts deleted file mode 100644 index f5eaf79323..0000000000 --- a/packages/opencode/script/check-migrations.ts +++ /dev/null @@ -1,16 +0,0 @@ -#!/usr/bin/env bun - -import { $ } from "bun" - -// drizzle-kit check compares schema to migrations, exits non-zero if drift -const result = await $`bun drizzle-kit check`.quiet().nothrow() - -if (result.exitCode !== 0) { - console.error("Schema has changes not captured in migrations!") - console.error("Run: bun drizzle-kit generate") - console.error("") - console.error(result.stderr.toString()) - process.exit(1) -} - -console.log("Migrations are up to date") diff --git a/packages/opencode/src/account/account.ts b/packages/opencode/src/account/account.ts index 2d855e0e95..9d9f7e4a28 100644 --- a/packages/opencode/src/account/account.ts +++ b/packages/opencode/src/account/account.ts @@ -454,6 +454,6 @@ export const layer: Layer.Layer[0] extends (db: infer T) => unknown ? T : never -type DbTransactionCallback = Parameters>[0] - const ACCOUNT_STATE_ID = 1 export interface Interface { @@ -41,32 +38,32 @@ export class Service extends Context.Service()("@opencode/Ac export const use = serviceUse(Service) -export const layer: Layer.Layer = Layer.effect( +export const layer = Layer.effect( Service, Effect.gen(function* () { + const { db } = yield* Database.Service const decode = Schema.decodeUnknownSync(Info) - const query = (f: DbTransactionCallback) => - Effect.try({ - try: () => Database.use(f), - catch: (cause) => new AccountRepoError({ message: "Database operation failed", cause }), - }) + const query = (effect: Effect.Effect) => + effect.pipe(Effect.mapError((cause) => new AccountRepoError({ message: "Database operation failed", cause }))) - const tx = (f: DbTransactionCallback) => - Effect.try({ - try: () => Database.transaction(f), - catch: (cause) => new AccountRepoError({ message: "Database operation failed", cause }), - }) - - const current = (db: DbClient) => { - const state = db.select().from(AccountStateTable).where(eq(AccountStateTable.id, ACCOUNT_STATE_ID)).get() + const current = Effect.fnUntraced(function* () { + const state = yield* db + .select() + .from(AccountStateTable) + .where(eq(AccountStateTable.id, ACCOUNT_STATE_ID)) + .get() if (!state?.active_account_id) return - const account = db.select().from(AccountTable).where(eq(AccountTable.id, state.active_account_id)).get() + const account = yield* db + .select() + .from(AccountTable) + .where(eq(AccountTable.id, state.active_account_id)) + .get() if (!account) return return { ...account, active_org_id: state.active_org_id ?? null } - } + }) - const state = (db: DbClient, accountID: AccountID, orgID: Option.Option) => { + const state = (accountID: AccountID, orgID: Option.Option) => { const id = Option.getOrNull(orgID) return db .insert(AccountStateTable) @@ -79,41 +76,46 @@ export const layer: Layer.Layer = Layer.effect( } const active = Effect.fn("AccountRepo.active")(() => - query((db) => current(db)).pipe(Effect.map((row) => (row ? Option.some(decode(row)) : Option.none()))), + query(current()).pipe(Effect.map((row) => (row ? Option.some(decode(row)) : Option.none()))), ) const list = Effect.fn("AccountRepo.list")(() => - query((db) => + query( db .select() .from(AccountTable) .all() - .map((row: AccountRow) => decode({ ...row, active_org_id: null })), + .pipe(Effect.map((rows) => rows.map((row: AccountRow) => decode({ ...row, active_org_id: null })))), ), ) const remove = Effect.fn("AccountRepo.remove")((accountID: AccountID) => - tx((db) => { - db.update(AccountStateTable) - .set({ active_account_id: null, active_org_id: null }) - .where(eq(AccountStateTable.active_account_id, accountID)) - .run() - db.delete(AccountTable).where(eq(AccountTable.id, accountID)).run() - }).pipe(Effect.asVoid), + query( + db.transaction((tx) => + Effect.gen(function* () { + yield* tx + .update(AccountStateTable) + .set({ active_account_id: null, active_org_id: null }) + .where(eq(AccountStateTable.active_account_id, accountID)) + .run() + yield* tx.delete(AccountTable).where(eq(AccountTable.id, accountID)).run() + }), + ), + ).pipe(Effect.asVoid), ) const use = Effect.fn("AccountRepo.use")((accountID: AccountID, orgID: Option.Option) => - query((db) => state(db, accountID, orgID)).pipe(Effect.asVoid), + query(state(accountID, orgID)).pipe(Effect.asVoid), ) const getRow = Effect.fn("AccountRepo.getRow")((accountID: AccountID) => - query((db) => db.select().from(AccountTable).where(eq(AccountTable.id, accountID)).get()).pipe( + query(db.select().from(AccountTable).where(eq(AccountTable.id, accountID)).get()).pipe( Effect.map(Option.fromNullishOr), ), ) const persistToken = Effect.fn("AccountRepo.persistToken")((input) => - query((db) => + query( db .update(AccountTable) .set({ @@ -127,31 +129,36 @@ export const layer: Layer.Layer = Layer.effect( ) const persistAccount = Effect.fn("AccountRepo.persistAccount")((input) => - tx((db) => { - const url = normalizeServerUrl(input.url) + query( + db.transaction((tx) => + Effect.gen(function* () { + const url = normalizeServerUrl(input.url) - db.insert(AccountTable) - .values({ - id: input.id, - email: input.email, - url, - access_token: input.accessToken, - refresh_token: input.refreshToken, - token_expiry: input.expiry, - }) - .onConflictDoUpdate({ - target: AccountTable.id, - set: { - email: input.email, - url, - access_token: input.accessToken, - refresh_token: input.refreshToken, - token_expiry: input.expiry, - }, - }) - .run() - void state(db, input.id, input.orgID) - }).pipe(Effect.asVoid), + yield* tx + .insert(AccountTable) + .values({ + id: input.id, + email: input.email, + url, + access_token: input.accessToken, + refresh_token: input.refreshToken, + token_expiry: input.expiry, + }) + .onConflictDoUpdate({ + target: AccountTable.id, + set: { + email: input.email, + url, + access_token: input.accessToken, + refresh_token: input.refreshToken, + token_expiry: input.expiry, + }, + }) + .run() + yield* state(input.id, input.orgID) + }), + ), + ).pipe(Effect.asVoid), ) return Service.of({ @@ -166,4 +173,6 @@ export const layer: Layer.Layer = Layer.effect( }), ) +export const defaultLayer = layer.pipe(Layer.provide(Database.defaultLayer)) + export * as AccountRepo from "./repo" diff --git a/packages/opencode/src/acp/content.ts b/packages/opencode/src/acp/content.ts index f83a75ef19..32630a620c 100644 --- a/packages/opencode/src/acp/content.ts +++ b/packages/opencode/src/acp/content.ts @@ -1,9 +1,9 @@ import type { ContentBlock, ContentChunk, ResourceLink, Role } from "@agentclientprotocol/sdk" import path from "node:path" import { pathToFileURL } from "node:url" -import type { MessageV2 } from "@/session/message-v2" +import { SessionLegacy } from "@opencode-ai/core/session/legacy" -export type PromptPart = MessageV2.TextPartInput | MessageV2.FilePartInput +export type PromptPart = SessionLegacy.TextPartInput | SessionLegacy.FilePartInput export type ReplayPart = | { @@ -141,7 +141,7 @@ function uriToFilePart( uri: string, mime: string, filename?: string, -): MessageV2.FilePartInput | MessageV2.TextPartInput { +): SessionLegacy.FilePartInput | SessionLegacy.TextPartInput { try { if (uri.startsWith("file://")) { return { diff --git a/packages/opencode/src/acp/directory.ts b/packages/opencode/src/acp/directory.ts index c49613dd06..eb0b788592 100644 --- a/packages/opencode/src/acp/directory.ts +++ b/packages/opencode/src/acp/directory.ts @@ -2,15 +2,15 @@ import { Agent } from "@/agent/agent" import { Command } from "@/command" import { InstanceRef } from "@/effect/instance-ref" import { InstanceStore } from "@/project/instance-store" -import { ModelID, ProviderID } from "@/provider/schema" +import { ProviderV2 } from "@opencode-ai/core/provider" import { Provider } from "@/provider/provider" import { Context, Effect, Layer, SynchronizedRef } from "effect" import type * as ACPError from "./error" export type ModelOption = { - readonly providerID: ProviderID + readonly providerID: ProviderV2.ID readonly providerName: string - readonly modelID: ModelID + readonly modelID: ProviderV2.ModelID readonly modelName: string } @@ -23,13 +23,13 @@ export type ModeOption = { export type ModelVariants = NonNullable export type DefaultModel = { - readonly providerID: ProviderID - readonly modelID: ModelID + readonly providerID: ProviderV2.ID + readonly modelID: ProviderV2.ModelID } export type Snapshot = { readonly directory: string - readonly providers: Record + readonly providers: Record readonly modelOptions: readonly ModelOption[] readonly variantsByModel: Readonly> readonly availableModes: readonly ModeOption[] @@ -58,7 +58,7 @@ export const variants = (snapshot: Snapshot, model: DefaultModel) => snapshot.va export const build = (input: { readonly directory: string - readonly providers: Record + readonly providers: Record readonly modes: readonly ModeOption[] readonly defaultModeID: string readonly commands: readonly Command.Info[] diff --git a/packages/opencode/src/acp/service.ts b/packages/opencode/src/acp/service.ts index 7172907283..39840c0910 100644 --- a/packages/opencode/src/acp/service.ts +++ b/packages/opencode/src/acp/service.ts @@ -41,7 +41,7 @@ import { ACPEvent } from "./event" import { ACPSession } from "./session" import { UsageService } from "./usage" import { ACPProfile } from "./profile" -import { ModelID, ProviderID } from "@/provider/schema" +import { ProviderV2 } from "@opencode-ai/core/provider" import { Provider } from "@/provider/provider" import type { Command } from "@/command" @@ -603,7 +603,7 @@ function makeUsageService(sdk: OpencodeClient) { .then((response) => { const providers = Object.fromEntries( (response.data?.providers ?? []).map((provider) => [provider.id, provider]), - ) as Record + ) as Record return UsageService.findContextLimit(providers, params.providerID, params.modelID) }) .catch((error: unknown) => { @@ -642,8 +642,8 @@ function makeUsageService(sdk: OpencodeClient) { const size = yield* contextLimit({ directory: params.directory, - providerID: ProviderID.make(message.providerID), - modelID: ModelID.make(message.modelID), + providerID: ProviderV2.ID.make(message.providerID), + modelID: ProviderV2.ModelID.make(message.modelID), }) if (!size) return @@ -745,7 +745,7 @@ async function loadDirectorySnapshot(sdk: OpencodeClient, directory: string) { const commandsData = commandsResponse.data! const skills = skillsResponse.data! const providers = Object.fromEntries(providersData.providers.map((provider) => [provider.id, provider])) as Record< - ProviderID, + ProviderV2.ID, Provider.Info > const defaultModelStarted = performance.now() @@ -784,7 +784,7 @@ async function loadDirectorySnapshot(sdk: OpencodeClient, directory: string) { function defaultModelFromConfig( configuredModel: string | undefined, - providers: Record, + providers: Record, ): Directory.DefaultModel | undefined { const configured = configuredModel ? Provider.parseModel(configuredModel) : undefined if (configured && providers[configured.providerID]?.models[configured.modelID]) return configured @@ -792,7 +792,7 @@ function defaultModelFromConfig( // First-session ACP startup must not scan historical sessions just to infer // a default. Configured model, opencode provider, then sorted best model keep // the protocol response deterministic without extra session/message reads. - const opencodeProvider = providers[ProviderID.make("opencode")] + const opencodeProvider = providers[ProviderV2.ID.make("opencode")] const opencodeModel = opencodeProvider ? Provider.sort(Object.values(opencodeProvider.models))[0] : undefined if (opencodeProvider && opencodeModel) return { providerID: opencodeProvider.id, modelID: opencodeModel.id } @@ -805,7 +805,7 @@ function selectDefaultModel(snapshot: Directory.Snapshot) { if (snapshot.defaultModel) return snapshot.defaultModel const model = snapshot.modelOptions[0] if (model) return { providerID: model.providerID, modelID: model.modelID } - return { providerID: "unknown" as ProviderID, modelID: "unknown" as ModelID } + return { providerID: "unknown" as ProviderV2.ID, modelID: "unknown" as ProviderV2.ModelID } } function detectSlashCommand(parts: ReturnType) { @@ -864,8 +864,8 @@ function configOptions(snapshot: Directory.Snapshot, session: ConfigState) { function parseSelectedModel(snapshot: Directory.Snapshot, modelId: string) { const selected = parseModelSelection(modelId, Object.values(snapshot.providers)) - const provider = snapshot.providers[ProviderID.make(selected.model.providerID)] - const model = provider?.models[ModelID.make(selected.model.modelID)] + const provider = snapshot.providers[ProviderV2.ID.make(selected.model.providerID)] + const model = provider?.models[ProviderV2.ModelID.make(selected.model.modelID)] if (!model) { return Effect.fail( new ACPError.InvalidModelError({ @@ -993,7 +993,7 @@ function restoreFromMessages(messages: readonly MessageInfo[]) { ) if (user?.model?.providerID && user.model.modelID) { return { - model: { providerID: user.model.providerID as ProviderID, modelID: user.model.modelID as ModelID }, + model: { providerID: user.model.providerID as ProviderV2.ID, modelID: user.model.modelID as ProviderV2.ModelID }, variant: user.model.variant, modeId: user.agent, } @@ -1002,7 +1002,7 @@ function restoreFromMessages(messages: readonly MessageInfo[]) { const assistant = messages.findLast((message) => message.providerID && message.modelID) if (assistant?.providerID && assistant.modelID) { return { - model: { providerID: assistant.providerID as ProviderID, modelID: assistant.modelID as ModelID }, + model: { providerID: assistant.providerID as ProviderV2.ID, modelID: assistant.modelID as ProviderV2.ModelID }, variant: assistant.variant, modeId: assistant.mode ?? assistant.agent, } diff --git a/packages/opencode/src/acp/session.ts b/packages/opencode/src/acp/session.ts index 82a9f61758..7b7dc9d4a1 100644 --- a/packages/opencode/src/acp/session.ts +++ b/packages/opencode/src/acp/session.ts @@ -1,12 +1,12 @@ import type { McpServer } from "@agentclientprotocol/sdk" import type { Message, Part } from "@opencode-ai/sdk/v2" +import { ProviderV2 } from "@opencode-ai/core/provider" import { Context, Effect, Layer, Ref } from "effect" -import type { ModelID, ProviderID } from "../provider/schema" import * as ACPError from "./error" export type SelectedModel = { - providerID: ProviderID - modelID: ModelID + providerID: ProviderV2.ID + modelID: ProviderV2.ModelID } export type KnownMessagePartMetadata = { diff --git a/packages/opencode/src/acp/usage.ts b/packages/opencode/src/acp/usage.ts index aae3246696..a6db606b59 100644 --- a/packages/opencode/src/acp/usage.ts +++ b/packages/opencode/src/acp/usage.ts @@ -3,7 +3,7 @@ import * as Log from "@opencode-ai/core/util/log" import type { AssistantMessage as OpenCodeAssistantMessage, Message } from "@opencode-ai/sdk/v2" import { InstanceRef } from "@/effect/instance-ref" import { InstanceStore } from "@/project/instance-store" -import { ModelID, ProviderID } from "@/provider/schema" +import { ProviderV2 } from "@opencode-ai/core/provider" import { Provider } from "@/provider/provider" import { Context, Effect, Layer, SynchronizedRef } from "effect" @@ -38,7 +38,7 @@ export interface MessageLoaderInterface { } export interface ContextLimitLoaderInterface { - readonly providers: (directory: string) => Effect.Effect, unknown> + readonly providers: (directory: string) => Effect.Effect, unknown> } export type UsageConnection = Pick @@ -49,8 +49,8 @@ export interface Interface { readonly totalSessionCost: (messages: readonly SessionMessage[]) => number readonly contextLimit: (input: { readonly directory: string - readonly providerID: ProviderID - readonly modelID: ModelID + readonly providerID: ProviderV2.ID + readonly modelID: ProviderV2.ModelID }) => Effect.Effect readonly sendUpdate: (input: { readonly connection: UsageConnection @@ -110,9 +110,9 @@ export function totalSessionCost(messages: readonly SessionMessage[]): number { } export function findContextLimit( - providers: Record, - providerID: ProviderID, - modelID: ModelID, + providers: Record, + providerID: ProviderV2.ID, + modelID: ProviderV2.ModelID, ): number | undefined { return providers[providerID]?.models[modelID]?.limit.context } @@ -143,8 +143,8 @@ export const layer = Layer.effect( const cachedLimit = Effect.fnUntraced(function* (input: { readonly directory: string - readonly providerID: ProviderID - readonly modelID: ModelID + readonly providerID: ProviderV2.ID + readonly modelID: ProviderV2.ModelID }) { return yield* SynchronizedRef.modifyEffect( limits, @@ -170,8 +170,8 @@ export const layer = Layer.effect( const contextLimit = Effect.fn("ACPUsage.contextLimit")(function* (input: { readonly directory: string - readonly providerID: ProviderID - readonly modelID: ModelID + readonly providerID: ProviderV2.ID + readonly modelID: ProviderV2.ModelID }) { return yield* yield* cachedLimit(input) }) @@ -197,8 +197,8 @@ export const layer = Layer.effect( const size = yield* contextLimit({ directory: input.directory, - providerID: ProviderID.make(message.providerID), - modelID: ModelID.make(message.modelID), + providerID: ProviderV2.ID.make(message.providerID), + modelID: ProviderV2.ModelID.make(message.modelID), }) if (!size) return diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index 064a59f59e..9dba3445be 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -1,7 +1,7 @@ import { Config } from "@/config/config" import { serviceUse } from "@opencode-ai/core/effect/service-use" import { Provider } from "@/provider/provider" -import { ModelID, ProviderID } from "../provider/schema" + import { generateObject, streamObject, type ModelMessage } from "ai" import { Truncate } from "@/tool/truncate" import { Auth } from "../auth" @@ -25,6 +25,7 @@ import { RuntimeFlags } from "@/effect/runtime-flags" import * as Option from "effect/Option" import * as OtelTracer from "@effect/opentelemetry/Tracer" import { type DeepMutable } from "@opencode-ai/core/schema" +import { ProviderV2 } from "@opencode-ai/core/provider" export const Info = Schema.Struct({ name: Schema.String, @@ -38,8 +39,8 @@ export const Info = Schema.Struct({ permission: Permission.Ruleset, model: Schema.optional( Schema.Struct({ - modelID: ModelID, - providerID: ProviderID, + modelID: ProviderV2.ModelID, + providerID: ProviderV2.ID, }), ), variant: Schema.optional(Schema.String), @@ -62,7 +63,7 @@ export interface Interface { readonly defaultAgent: () => Effect.Effect readonly generate: (input: { description: string - model?: { providerID: ProviderID; modelID: ModelID } + model?: { providerID: ProviderV2.ID; modelID: ProviderV2.ModelID } }) => Effect.Effect< { identifier: string @@ -383,7 +384,7 @@ export const layer = Layer.effect( }), generate: Effect.fn("Agent.generate")(function* (input: { description: string - model?: { providerID: ProviderID; modelID: ModelID } + model?: { providerID: ProviderV2.ID; modelID: ProviderV2.ModelID } }) { const cfg = yield* config.get() const model = input.model ?? (yield* provider.defaultModel()) diff --git a/packages/opencode/src/bus/bus-event.ts b/packages/opencode/src/bus/bus-event.ts deleted file mode 100644 index 5a9e52ef07..0000000000 --- a/packages/opencode/src/bus/bus-event.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { Schema } from "effect" -import { EventV2 } from "@opencode-ai/core/event" - -export type Definition = { - type: Type - properties: Properties -} - -const registry = new Map() - -export function define( - type: Type, - properties: Properties, -): Definition { - const result = { type, properties } - registry.set(type, result) - return result -} - -export function effectPayloads() { - return [ - ...registry - .entries() - .map(([type, def]) => - Schema.Struct({ - id: Schema.String, - type: Schema.Literal(type), - properties: def.properties, - }).annotate({ identifier: `Event.${type}` }), - ) - .toArray(), - ...EventV2.registry - .values() - .map((definition) => - Schema.Struct({ - id: Schema.String, - type: Schema.Literal(definition.type), - properties: definition.data, - }).annotate({ identifier: `Event.${definition.type}` }), - ) - .toArray(), - ] -} - -export * as BusEvent from "./bus-event" diff --git a/packages/opencode/src/bus/index.ts b/packages/opencode/src/bus/index.ts deleted file mode 100644 index 73ec18d73b..0000000000 --- a/packages/opencode/src/bus/index.ts +++ /dev/null @@ -1,217 +0,0 @@ -import { Effect, Exit, Layer, PubSub, Scope, Context, Stream, Schema } from "effect" -import { EffectBridge } from "@/effect/bridge" -import * as Log from "@opencode-ai/core/util/log" -import { BusEvent } from "./bus-event" -import { GlobalBus } from "./global" -import { InstanceState } from "@/effect/instance-state" -import { makeRuntime } from "@/effect/run-service" -import { serviceUse } from "@opencode-ai/core/effect/service-use" -import { Identifier } from "@/id/id" -import type { InstanceContext } from "@/project/instance-context" -import { InstanceRef } from "@/effect/instance-ref" - -const log = Log.create({ service: "bus" }) - -type BusProperties> = Schema.Schema.Type - -export const InstanceDisposed = BusEvent.define( - "server.instance.disposed", - Schema.Struct({ - directory: Schema.String, - }), -) - -type Payload = { - id: string - type: D["type"] - properties: BusProperties -} - -type State = { - wildcard: PubSub.PubSub - typed: Map> -} - -export interface Interface { - readonly publish: ( - def: D, - properties: BusProperties, - options?: { id?: string }, - ) => Effect.Effect - // subscribe / subscribeAll are eager: the underlying PubSub subscription is - // acquired in the caller's Scope at `yield*` time. Any publish after the - // yield is delivered, even if stream consumption starts later. The previous - // Stream-returning shape acquired the subscription lazily on first pull, - // opening a race window during which publishes were lost — see - // test/bus/bus-effect.test.ts RACE tests. - readonly subscribe: ( - def: D, - ) => Effect.Effect>, never, Scope.Scope> - readonly subscribeAll: () => Effect.Effect, never, Scope.Scope> - readonly subscribeCallback: ( - def: D, - callback: (event: Payload) => unknown, - ) => Effect.Effect<() => void> - readonly subscribeAllCallback: (callback: (event: any) => unknown) => Effect.Effect<() => void> -} - -export class Service extends Context.Service()("@opencode/Bus") {} - -export const use = serviceUse(Service) - -export const layer = Layer.effect( - Service, - Effect.gen(function* () { - const state = yield* InstanceState.make( - Effect.fn("Bus.state")(function* (ctx) { - const wildcard = yield* PubSub.unbounded() - const typed = new Map>() - - yield* Effect.addFinalizer(() => - Effect.gen(function* () { - // 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) - for (const ps of typed.values()) { - yield* PubSub.shutdown(ps) - } - }), - ) - - return { wildcard, typed } - }), - ) - - function getOrCreate(state: State, def: D) { - return Effect.gen(function* () { - let ps = state.typed.get(def.type) - if (!ps) { - ps = yield* PubSub.unbounded() - state.typed.set(def.type, ps) - } - return ps as unknown as PubSub.PubSub> - }) - } - - function publish(def: D, properties: BusProperties, options?: { id?: string }) { - return Effect.gen(function* () { - const s = yield* InstanceState.get(state) - const payload: Payload = { id: options?.id ?? createID(), type: def.type, properties } - log.info("publishing", { type: def.type }) - - const ps = s.typed.get(def.type) - if (ps) yield* PubSub.publish(ps, payload) - yield* PubSub.publish(s.wildcard, payload) - - const dir = yield* InstanceState.directory - const context = yield* InstanceState.context - const workspace = yield* InstanceState.workspaceID - - GlobalBus.emit("event", { - directory: dir, - project: context.project.id, - workspace, - payload, - }) - }) - } - - const subscribe = ( - def: D, - ): Effect.Effect>, never, Scope.Scope> => - Effect.gen(function* () { - log.info("subscribing", { type: def.type }) - const s = yield* InstanceState.get(state) - const ps = yield* getOrCreate(s, def) - const subscription = yield* PubSub.subscribe(ps) - yield* Effect.addFinalizer(() => Effect.sync(() => log.info("unsubscribing", { type: def.type }))) - return Stream.fromSubscription(subscription) - }) - - const subscribeAll = (): Effect.Effect, never, Scope.Scope> => - Effect.gen(function* () { - log.info("subscribing", { type: "*" }) - const s = yield* InstanceState.get(state) - const subscription = yield* PubSub.subscribe(s.wildcard) - yield* Effect.addFinalizer(() => Effect.sync(() => log.info("unsubscribing", { type: "*" }))) - return Stream.fromSubscription(subscription) - }) - - function on(pubsub: PubSub.PubSub, type: string, callback: (event: T) => unknown) { - return Effect.gen(function* () { - log.info("subscribing", { type }) - const bridge = yield* EffectBridge.make() - const scope = yield* Scope.make() - const subscription = yield* Scope.provide(scope)(PubSub.subscribe(pubsub)) - - yield* Scope.provide(scope)( - Stream.fromSubscription(subscription).pipe( - Stream.runForEach((msg) => - Effect.tryPromise({ - try: () => Promise.resolve().then(() => callback(msg)), - catch: (cause) => { - log.error("subscriber failed", { type, cause }) - }, - }).pipe(Effect.ignore), - ), - Effect.forkScoped, - ), - ) - - return () => { - log.info("unsubscribing", { type }) - bridge.fork(Scope.close(scope, Exit.void)) - } - }) - } - - const subscribeCallback = Effect.fn("Bus.subscribeCallback")(function* ( - def: D, - callback: (event: Payload) => unknown, - ) { - const s = yield* InstanceState.get(state) - const ps = yield* getOrCreate(s, def) - return yield* on(ps, def.type, callback) - }) - - const subscribeAllCallback = Effect.fn("Bus.subscribeAllCallback")(function* (callback: (event: any) => unknown) { - const s = yield* InstanceState.get(state) - return yield* on(s.wildcard, "*", callback) - }) - - return Service.of({ publish, subscribe, subscribeAll, subscribeCallback, subscribeAllCallback }) - }), -) - -export const defaultLayer = layer - -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 function createID() { - return Identifier.create("evt", "ascending") -} - -export async function publish( - ctx: InstanceContext, - def: D, - properties: BusProperties, - options?: { id?: string }, -) { - return runPromise((svc) => svc.publish(def, properties, options).pipe(Effect.provideService(InstanceRef, ctx))) -} - -export function subscribe(def: D, callback: (event: Payload) => unknown) { - return runSync((svc) => svc.subscribeCallback(def, callback)) -} - -export function subscribeAll(callback: (event: any) => unknown) { - return runSync((svc) => svc.subscribeAllCallback(callback)) -} - -export * as Bus from "." diff --git a/packages/opencode/src/cli/cmd/db.ts b/packages/opencode/src/cli/cmd/db.ts index b113455f3b..9e7e37e18e 100644 --- a/packages/opencode/src/cli/cmd/db.ts +++ b/packages/opencode/src/cli/cmd/db.ts @@ -1,17 +1,14 @@ import type { Argv } from "yargs" import { spawn } from "child_process" -import { Database } from "@/storage/db" -import { drizzle } from "drizzle-orm/bun-sqlite" -import { Database as BunDatabase } from "bun:sqlite" -import { UI } from "../ui" -import { cmd } from "./cmd" -import { JsonMigration } from "@/storage/json-migration" -import { EOL } from "os" -import { errorMessage } from "../../util/error" +import { Database } from "@opencode-ai/core/database/database" +import { Effect } from "effect" +import { sql } from "drizzle-orm" +import { effectCmd } from "../effect-cmd" -const QueryCommand = cmd({ +const QueryCommand = effectCmd({ command: "$0 [query]", describe: "open an interactive sqlite3 shell or run a query", + instance: false, builder: (yargs: Argv) => { return yargs .positional("query", { @@ -25,96 +22,41 @@ const QueryCommand = cmd({ describe: "Output format", }) }, - handler: async (args: { query?: string; format: string }) => { + handler: Effect.fn("Cli.db.query")(function* (args: { query?: string; format: string }) { const query = args.query as string | undefined if (query) { - const db = new BunDatabase(Database.getPath(), { readonly: true }) - try { - const result = db.query(query).all() as Record[] - if (args.format === "json") { - console.log(JSON.stringify(result, null, 2)) - } else if (result.length > 0) { - const keys = Object.keys(result[0]) - console.log(keys.join("\t")) - for (const row of result) { - console.log(keys.map((k) => row[k]).join("\t")) - } - } - } catch (err) { - UI.error(errorMessage(err)) - process.exit(1) + const { db } = yield* Database.Service + const result = yield* db.all>(sql.raw(query)).pipe(Effect.orDie) + if (args.format === "json") console.log(JSON.stringify(result, null, 2)) + else if (result.length > 0) { + const keys = Object.keys(result[0]) + console.log(keys.join("\t")) + for (const row of result) console.log(keys.map((key) => row[key]).join("\t")) } - db.close() return } - const child = spawn("sqlite3", [Database.getPath()], { + const child = spawn("sqlite3", [Database.path()], { stdio: "inherit", }) - await new Promise((resolve) => child.on("close", resolve)) - }, + yield* Effect.promise(() => new Promise((resolve) => child.on("close", resolve))) + }), }) -const PathCommand = cmd({ +const PathCommand = effectCmd({ command: "path", describe: "print the database path", - handler: () => { - console.log(Database.getPath()) - }, + instance: false, + handler: Effect.fn("Cli.db.path")(function* () { + console.log(Database.path()) + }), }) -const MigrateCommand = cmd({ - command: "migrate", - describe: "migrate JSON data to SQLite (merges with existing data)", - handler: async () => { - const sqlite = new BunDatabase(Database.getPath()) - const tty = process.stderr.isTTY - const width = 36 - const orange = "\x1b[38;5;214m" - const muted = "\x1b[0;2m" - const reset = "\x1b[0m" - let last = -1 - if (tty) process.stderr.write("\x1b[?25l") - try { - const stats = await JsonMigration.run(drizzle({ client: sqlite }), { - progress: (event) => { - const percent = Math.floor((event.current / event.total) * 100) - if (percent === last) return - last = percent - if (tty) { - const fill = Math.round((percent / 100) * width) - const bar = `${"■".repeat(fill)}${"・".repeat(width - fill)}` - process.stderr.write( - `\r${orange}${bar} ${percent.toString().padStart(3)}%${reset} ${muted}${event.current}/${event.total}${reset} `, - ) - } else { - process.stderr.write(`sqlite-migration:${percent}${EOL}`) - } - }, - }) - if (tty) process.stderr.write("\n") - if (tty) process.stderr.write("\x1b[?25h") - else process.stderr.write(`sqlite-migration:done${EOL}`) - UI.println( - `Migration complete: ${stats.projects} projects, ${stats.sessions} sessions, ${stats.messages} messages`, - ) - if (stats.errors.length > 0) { - UI.println(`${stats.errors.length} errors occurred during migration`) - } - } catch (err) { - if (tty) process.stderr.write("\x1b[?25h") - UI.error(`Migration failed: ${errorMessage(err)}`) - process.exit(1) - } finally { - sqlite.close() - } - }, -}) - -export const DbCommand = cmd({ +export const DbCommand = effectCmd({ command: "db", describe: "database tools", + instance: false, builder: (yargs: Argv) => { - return yargs.command(QueryCommand).command(PathCommand).command(MigrateCommand).demandCommand() + return yargs.command(QueryCommand).command(PathCommand).demandCommand() }, - handler: () => {}, + handler: Effect.fn("Cli.db")(function* () {}), }) diff --git a/packages/opencode/src/cli/cmd/debug/agent.ts b/packages/opencode/src/cli/cmd/debug/agent.ts index c74c1c9079..0c310474e5 100644 --- a/packages/opencode/src/cli/cmd/debug/agent.ts +++ b/packages/opencode/src/cli/cmd/debug/agent.ts @@ -1,4 +1,5 @@ import { EOL } from "os" +import { SessionLegacy } from "@opencode-ai/core/session/legacy" import { basename } from "path" import { Cause, Effect } from "effect" import { Agent } from "../../../agent/agent" @@ -163,7 +164,7 @@ const createToolContext = Effect.fn("Cli.debug.agent.createToolContext")(functio ) }) const now = Date.now() - const message: MessageV2.Assistant = { + const message: SessionLegacy.Assistant = { id: messageID, sessionID: session.id, role: "assistant", diff --git a/packages/opencode/src/cli/cmd/debug/scrap.ts b/packages/opencode/src/cli/cmd/debug/scrap.ts index 2a127e5dbd..124dfd1356 100644 --- a/packages/opencode/src/cli/cmd/debug/scrap.ts +++ b/packages/opencode/src/cli/cmd/debug/scrap.ts @@ -1,15 +1,18 @@ import { EOL } from "os" import { Project } from "@/project/project" import * as Log from "@opencode-ai/core/util/log" +import { makeRuntime } from "@opencode-ai/core/effect/runtime" import { cmd } from "../cmd" +const runtime = makeRuntime(Project.Service, Project.defaultLayer) + export const ScrapCommand = cmd({ command: "scrap", describe: "list all known projects", builder: (yargs) => yargs, async handler() { const timer = Log.Default.time("scrap") - const list = await Project.list() + const list = await runtime.runPromise((project) => project.list()) process.stdout.write(JSON.stringify(list, null, 2) + EOL) timer.stop() }, diff --git a/packages/opencode/src/cli/cmd/export.ts b/packages/opencode/src/cli/cmd/export.ts index 9eb1faffea..e6bff506ca 100644 --- a/packages/opencode/src/cli/cmd/export.ts +++ b/packages/opencode/src/cli/cmd/export.ts @@ -1,4 +1,5 @@ import { Session } from "@/session/session" +import { SessionLegacy } from "@opencode-ai/core/session/legacy" import { MessageV2 } from "../../session/message-v2" import { SessionID } from "../../session/schema" import { effectCmd, fail } from "../effect-cmd" @@ -31,7 +32,7 @@ function diff(kind: string, diffs: { file?: string; patch?: string }[] | undefin })) } -function source(part: MessageV2.FilePart) { +function source(part: SessionLegacy.FilePart) { if (!part.source) return part.source if (part.source.type === "symbol") { return { @@ -56,7 +57,7 @@ function source(part: MessageV2.FilePart) { } } -function filepart(part: MessageV2.FilePart): MessageV2.FilePart { +function filepart(part: SessionLegacy.FilePart): SessionLegacy.FilePart { return { ...part, url: redact("file-url", part.id, part.url), @@ -65,7 +66,7 @@ function filepart(part: MessageV2.FilePart): MessageV2.FilePart { } } -function part(part: MessageV2.Part): MessageV2.Part { +function part(part: SessionLegacy.Part): SessionLegacy.Part { switch (part.type) { case "text": return { @@ -159,7 +160,7 @@ function part(part: MessageV2.Part): MessageV2.Part { const partFn = part -function sanitize(data: { info: Session.Info; messages: MessageV2.WithParts[] }) { +function sanitize(data: { info: Session.Info; messages: SessionLegacy.WithParts[] }) { return { info: { ...data.info, diff --git a/packages/opencode/src/cli/cmd/github.ts b/packages/opencode/src/cli/cmd/github.ts index 9ac605f46f..e12604c3a2 100644 --- a/packages/opencode/src/cli/cmd/github.ts +++ b/packages/opencode/src/cli/cmd/github.ts @@ -1,4 +1,5 @@ import path from "path" +import { SessionLegacy } from "@opencode-ai/core/session/legacy" import { exec } from "child_process" import { Filesystem } from "@/util/filesystem" import * as prompts from "@clack/prompts" @@ -26,8 +27,9 @@ import { Session } from "@/session/session" import type { SessionID } from "../../session/schema" import { MessageID, PartID } from "../../session/schema" import { Provider } from "@/provider/provider" -import { Bus } from "../../bus" import { MessageV2 } from "../../session/message-v2" +import { EventV2Bridge } from "@/event-v2-bridge" +import { EventV2 } from "@opencode-ai/core/event" import { SessionPrompt } from "@/session/prompt" import { Git } from "@/git" import { setTimeout as sleep } from "node:timers/promises" @@ -159,7 +161,7 @@ export { parseGitHubRemote } * Returns null for non-text responses (signals summary needed). * Throws only for truly empty responses. */ -export function extractResponseText(parts: MessageV2.Part[]): string | null { +export function extractResponseText(parts: SessionLegacy.Part[]): string | null { const textPart = parts.findLast((p) => p.type === "text") if (textPart) return textPart.text @@ -435,7 +437,7 @@ export const GithubRunCommand = effectCmd({ const sessionSvc = yield* Session.Service const sessionShare = yield* SessionShare.Service const sessionPrompt = yield* SessionPrompt.Service - const busSvc = yield* Bus.Service + const events = yield* EventV2Bridge.Service const runLocalEffect = (effect: Effect.Effect) => Effect.runPromise(effect.pipe(Effect.provideService(InstanceRef, ctx))) yield* Effect.promise(async () => { @@ -897,10 +899,12 @@ export const GithubRunCommand = effectCmd({ let text = "" await runLocalEffect( - busSvc.subscribeCallback(MessageV2.Event.PartUpdated, (evt) => { - if (evt.properties.part.sessionID !== session.id) return + events.listen((evt) => { + if (evt.type !== MessageV2.Event.PartUpdated.type) return Effect.void + const data = evt.data as EventV2.Data + if (data.part.sessionID !== session.id) return Effect.void //if (evt.properties.part.messageID === messageID) return - const part = evt.properties.part + const part = data.part if (part.type === "tool" && part.state.status === "completed") { const [tool, color] = TOOL[part.tool] ?? [part.tool, UI.Style.TEXT_INFO_BOLD] @@ -920,9 +924,10 @@ export const GithubRunCommand = effectCmd({ UI.println(UI.markdown(text)) UI.empty() text = "" - return + return Effect.void } } + return Effect.void }), ) } diff --git a/packages/opencode/src/cli/cmd/import.ts b/packages/opencode/src/cli/cmd/import.ts index 569aa309a4..7cad05baec 100644 --- a/packages/opencode/src/cli/cmd/import.ts +++ b/packages/opencode/src/cli/cmd/import.ts @@ -1,9 +1,10 @@ import type { Session as SDKSession, Message, Part } from "@opencode-ai/sdk/v2" +import { SessionLegacy } from "@opencode-ai/core/session/legacy" import { Session } from "@/session/session" import { MessageV2 } from "../../session/message-v2" import { CliError, effectCmd } from "../effect-cmd" -import { Database } from "@/storage/db" -import { SessionTable, MessageTable, PartTable } from "../../session/session.sql" +import { Database } from "@opencode-ai/core/database/database" +import { SessionTable, MessageTable, PartTable } from "@opencode-ai/core/session/sql" import { InstanceRef } from "@/effect/instance-ref" import { ShareNext } from "@/share/share-next" import { EOL } from "os" @@ -12,8 +13,8 @@ import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Effect, Schema } from "effect" import type { InstanceContext } from "@/project/instance-context" -const decodeMessageInfo = Schema.decodeUnknownSync(MessageV2.Info) -const decodePart = Schema.decodeUnknownSync(MessageV2.Part) +const decodeMessageInfo = Schema.decodeUnknownSync(SessionLegacy.Info) +const decodePart = Schema.decodeUnknownSync(SessionLegacy.Part) /** Discriminated union returned by the ShareNext API (GET /api/shares/:id/data) */ export type ShareData = @@ -98,6 +99,7 @@ export const ImportCommand = effectCmd({ const runImport = Effect.fn("Cli.import.body")(function* (file: string, ctx: InstanceContext) { const share = yield* ShareNext.Service const fs = yield* AppFileSystem.Service + const { db } = yield* Database.Service let exportData: ExportData | undefined @@ -175,48 +177,45 @@ const runImport = Effect.fn("Cli.import.body")(function* (file: string, ctx: Ins path: path.relative(path.resolve(ctx.worktree), ctx.directory).replaceAll("\\", "/"), }) as Session.Info const row = Session.toRow(info) - Database.use((db) => - db - .insert(SessionTable) - .values(row) - .onConflictDoUpdate({ - target: SessionTable.id, - set: { project_id: row.project_id, directory: row.directory, path: row.path }, - }) - .run(), - ) + yield* db + .insert(SessionTable) + .values(row) + .onConflictDoUpdate({ + target: SessionTable.id, + set: { project_id: row.project_id, directory: row.directory, path: row.path }, + }) + .run() + .pipe(Effect.orDie) for (const msg of exportData.messages) { - const msgInfo = decodeMessageInfo(msg.info) as MessageV2.Info + const msgInfo = decodeMessageInfo(msg.info) as SessionLegacy.Info const { id, sessionID: _, ...msgData } = msgInfo - Database.use((db) => - db - .insert(MessageTable) - .values({ - id, - session_id: row.id, - time_created: msgInfo.time?.created ?? Date.now(), - data: msgData, - }) - .onConflictDoNothing() - .run(), - ) + yield* db + .insert(MessageTable) + .values({ + id, + session_id: row.id, + time_created: msgInfo.time?.created ?? Date.now(), + data: msgData as never, + }) + .onConflictDoNothing() + .run() + .pipe(Effect.orDie) for (const part of msg.parts) { - const partInfo = decodePart(part) as MessageV2.Part + const partInfo = decodePart(part) as SessionLegacy.Part const { id: partId, sessionID: _s, messageID, ...partData } = partInfo - Database.use((db) => - db - .insert(PartTable) - .values({ - id: partId, - message_id: messageID, - session_id: row.id, - data: partData, - }) - .onConflictDoNothing() - .run(), - ) + yield* db + .insert(PartTable) + .values({ + id: partId, + message_id: messageID, + session_id: row.id, + data: partData, + }) + .onConflictDoNothing() + .run() + .pipe(Effect.orDie) } } diff --git a/packages/opencode/src/cli/cmd/mcp.ts b/packages/opencode/src/cli/cmd/mcp.ts index f7ea030aa9..75e41b3c8a 100644 --- a/packages/opencode/src/cli/cmd/mcp.ts +++ b/packages/opencode/src/cli/cmd/mcp.ts @@ -17,7 +17,8 @@ import path from "path" import { Global } from "@opencode-ai/core/global" import { modify, applyEdits } from "jsonc-parser" import { Filesystem } from "@/util/filesystem" -import { Bus } from "../../bus" +import { EventV2Bridge } from "@/event-v2-bridge" +import { EventV2 } from "@opencode-ai/core/event" import { Effect } from "effect" function getAuthStatusIcon(status: MCP.AuthStatus): string { @@ -256,13 +257,17 @@ export const McpAuthCommand = effectCmd({ spinner.start("Starting OAuth flow...") // Subscribe to browser open failure events to show URL for manual opening - const unsubscribe = Bus.subscribe(MCP.BrowserOpenFailed, (evt) => { - if (evt.properties.mcpName === serverName) { + const events = yield* EventV2Bridge.Service + const unsubscribe = yield* events.listen((event) => { + if (event.type !== MCP.BrowserOpenFailed.type) return Effect.void + const data = event.data as EventV2.Data + if (data.mcpName === serverName) { spinner.stop("Could not open browser automatically") prompts.log.warn("Please open this URL in your browser to authenticate:") - prompts.log.info(evt.properties.url) + prompts.log.info(data.url) spinner.start("Waiting for authorization...") } + return Effect.void }) yield* MCP.Service.use((mcp) => mcp.authenticate(serverName)).pipe( @@ -300,7 +305,7 @@ export const McpAuthCommand = effectCmd({ prompts.log.error(error instanceof Error ? error.message : String(error)) }), ), - Effect.ensuring(Effect.sync(() => unsubscribe())), + Effect.ensuring(unsubscribe), ) prompts.outro("Done") diff --git a/packages/opencode/src/cli/cmd/models.ts b/packages/opencode/src/cli/cmd/models.ts index 909b0b40ba..3349d3c5eb 100644 --- a/packages/opencode/src/cli/cmd/models.ts +++ b/packages/opencode/src/cli/cmd/models.ts @@ -1,10 +1,11 @@ import { EOL } from "os" import { Effect } from "effect" import { Provider } from "@/provider/provider" -import { ProviderID } from "../../provider/schema" + import { ModelsDev } from "@opencode-ai/core/models-dev" import { effectCmd, fail } from "../effect-cmd" import { UI } from "../ui" +import { ProviderV2 } from "@opencode-ai/core/provider" export const ModelsCommand = effectCmd({ command: "models [provider]", @@ -33,7 +34,7 @@ export const ModelsCommand = effectCmd({ const provider = yield* Provider.Service const providers = yield* provider.list() - const print = (providerID: ProviderID, verbose?: boolean) => { + const print = (providerID: ProviderV2.ID, verbose?: boolean) => { const p = providers[providerID] const sorted = Object.entries(p.models).sort(([a], [b]) => a.localeCompare(b)) for (const [modelID, model] of sorted) { @@ -47,7 +48,7 @@ export const ModelsCommand = effectCmd({ } if (args.provider) { - const providerID = ProviderID.make(args.provider) + const providerID = ProviderV2.ID.make(args.provider) if (!providers[providerID]) return yield* fail(`Provider not found: ${args.provider}`) print(providerID, args.verbose) return @@ -61,6 +62,6 @@ export const ModelsCommand = effectCmd({ return a.localeCompare(b) }) - for (const providerID of ids) print(ProviderID.make(providerID), args.verbose) + for (const providerID of ids) print(ProviderV2.ID.make(providerID), args.verbose) }), }) diff --git a/packages/opencode/src/cli/cmd/stats.ts b/packages/opencode/src/cli/cmd/stats.ts index 7ee16c2e21..22dee14772 100644 --- a/packages/opencode/src/cli/cmd/stats.ts +++ b/packages/opencode/src/cli/cmd/stats.ts @@ -2,8 +2,8 @@ import { Effect } from "effect" import { effectCmd } from "../effect-cmd" import { Session } from "@/session/session" import { NotFoundError } from "@/storage/storage" -import { Database } from "@/storage/db" -import { SessionTable } from "../../session/session.sql" +import { Database } from "@opencode-ai/core/database/database" +import { SessionTable } from "@opencode-ai/core/session/sql" import { Project } from "@/project/project" import { InstanceRef } from "@/effect/instance-ref" @@ -80,9 +80,10 @@ export const StatsCommand = effectCmd({ }), }) -const getAllSessions = Effect.sync(() => - Database.use((db) => db.select().from(SessionTable).all()).map((row) => Session.fromRow(row)), -) +const getAllSessions = Effect.fnUntraced(function* () { + const { db } = yield* Database.Service + return (yield* db.select().from(SessionTable).all().pipe(Effect.orDie)).map((row) => Session.fromRow(row)) +}) const aggregateSessionStats = Effect.fn("Cli.stats.aggregate")(function* ( days?: number, @@ -90,7 +91,7 @@ const aggregateSessionStats = Effect.fn("Cli.stats.aggregate")(function* ( currentProject?: Project.Info, ) { const svc = yield* Session.Service - const sessions = yield* getAllSessions + const sessions = yield* getAllSessions() const MS_IN_DAY = 24 * 60 * 60 * 1000 const cutoffTime = (() => { diff --git a/packages/opencode/src/cli/cmd/tui/event.ts b/packages/opencode/src/cli/cmd/tui/event.ts index bebb1fc6aa..73412b8778 100644 --- a/packages/opencode/src/cli/cmd/tui/event.ts +++ b/packages/opencode/src/cli/cmd/tui/event.ts @@ -1,15 +1,15 @@ -import { BusEvent } from "@/bus/bus-event" import { SessionID } from "@/session/schema" import { PositiveInt } from "@opencode-ai/core/schema" +import { EventV2 } from "@opencode-ai/core/event" import { Effect, Schema } from "effect" const DEFAULT_TOAST_DURATION = 5000 export const TuiEvent = { - PromptAppend: BusEvent.define("tui.prompt.append", Schema.Struct({ text: Schema.String })), - CommandExecute: BusEvent.define( - "tui.command.execute", - Schema.Struct({ + PromptAppend: EventV2.define({ type: "tui.prompt.append", schema: { text: Schema.String } }), + CommandExecute: EventV2.define({ + type: "tui.command.execute", + schema: { command: Schema.Union([ Schema.Literals([ "session.list", @@ -31,23 +31,23 @@ export const TuiEvent = { ]), Schema.String, ]), - }), - ), - ToastShow: BusEvent.define( - "tui.toast.show", - Schema.Struct({ + }, + }), + ToastShow: EventV2.define({ + type: "tui.toast.show", + schema: { title: Schema.optional(Schema.String), message: Schema.String, variant: Schema.Literals(["info", "success", "warning", "error"]), duration: PositiveInt.pipe(Schema.withDecodingDefault(Effect.succeed(DEFAULT_TOAST_DURATION))).annotate({ description: "Duration in milliseconds", }), - }), - ), - SessionSelect: BusEvent.define( - "tui.session.select", - Schema.Struct({ + }, + }), + SessionSelect: EventV2.define({ + type: "tui.session.select", + schema: { sessionID: SessionID.annotate({ description: "Session ID to navigate to" }), - }), - ), + }, + }), } diff --git a/packages/opencode/src/cli/cmd/tui/ui/toast.tsx b/packages/opencode/src/cli/cmd/tui/ui/toast.tsx index d15fb39205..381ecca7e8 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/toast.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/toast.tsx @@ -7,10 +7,10 @@ import { TextAttributes } from "@opentui/core" import { Schema } from "effect" import { TuiEvent } from "../event" -type ToastInput = Schema.Codec.Encoded -export type ToastOptions = Schema.Schema.Type +type ToastInput = Schema.Codec.Encoded +export type ToastOptions = Schema.Schema.Type -const decodeToastOptions = Schema.decodeUnknownSync(TuiEvent.ToastShow.properties) +const decodeToastOptions = Schema.decodeUnknownSync(TuiEvent.ToastShow.data) export function Toast() { const toast = useToast() diff --git a/packages/opencode/src/command/index.ts b/packages/opencode/src/command/index.ts index 96e171733d..6ef2ab780d 100644 --- a/packages/opencode/src/command/index.ts +++ b/packages/opencode/src/command/index.ts @@ -1,4 +1,3 @@ -import { BusEvent } from "@/bus/bus-event" import { InstanceState } from "@/effect/instance-state" import { EffectBridge } from "@/effect/bridge" import type { InstanceContext } from "@/project/instance-context" @@ -7,6 +6,7 @@ import { Effect, Layer, Context, Schema } from "effect" import { Config } from "@/config/config" import { MCP } from "../mcp" import { Skill } from "../skill" +import { EventV2 } from "@opencode-ai/core/event" import PROMPT_INITIALIZE from "./template/initialize.txt" import PROMPT_REVIEW from "./template/review.txt" @@ -15,15 +15,15 @@ type State = { } export const Event = { - Executed: BusEvent.define( - "command.executed", - Schema.Struct({ + Executed: EventV2.define({ + type: "command.executed", + schema: { name: Schema.String, sessionID: SessionID, arguments: Schema.String, messageID: MessageID, - }), - ), + }, + }), } export const Info = Schema.Struct({ diff --git a/packages/opencode/src/control-plane/adapters/index.ts b/packages/opencode/src/control-plane/adapters/index.ts index e5fa13714b..0b052f5c91 100644 --- a/packages/opencode/src/control-plane/adapters/index.ts +++ b/packages/opencode/src/control-plane/adapters/index.ts @@ -1,4 +1,4 @@ -import type { ProjectID } from "@/project/schema" +import type { ProjectV2 } from "@opencode-ai/core/project" import type { WorkspaceAdapter, WorkspaceAdapterEntry } from "../types" import { WorktreeAdapter } from "./worktree" @@ -6,9 +6,9 @@ const BUILTIN: Record = { worktree: WorktreeAdapter, } -const state = new Map>() +const state = new Map>() -export function getAdapter(projectID: ProjectID, type: string): WorkspaceAdapter { +export function getAdapter(projectID: ProjectV2.ID, type: string): WorkspaceAdapter { const custom = state.get(projectID)?.get(type) if (custom) return custom @@ -18,7 +18,7 @@ export function getAdapter(projectID: ProjectID, type: string): WorkspaceAdapter throw new Error(`Unknown workspace adapter: ${type}`) } -export function listAdapters(projectID: ProjectID): WorkspaceAdapterEntry[] { +export function listAdapters(projectID: ProjectV2.ID): WorkspaceAdapterEntry[] { return registeredAdapters(projectID).map(([type, adapter]) => ({ type, name: adapter.name, @@ -26,15 +26,15 @@ export function listAdapters(projectID: ProjectID): WorkspaceAdapterEntry[] { })) } -export function registeredAdapters(projectID: ProjectID): [string, WorkspaceAdapter][] { +export function registeredAdapters(projectID: ProjectV2.ID): [string, WorkspaceAdapter][] { const adapters = new Map(Object.entries(BUILTIN)) for (const [type, adapter] of state.get(projectID)?.entries() ?? []) adapters.set(type, adapter) return [...adapters.entries()] } // Plugins can be loaded per-project so we need to scope them. If you -// want to install a global one pass `ProjectID.global` -export function registerAdapter(projectID: ProjectID, type: string, adapter: WorkspaceAdapter) { +// want to install a global one pass `ProjectV2.ID.global` +export function registerAdapter(projectID: ProjectV2.ID, type: string, adapter: WorkspaceAdapter) { const adapters = state.get(projectID) ?? new Map() adapters.set(type, adapter) state.set(projectID, adapters) diff --git a/packages/opencode/src/control-plane/schema.ts b/packages/opencode/src/control-plane/schema.ts deleted file mode 100644 index 1954543f4a..0000000000 --- a/packages/opencode/src/control-plane/schema.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { Schema } from "effect" - -import { Identifier } from "@/id/id" -import { withStatics } from "@opencode-ai/core/schema" - -const workspaceIdSchema = Schema.String.check(Schema.isStartsWith("wrk")).pipe(Schema.brand("WorkspaceID")) - -export type WorkspaceID = typeof workspaceIdSchema.Type - -export const WorkspaceID = workspaceIdSchema.pipe( - withStatics((schema: typeof workspaceIdSchema) => ({ - ascending: (id?: string) => schema.make(Identifier.ascending("workspace", id)), - })), -) diff --git a/packages/opencode/src/control-plane/types.ts b/packages/opencode/src/control-plane/types.ts index daa8374530..f54a878dbd 100644 --- a/packages/opencode/src/control-plane/types.ts +++ b/packages/opencode/src/control-plane/types.ts @@ -1,17 +1,17 @@ import { Schema, Struct } from "effect" -import { ProjectID } from "@/project/schema" +import { ProjectV2 } from "@opencode-ai/core/project" import type { InstanceContext } from "@/project/instance-context" -import { WorkspaceID } from "./schema" +import { WorkspaceV2 } from "@opencode-ai/core/workspace" import type { DeepMutable } from "@opencode-ai/core/schema" export const WorkspaceInfo = Schema.Struct({ - id: WorkspaceID, + id: WorkspaceV2.ID, type: Schema.String, name: Schema.String, branch: Schema.optional(Schema.NullOr(Schema.String)), directory: Schema.optional(Schema.NullOr(Schema.String)), extra: Schema.optional(Schema.NullOr(Schema.Unknown)), - projectID: ProjectID, + projectID: ProjectV2.ID, }).annotate({ identifier: "Workspace" }) export type WorkspaceInfo = DeepMutable> @@ -40,7 +40,7 @@ export type Target = export type WorkspaceAdapterContext = { readonly instance?: InstanceContext - readonly workspaceID?: WorkspaceID + readonly workspaceID?: WorkspaceV2.ID } export type WorkspaceAdapter = { diff --git a/packages/opencode/src/control-plane/workspace-context.ts b/packages/opencode/src/control-plane/workspace-context.ts index 2e6aff1be6..52229e5639 100644 --- a/packages/opencode/src/control-plane/workspace-context.ts +++ b/packages/opencode/src/control-plane/workspace-context.ts @@ -1,18 +1,18 @@ import { LocalContext } from "@/util/local-context" -import type { WorkspaceID } from "../control-plane/schema" +import type { WorkspaceV2 } from "@opencode-ai/core/workspace" export interface WorkspaceContext { - workspaceID: WorkspaceID | undefined + workspaceID: WorkspaceV2.ID | undefined } const context = LocalContext.create("instance") export const WorkspaceContext = { - async provide(input: { workspaceID?: WorkspaceID; fn: () => R }): Promise { + async provide(input: { workspaceID?: WorkspaceV2.ID; fn: () => R }): Promise { return context.provide({ workspaceID: input.workspaceID }, () => input.fn()) }, - restore(workspaceID: WorkspaceID, fn: () => R): R { + restore(workspaceID: WorkspaceV2.ID, fn: () => R): R { return context.provide({ workspaceID }, fn) }, diff --git a/packages/opencode/src/control-plane/workspace.ts b/packages/opencode/src/control-plane/workspace.ts index 9f44d22334..d3147ec993 100644 --- a/packages/opencode/src/control-plane/workspace.ts +++ b/packages/opencode/src/control-plane/workspace.ts @@ -1,28 +1,28 @@ import { Context, Effect, FiberMap, Iterable, Layer, Schema, Stream } from "effect" import { serviceUse } from "@opencode-ai/core/effect/service-use" import { FetchHttpClient, HttpBody, HttpClient, HttpClientError, HttpClientRequest } from "effect/unstable/http" -import { Database } from "@/storage/db" +import { Database } from "@opencode-ai/core/database/database" import { asc } from "drizzle-orm" import { eq } from "drizzle-orm" import { inArray } from "drizzle-orm" import { Project } from "@/project/project" -import { BusEvent } from "@/bus/bus-event" import { GlobalBus } from "@/bus/global" import { Auth } from "@/auth" -import { SyncEvent } from "@/sync" -import { EventSequenceTable, EventTable } from "@/sync/event.sql" +import { EventV2 } from "@opencode-ai/core/event" +import { EventV2Bridge } from "@/event-v2-bridge" +import { EventSequenceTable, EventTable } from "@opencode-ai/core/event/sql" import { AppFileSystem } from "@opencode-ai/core/filesystem" import * as Log from "@opencode-ai/core/util/log" import { RuntimeFlags } from "@/effect/runtime-flags" -import { ProjectID } from "@/project/schema" +import { ProjectV2 } from "@opencode-ai/core/project" import { Slug } from "@opencode-ai/core/util/slug" -import { WorkspaceTable } from "./workspace.sql" +import { WorkspaceTable } from "@opencode-ai/core/control-plane/workspace.sql" import { getAdapter, registeredAdapters } from "./adapters" import { type Target, type WorkspaceInfo, WorkspaceInfo as WorkspaceInfoSchema } from "./types" -import { WorkspaceID } from "./schema" +import { WorkspaceV2 } from "@opencode-ai/core/workspace" import { Session } from "@/session/session" import { SessionPrompt } from "@/session/prompt" -import { SessionTable } from "@/session/session.sql" +import { SessionTable } from "@opencode-ai/core/session/sql" import { SessionID } from "@/session/schema" import { NotFoundError } from "@/storage/storage" import { errorData } from "@/util/error" @@ -40,25 +40,25 @@ export const Info = Schema.Struct({ export type Info = WorkspaceInfo & { timeUsed: number } export const ConnectionStatus = Schema.Struct({ - workspaceID: WorkspaceID, + workspaceID: WorkspaceV2.ID, status: Schema.Literals(["connected", "connecting", "disconnected", "error"]), }) export type ConnectionStatus = Schema.Schema.Type export const Event = { - Ready: BusEvent.define( - "workspace.ready", - Schema.Struct({ + Ready: EventV2.define({ + type: "workspace.ready", + schema: { name: Schema.String, - }), - ), - Failed: BusEvent.define( - "workspace.failed", - Schema.Struct({ + }, + }), + Failed: EventV2.define({ + type: "workspace.failed", + schema: { message: Schema.String, - }), - ), - Status: BusEvent.define("workspace.status", ConnectionStatus), + }, + }), + Status: EventV2.define({ type: "workspace.status", schema: ConnectionStatus.fields }), } function fromRow(row: typeof WorkspaceTable.$inferSelect): Info { @@ -74,22 +74,19 @@ function fromRow(row: typeof WorkspaceTable.$inferSelect): Info { } } -const db = (fn: (d: Parameters[0] extends (trx: infer D) => any ? D : never) => T) => - Effect.sync(() => Database.use(fn)) - const log = Log.create({ service: "workspace-sync" }) export const CreateInput = Schema.Struct({ - id: Schema.optional(WorkspaceID), + id: Schema.optional(WorkspaceV2.ID), type: Info.fields.type, branch: Info.fields.branch, - projectID: ProjectID, + projectID: ProjectV2.ID, extra: Schema.optional(Info.fields.extra), }) export type CreateInput = Schema.Schema.Type export const SessionWarpInput = Schema.Struct({ - workspaceID: Schema.NullOr(WorkspaceID), + workspaceID: Schema.NullOr(WorkspaceV2.ID), sessionID: SessionID, copyChanges: Schema.optional(Schema.Boolean), }) @@ -105,7 +102,7 @@ export class WorkspaceNotFoundError extends Schema.TaggedErrorClass Effect.Effect readonly list: (project: Project.Info) => Effect.Effect readonly syncList: (project: Project.Info) => Effect.Effect - readonly get: (id: WorkspaceID) => Effect.Effect - readonly remove: (id: WorkspaceID) => Effect.Effect + readonly get: (id: WorkspaceV2.ID) => Effect.Effect + readonly remove: (id: WorkspaceV2.ID) => Effect.Effect readonly status: () => Effect.Effect - readonly isSyncing: (workspaceID: WorkspaceID) => Effect.Effect + readonly isSyncing: (workspaceID: WorkspaceV2.ID) => Effect.Effect readonly waitForSync: ( - workspaceID: WorkspaceID, + workspaceID: WorkspaceV2.ID, state: Record, signal?: AbortSignal, timeout?: number, ) => Effect.Effect - readonly startWorkspaceSyncing: (projectID: ProjectID) => Effect.Effect + readonly startWorkspaceSyncing: (projectID: ProjectV2.ID) => Effect.Effect } export class Service extends Context.Service()("@opencode/Workspace") {} @@ -177,14 +174,15 @@ export const layer = Layer.effect( const session = yield* Session.Service const prompt = yield* SessionPrompt.Service const http = yield* HttpClient.HttpClient - const sync = yield* SyncEvent.Service + const events = yield* EventV2Bridge.Service const vcs = yield* Vcs.Service const flags = yield* RuntimeFlags.Service const fs = yield* AppFileSystem.Service - const connections = new Map() - const syncFibers = yield* FiberMap.make() + const { db } = yield* Database.Service + const connections = new Map() + const syncFibers = yield* FiberMap.make() - const setStatus = (id: WorkspaceID, status: ConnectionStatus["status"]) => { + const setStatus = (id: WorkspaceV2.ID, status: ConnectionStatus["status"]) => { const prev = connections.get(id) if (prev?.status === status) return const next = { workspaceID: id, status } @@ -270,7 +268,7 @@ export const layer = Layer.effect( }) const runInWorkspace = (input: { - workspaceID?: WorkspaceID + workspaceID?: WorkspaceV2.ID local: () => Effect.Effect remote: (input: { workspace: Info @@ -333,19 +331,20 @@ export const layer = Layer.effect( url: URL | string, headers: HeadersInit | undefined, ) { - const sessionIDs = yield* db((db) => - db - .select({ id: SessionTable.id }) - .from(SessionTable) - .where(eq(SessionTable.workspace_id, space.id)) - .all() - .map((row) => row.id), - ) + const sessionIDs = (yield* db + .select({ id: SessionTable.id }) + .from(SessionTable) + .where(eq(SessionTable.workspace_id, space.id)) + .all() + .pipe(Effect.orDie)).map((row) => row.id) const state = sessionIDs.length ? Object.fromEntries( - (yield* db((db) => - db.select().from(EventSequenceTable).where(inArray(EventSequenceTable.aggregate_id, sessionIDs)).all(), - )).map((row) => [row.aggregate_id, row.seq]), + (yield* db + .select() + .from(EventSequenceTable) + .where(inArray(EventSequenceTable.aggregate_id, sessionIDs)) + .all() + .pipe(Effect.orDie)).map((row) => [row.aggregate_id, row.seq]), ) : {} @@ -371,20 +370,20 @@ export const layer = Layer.effect( }) } - const events = (yield* response.json) as HistoryEvent[] + const history = (yield* response.json) as HistoryEvent[] log.info("workspace history synced", { workspaceID: space.id, - events: events.length, + events: history.length, }) yield* Effect.forEach( - events, + history, (event) => - sync + events .replay( { - id: event.id, + id: EventV2.ID.make(event.id), aggregateID: event.aggregate_id, seq: event.seq, type: event.type, @@ -431,11 +430,11 @@ export const layer = Layer.effect( yield* parseSSE(stream, (evt) => Effect.gen(function* () { if (!evt || typeof evt !== "object" || !("payload" in evt)) return - const payload = evt.payload as { type?: string; syncEvent?: SyncEvent.SerializedEvent } + const payload = evt.payload as { type?: string; syncEvent?: EventV2.SerializedEvent } if (payload.type === "server.heartbeat") return if (payload.type === "sync" && payload.syncEvent) { - const failed = yield* sync.replay(payload.syncEvent).pipe( + const failed = yield* events.replay(payload.syncEvent, { publish: true }).pipe( Effect.as(false), Effect.catchCause((error) => Effect.sync(() => { @@ -524,13 +523,13 @@ export const layer = Layer.effect( ) }) - const stopSync = Effect.fn("Workspace.stopSync")(function* (id: WorkspaceID) { + const stopSync = Effect.fn("Workspace.stopSync")(function* (id: WorkspaceV2.ID) { yield* FiberMap.remove(syncFibers, id) connections.delete(id) }) const create = Effect.fn("Workspace.create")(function* (input: CreateInput) { - const id = WorkspaceID.ascending(input.id) + const id = WorkspaceV2.ID.ascending(input.id) const adapter = getAdapter(input.projectID, input.type) const config = yield* WorkspaceAdapterRuntime.configure(adapter, { ...input, @@ -551,20 +550,20 @@ export const layer = Layer.effect( timeUsed: Date.now(), } - yield* db((db) => { - db.insert(WorkspaceTable) - .values({ - id: info.id, - type: info.type, - branch: info.branch, - name: info.name, - directory: info.directory, - extra: info.extra, - project_id: info.projectID, - time_used: info.timeUsed, - }) - .run() - }) + yield* db + .insert(WorkspaceTable) + .values({ + id: info.id, + type: info.type, + branch: info.branch, + name: info.name, + directory: info.directory, + extra: info.extra, + project_id: info.projectID, + time_used: info.timeUsed, + }) + .run() + .pipe(Effect.orDie) const env = { OPENCODE_AUTH_CONTENT: JSON.stringify(yield* auth.all()), @@ -603,13 +602,12 @@ export const layer = Layer.effect( sessionID: input.sessionID, }) - const current = yield* db((db) => - db - .select({ workspaceID: SessionTable.workspace_id }) - .from(SessionTable) - .where(eq(SessionTable.id, input.sessionID)) - .get(), - ) + const current = yield* db + .select({ workspaceID: SessionTable.workspace_id }) + .from(SessionTable) + .where(eq(SessionTable.id, input.sessionID)) + .get() + .pipe(Effect.orDie) if (current?.workspaceID) { const previous = yield* get(current.workspaceID) @@ -634,7 +632,7 @@ export const layer = Layer.effect( // "claim" this session so any future events coming from // the old workspace are ignored - yield* sync.claim(input.sessionID, input.workspaceID ?? previous.projectID) + yield* events.claim(input.sessionID, input.workspaceID ?? previous.projectID) } } @@ -669,12 +667,7 @@ export const layer = Layer.effect( } if (input.workspaceID === null) { - yield* sync.run(Session.Event.Updated, { - sessionID: input.sessionID, - info: { - workspaceID: null, - }, - }) + yield* session.setWorkspace({ sessionID: input.sessionID, workspaceID: undefined }) log.info("session warp complete", { workspaceID: input.workspaceID, @@ -695,12 +688,7 @@ export const layer = Layer.effect( const target = yield* WorkspaceAdapterRuntime.target(space) if (target.type === "local") { - yield* sync.run(Session.Event.Updated, { - sessionID: input.sessionID, - info: { - workspaceID: input.workspaceID, - }, - }) + yield* session.setWorkspace({ sessionID: input.sessionID, workspaceID: input.workspaceID }) log.info("session warp complete", { workspaceID: input.workspaceID, @@ -710,20 +698,19 @@ export const layer = Layer.effect( return } - const rows = yield* db((db) => - db - .select({ - id: EventTable.id, - aggregateID: EventTable.aggregate_id, - seq: EventTable.seq, - type: EventTable.type, - data: EventTable.data, - }) - .from(EventTable) - .where(eq(EventTable.aggregate_id, input.sessionID)) - .orderBy(asc(EventTable.seq)) - .all(), - ) + const rows = yield* db + .select({ + id: EventTable.id, + aggregateID: EventTable.aggregate_id, + seq: EventTable.seq, + type: EventTable.type, + data: EventTable.data, + }) + .from(EventTable) + .where(eq(EventTable.aggregate_id, input.sessionID)) + .orderBy(asc(EventTable.seq)) + .all() + .pipe(Effect.orDie) if (rows.length === 0) return yield* new SessionEventsNotFoundError({ message: `No events found for session: ${input.sessionID}`, @@ -810,6 +797,8 @@ export const layer = Layer.effect( }) } + yield* session.setWorkspace({ sessionID: input.sessionID, workspaceID: input.workspaceID }) + log.info("session warp complete", { workspaceID: input.workspaceID, sessionID: input.sessionID, @@ -829,15 +818,14 @@ export const layer = Layer.effect( }) const list = Effect.fn("Workspace.list")(function* (project: Project.Info) { - return yield* db((db) => - db - .select() - .from(WorkspaceTable) - .where(eq(WorkspaceTable.project_id, project.id)) - .all() - .map(fromRow) - .sort((a, b) => a.id.localeCompare(b.id)), - ) + return (yield* db + .select() + .from(WorkspaceTable) + .where(eq(WorkspaceTable.project_id, project.id)) + .all() + .pipe(Effect.orDie)) + .map(fromRow) + .sort((a, b) => a.id.localeCompare(b.id)) }) const syncList = Effect.fn("Workspace.syncList")(function* (project: Project.Info) { @@ -864,7 +852,7 @@ export const layer = Layer.effect( names.add(item.name) const info: Info = { - id: WorkspaceID.ascending(), + id: WorkspaceV2.ID.ascending(), type: item.type, branch: item.branch, name: item.name, @@ -874,20 +862,20 @@ export const layer = Layer.effect( timeUsed: Date.now(), } - yield* db((db) => { - db.insert(WorkspaceTable) - .values({ - id: info.id, - type: info.type, - branch: info.branch, - name: info.name, - directory: info.directory, - extra: info.extra, - project_id: info.projectID, - time_used: info.timeUsed, - }) - .run() - }) + yield* db + .insert(WorkspaceTable) + .values({ + id: info.id, + type: info.type, + branch: info.branch, + name: info.name, + directory: info.directory, + extra: info.extra, + project_id: info.projectID, + time_used: info.timeUsed, + }) + .run() + .pipe(Effect.orDie) yield* startSync(info) }), @@ -895,20 +883,19 @@ export const layer = Layer.effect( ) }) - const get = Effect.fn("Workspace.get")(function* (id: WorkspaceID) { - const row = yield* db((db) => db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get()) + const get = Effect.fn("Workspace.get")(function* (id: WorkspaceV2.ID) { + const row = yield* db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get().pipe(Effect.orDie) if (!row) return return fromRow(row) }) - const remove = Effect.fn("Workspace.remove")(function* (id: WorkspaceID) { - const sessions = yield* db((db) => - db - .select({ id: SessionTable.id, parentID: SessionTable.parent_id }) - .from(SessionTable) - .where(eq(SessionTable.workspace_id, id)) - .all(), - ) + const remove = Effect.fn("Workspace.remove")(function* (id: WorkspaceV2.ID) { + const sessions = yield* db + .select({ id: SessionTable.id, parentID: SessionTable.parent_id }) + .from(SessionTable) + .where(eq(SessionTable.workspace_id, id)) + .all() + .pipe(Effect.orDie) const sessionIDs = new Set(sessions.map((sessionInfo) => sessionInfo.id)) yield* Effect.forEach( sessions.filter((sessionInfo) => !sessionInfo.parentID || !sessionIDs.has(sessionInfo.parentID)), @@ -917,7 +904,7 @@ export const layer = Layer.effect( { discard: true }, ) - const row = yield* db((db) => db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get()) + const row = yield* db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get().pipe(Effect.orDie) if (!row) return yield* stopSync(id) @@ -933,7 +920,7 @@ export const layer = Layer.effect( }), ) - yield* db((db) => db.delete(WorkspaceTable).where(eq(WorkspaceTable.id, id)).run()) + yield* db.delete(WorkspaceTable).where(eq(WorkspaceTable.id, id)).run().pipe(Effect.orDie) return info }) @@ -941,30 +928,21 @@ export const layer = Layer.effect( return [...connections.values()] }) - const isSyncing = Effect.fn("Workspace.isSyncing")(function* (workspaceID: WorkspaceID) { + const isSyncing = Effect.fn("Workspace.isSyncing")(function* (workspaceID: WorkspaceV2.ID) { const exists = yield* FiberMap.has(syncFibers, workspaceID) return exists && connections.get(workspaceID)?.status !== "error" }) const waitForSync = Effect.fn("Workspace.waitForSync")(function* ( - workspaceID: WorkspaceID, + workspaceID: WorkspaceV2.ID, state: Record, signal?: AbortSignal, timeout = TIMEOUT, ) { - if (synced(state)) return + if (yield* synced(db, state)) return yield* Effect.catch( - waitEvent({ - timeout, - signal, - fn(event) { - if (event.workspace !== workspaceID && event.payload.type !== "sync") { - return false - } - return synced(state) - }, - }), + waitUntilSynced({ db, workspaceID, state, signal, timeout }), (): Effect.Effect => signal?.aborted ? Effect.fail( @@ -982,14 +960,13 @@ export const layer = Layer.effect( ) }) - const startWorkspaceSyncing = Effect.fn("Workspace.startWorkspaceSyncing")(function* (projectID: ProjectID) { - const rows = yield* db((db) => - db - .selectDistinct({ workspace: WorkspaceTable }) - .from(WorkspaceTable) - .where(eq(WorkspaceTable.project_id, projectID)) - .all(), - ) + const startWorkspaceSyncing = Effect.fn("Workspace.startWorkspaceSyncing")(function* (projectID: ProjectV2.ID) { + const rows = yield* db + .selectDistinct({ workspace: WorkspaceTable }) + .from(WorkspaceTable) + .where(eq(WorkspaceTable.project_id, projectID)) + .all() + .pipe(Effect.orDie) for (const { workspace } of rows) { yield* startSync(fromRow(workspace)).pipe( @@ -1025,11 +1002,12 @@ export const layer = Layer.effect( export const defaultLayer = layer.pipe( Layer.provide(Auth.defaultLayer), Layer.provide(Session.defaultLayer), - Layer.provide(SyncEvent.defaultLayer), Layer.provide(SessionPrompt.defaultLayer), Layer.provide(Project.defaultLayer), Layer.provide(Vcs.defaultLayer), Layer.provide(AppFileSystem.defaultLayer), + Layer.provide(Database.defaultLayer), + Layer.provide(EventV2Bridge.defaultLayer), Layer.provide(FetchHttpClient.layer), Layer.provide(RuntimeFlags.defaultLayer), ) @@ -1044,26 +1022,46 @@ type HistoryEvent = { data: Record } -function synced(state: Record) { +function waitUntilSynced(input: { + db: Database.Interface["db"] + workspaceID: WorkspaceV2.ID + state: Record + signal?: AbortSignal + timeout: number +}): Effect.Effect { + return Effect.suspend(() => + waitEvent({ + timeout: input.timeout, + signal: input.signal, + fn(event) { + return event.workspace === input.workspaceID || event.payload.type === "sync" + }, + }).pipe( + Effect.andThen(synced(input.db, input.state)), + Effect.flatMap((done): Effect.Effect => (done ? Effect.void : waitUntilSynced(input))), + ), + ) +} + +function synced(db: Database.Interface["db"], state: Record): Effect.Effect { const ids = Object.keys(state) - if (ids.length === 0) return true + if (ids.length === 0) return Effect.succeed(true) - const done = Object.fromEntries( - Database.use((db) => - db - .select({ - id: EventSequenceTable.aggregate_id, - seq: EventSequenceTable.seq, - }) - .from(EventSequenceTable) - .where(inArray(EventSequenceTable.aggregate_id, ids)) - .all(), - ).map((row) => [row.id, row.seq]), - ) as Record - - return ids.every((id) => { - return (done[id] ?? -1) >= state[id] - }) + return db + .select({ + id: EventSequenceTable.aggregate_id, + seq: EventSequenceTable.seq, + }) + .from(EventSequenceTable) + .where(inArray(EventSequenceTable.aggregate_id, ids)) + .all() + .pipe( + Effect.orDie, + Effect.map((rows) => { + const done = Object.fromEntries(rows.map((row) => [row.id, row.seq])) as Record + return ids.every((id) => (done[id] ?? -1) >= state[id]) + }), + ) } function route(url: string | URL, path: string) { diff --git a/packages/opencode/src/data-migration.ts b/packages/opencode/src/data-migration.ts deleted file mode 100644 index b6956032a4..0000000000 --- a/packages/opencode/src/data-migration.ts +++ /dev/null @@ -1,161 +0,0 @@ -import { Context, Effect, Layer } from "effect" -import { Database } from "./storage/db" -import { DataMigrationTable } from "./data-migration.sql" -import * as Log from "@opencode-ai/core/util/log" -import { and, asc, eq, gt, inArray, sql } from "drizzle-orm" -import { MessageTable, SessionTable } from "./session/session.sql" -import type { SessionID } from "./session/schema" - -export type Migration = { - name: string - run: Effect.Effect -} - -const log = Log.create({ service: "data-migration" }) - -export interface Interface {} - -export class Service extends Context.Service()("@opencode/DataMigration") {} - -export const layer = Layer.effect( - Service, - Effect.gen(function* () { - const migrations: Migration[] = [ - { - name: "session_usage_from_messages", - run: Effect.gen(function* () { - type Usage = { - cost: number - tokens: { input: number; output: number; reasoning: number; cache: { read: number; write: number } } - } - - for (let cursor: SessionID | undefined, page = 1; ; page++) { - const next = yield* Effect.gen(function* () { - const sessions = yield* Effect.sync(() => - Database.use((db) => - db - .select({ id: SessionTable.id }) - .from(SessionTable) - .where(cursor ? gt(SessionTable.id, cursor) : undefined) - .orderBy(asc(SessionTable.id)) - .limit(100) - .all(), - ), - ) - if (sessions.length === 0) return - - yield* Effect.sync(() => - Database.transaction((db) => { - const usageBySession = new Map( - sessions.map((session) => [ - session.id, - { cost: 0, tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } } }, - ]), - ) - - for (const row of db - .select({ - session_id: MessageTable.session_id, - cost: sql`coalesce(sum(coalesce(json_extract(${MessageTable.data}, '$.cost'), 0)), 0)`, - tokens_input: sql`coalesce(sum(coalesce(json_extract(${MessageTable.data}, '$.tokens.input'), 0)), 0)`, - tokens_output: sql`coalesce(sum(coalesce(json_extract(${MessageTable.data}, '$.tokens.output'), 0)), 0)`, - tokens_reasoning: sql`coalesce(sum(coalesce(json_extract(${MessageTable.data}, '$.tokens.reasoning'), 0)), 0)`, - tokens_cache_read: sql`coalesce(sum(coalesce(json_extract(${MessageTable.data}, '$.tokens.cache.read'), 0)), 0)`, - tokens_cache_write: sql`coalesce(sum(coalesce(json_extract(${MessageTable.data}, '$.tokens.cache.write'), 0)), 0)`, - }) - .from(MessageTable) - .where( - and( - inArray( - MessageTable.session_id, - sessions.map((session) => session.id), - ), - sql`json_extract(${MessageTable.data}, '$.role') = 'assistant'`, - ), - ) - .groupBy(MessageTable.session_id) - .all()) { - const current = usageBySession.get(row.session_id) - if (!current) continue - current.cost = row.cost - current.tokens.input = row.tokens_input - current.tokens.output = row.tokens_output - current.tokens.reasoning = row.tokens_reasoning - current.tokens.cache.read = row.tokens_cache_read - current.tokens.cache.write = row.tokens_cache_write - } - - for (const [sessionID, value] of usageBySession) { - db.update(SessionTable) - .set({ - cost: value.cost, - tokens_input: value.tokens.input, - tokens_output: value.tokens.output, - tokens_reasoning: value.tokens.reasoning, - tokens_cache_read: value.tokens.cache.read, - tokens_cache_write: value.tokens.cache.write, - time_updated: sql`${SessionTable.time_updated}`, - }) - .where(eq(SessionTable.id, sessionID)) - .run() - } - }), - ) - - return sessions.at(-1)?.id - }).pipe( - Effect.withSpan("DataMigration.sessionUsage.page", { - attributes: { - "data_migration.name": "session_usage_from_messages", - "data_migration.page": page, - "data_migration.cursor": cursor ?? "", - }, - }), - ) - if (!next) return - cursor = next - yield* Effect.sleep("10 millis") - } - }), - }, - ] - - yield* Effect.gen(function* () { - if (migrations.length === 0) return - - // Migrations run in a background fiber, so they must be resumable until - // their completion row is written. - for (const migration of migrations) { - const completed = Database.use((db) => - db - .select({ name: DataMigrationTable.name }) - .from(DataMigrationTable) - .where(eq(DataMigrationTable.name, migration.name)) - .get(), - ) - if (completed) continue - - log.info("running data migration", { name: migration.name }) - yield* migration.run.pipe(Effect.withSpan("DataMigration", { attributes: { name: migration.name } })) - Database.use((db) => - db - .insert(DataMigrationTable) - .values({ name: migration.name, time_completed: Date.now() }) - .onConflictDoNothing() - .run(), - ) - } - }).pipe( - Effect.tapCause((cause) => - Effect.logError("failed to run data migrations").pipe(Effect.annotateLogs("cause", cause)), - ), - Effect.ignore, - Effect.forkScoped, - ) - return Service.of({}) - }), -) - -export const defaultLayer = layer - -export * as DataMigration from "./data-migration" diff --git a/packages/opencode/src/effect/app-runtime.ts b/packages/opencode/src/effect/app-runtime.ts index 2bef35ed07..5434bb713f 100644 --- a/packages/opencode/src/effect/app-runtime.ts +++ b/packages/opencode/src/effect/app-runtime.ts @@ -3,7 +3,7 @@ import { attach } from "./run-service" import * as Observability from "@opencode-ai/core/effect/observability" import { AppFileSystem } from "@opencode-ai/core/filesystem" -import { Bus } from "@/bus" +import { Database } from "@opencode-ai/core/database/database" import { Auth } from "@/auth" import { Account } from "@/account/account" import { Config } from "@/config/config" @@ -51,18 +51,16 @@ import { PtyTicket } from "@/pty/ticket" import { Installation } from "@/installation" import { ShareNext } from "@/share/share-next" import { SessionShare } from "@/share/session" -import { SyncEvent } from "@/sync" import { Npm } from "@opencode-ai/core/npm" import { memoMap } from "@opencode-ai/core/effect/memo-map" -import { DataMigration } from "@/data-migration" import { BackgroundJob } from "@/background/job" -import { EventV2Bridge } from "@/event-v2-bridge" import { RuntimeFlags } from "@/effect/runtime-flags" +import { EventV2Bridge } from "@/event-v2-bridge" export const AppLayer = Layer.mergeAll( Npm.defaultLayer, AppFileSystem.defaultLayer, - Bus.defaultLayer, + Database.defaultLayer, Auth.defaultLayer, Account.defaultLayer, Config.defaultLayer, @@ -86,6 +84,7 @@ export const AppLayer = Layer.mergeAll( SessionStatus.defaultLayer, BackgroundJob.defaultLayer, RuntimeFlags.defaultLayer, + EventV2Bridge.defaultLayer, SessionRunState.defaultLayer, SessionProcessor.defaultLayer, SessionCompaction.defaultLayer, @@ -111,9 +110,6 @@ export const AppLayer = Layer.mergeAll( Installation.defaultLayer, ShareNext.defaultLayer, SessionShare.defaultLayer, - SyncEvent.defaultLayer, - EventV2Bridge.defaultLayer, - DataMigration.defaultLayer, ).pipe(Layer.provideMerge(InstanceLayer.layer), Layer.provideMerge(Observability.layer)) const rt = ManagedRuntime.make(AppLayer, { memoMap }) diff --git a/packages/opencode/src/effect/bootstrap-runtime.ts b/packages/opencode/src/effect/bootstrap-runtime.ts index 7f18538523..da3c10ff91 100644 --- a/packages/opencode/src/effect/bootstrap-runtime.ts +++ b/packages/opencode/src/effect/bootstrap-runtime.ts @@ -8,7 +8,6 @@ import { ShareNext } from "@/share/share-next" import { File } from "@/file" import { Vcs } from "@/project/vcs" import { Snapshot } from "@/snapshot" -import { Bus } from "@/bus" import { Config } from "@/config/config" import * as Observability from "@opencode-ai/core/effect/observability" import { memoMap } from "@opencode-ai/core/effect/memo-map" @@ -23,7 +22,6 @@ export const BootstrapLayer = Layer.mergeAll( FileWatcher.defaultLayer, Vcs.defaultLayer, Snapshot.defaultLayer, - Bus.defaultLayer, ).pipe(Layer.provide(Observability.layer)) export const BootstrapRuntime = ManagedRuntime.make(BootstrapLayer, { memoMap }) diff --git a/packages/opencode/src/effect/bridge.ts b/packages/opencode/src/effect/bridge.ts index 99f16f4371..a51c2938d8 100644 --- a/packages/opencode/src/effect/bridge.ts +++ b/packages/opencode/src/effect/bridge.ts @@ -1,6 +1,6 @@ import { Context, Effect, Exit, Fiber } from "effect" import { WorkspaceContext } from "@/control-plane/workspace-context" -import type { WorkspaceID } from "@/control-plane/schema" +import type { WorkspaceV2 } from "@opencode-ai/core/workspace" import { InstanceRef, WorkspaceRef } from "./instance-ref" import { attachWith } from "./run-service" @@ -11,7 +11,7 @@ export interface Shape { readonly bind: (fn: (...args: Args) => Result) => (...args: Args) => Result } -function restoreWorkspace(workspace: WorkspaceID | undefined, fn: () => R): R { +function restoreWorkspace(workspace: WorkspaceV2.ID | undefined, fn: () => R): R { if (workspace !== undefined) return WorkspaceContext.restore(workspace, fn) return fn() } diff --git a/packages/opencode/src/effect/instance-ref.ts b/packages/opencode/src/effect/instance-ref.ts index d95932c2de..49636c1f49 100644 --- a/packages/opencode/src/effect/instance-ref.ts +++ b/packages/opencode/src/effect/instance-ref.ts @@ -1,11 +1,11 @@ import { Context } from "effect" import type { InstanceContext } from "@/project/instance-context" -import type { WorkspaceID } from "@/control-plane/schema" +import type { WorkspaceV2 } from "@opencode-ai/core/workspace" export const InstanceRef = Context.Reference("~opencode/InstanceRef", { defaultValue: () => undefined, }) -export const WorkspaceRef = Context.Reference("~opencode/WorkspaceRef", { +export const WorkspaceRef = Context.Reference("~opencode/WorkspaceRef", { defaultValue: () => undefined, }) diff --git a/packages/opencode/src/effect/runtime-flags.ts b/packages/opencode/src/effect/runtime-flags.ts index 5765afee4e..c26e10aba2 100644 --- a/packages/opencode/src/effect/runtime-flags.ts +++ b/packages/opencode/src/effect/runtime-flags.ts @@ -17,11 +17,9 @@ export class Service extends ConfigService.Service()("@opencode/Runtime autoShare: bool("OPENCODE_AUTO_SHARE"), pure: bool("OPENCODE_PURE"), disableDefaultPlugins: bool("OPENCODE_DISABLE_DEFAULT_PLUGINS"), - disableChannelDb: bool("OPENCODE_DISABLE_CHANNEL_DB"), disableEmbeddedWebUi: bool("OPENCODE_DISABLE_EMBEDDED_WEB_UI"), disableExternalSkills: bool("OPENCODE_DISABLE_EXTERNAL_SKILLS"), disableLspDownload: bool("OPENCODE_DISABLE_LSP_DOWNLOAD"), - skipMigrations: bool("OPENCODE_SKIP_MIGRATIONS"), disableClaudeCodePrompt: Config.all({ broad: bool("OPENCODE_DISABLE_CLAUDE_CODE"), direct: bool("OPENCODE_DISABLE_CLAUDE_CODE_PROMPT"), diff --git a/packages/opencode/src/event-v2-bridge.ts b/packages/opencode/src/event-v2-bridge.ts index 4c6c79a707..673bf1f15b 100644 --- a/packages/opencode/src/event-v2-bridge.ts +++ b/packages/opencode/src/event-v2-bridge.ts @@ -1,27 +1,13 @@ -// Temporary V2 bridge: core events are the publish path, but the rest of -// opencode and the HTTP event stream still expect legacy bus/sync payloads. -// This layer goes away once consumers subscribe to core EventV2 directly. -import { Bus as ProjectBus } from "@/bus" -import { GlobalBus } from "@/bus/global" +// Opencode publish boundary for core events. Attach routed instance location +// so direct EventV2 consumers can isolate directory/workspace streams. import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref" -import { InstanceStore } from "@/project/instance-store" -import { SyncEvent } from "@/sync" +import { GlobalBus } from "@/bus/global" import { EventV2 } from "@opencode-ai/core/event" +import { AbsolutePath } from "@opencode-ai/core/schema" import "@opencode-ai/core/account" import "@opencode-ai/core/catalog" -import "@opencode-ai/core/session-event" -import { Context, Effect, Layer, Option } from "effect" - -export function toSyncDefinition(definition: D) { - const result = { - type: definition.type, - version: definition.version, - aggregate: definition.aggregate, - schema: definition.data, - properties: definition.data, - } - return result as SyncEvent.Definition -} +import "@opencode-ai/core/session/event" +import { Context, Effect, Layer } from "effect" export class Service extends Context.Service()("@opencode/EventV2Bridge") {} @@ -29,62 +15,40 @@ export const layer = Layer.effect( Service, Effect.gen(function* () { const events = yield* EventV2.Service - const bus = yield* ProjectBus.Service - const sync = yield* SyncEvent.Service - const publishGlobal = (event: EventV2.Payload) => - Effect.sync(() => { - GlobalBus.emit("event", { - workspace: event.location?.workspaceID, - payload: { - id: event.id, - type: event.type, - properties: event.data, + const publish: EventV2.Interface["publish"] = (definition, data, options) => + Effect.gen(function* () { + if (options?.location) return yield* events.publish(definition, data, options) + const ctx = yield* InstanceRef + if (!ctx) return yield* events.publish(definition, data, options) + const workspaceID = yield* WorkspaceRef + return yield* events.publish(definition, data, { + ...options, + location: { + directory: AbsolutePath.make(ctx.directory), + ...(workspaceID ? { workspaceID } : {}), }, }) }) - const provideEventLocation = (event: EventV2.Payload, effect: Effect.Effect) => { - return Effect.gen(function* () { + const unsubscribe = yield* events.listen((event) => + Effect.gen(function* () { const ctx = yield* InstanceRef - if (ctx) return yield* effect - const store = Option.getOrUndefined(yield* Effect.serviceOption(InstanceStore.Service)) - if (!event.location?.directory || !store) return yield* publishGlobal(event) - return yield* store.load({ directory: event.location.directory }).pipe( - Effect.flatMap((ctx) => { - const withInstance = effect.pipe(Effect.provideService(InstanceRef, ctx)) - if (!event.location?.workspaceID) return withInstance - return withInstance.pipe(Effect.provideService(WorkspaceRef, event.location.workspaceID)) - }), - ) - }) - } - - const unsubscribe = yield* events.sync((event) => { - const definition = EventV2.registry.get(event.type) - if (!definition) return Effect.void - const aggregateID = definition.aggregate - ? (event.data as Record)[definition.aggregate] - : undefined - - if (definition.version !== undefined && typeof aggregateID === "string") { - return provideEventLocation(event, sync.run(toSyncDefinition(definition), event.data)) - } - - return provideEventLocation( - event, - bus.publish({ type: definition.type, properties: definition.data }, event.data, { id: event.id }), - ) - }) + const workspaceID = (yield* WorkspaceRef) ?? event.location?.workspaceID + GlobalBus.emit("event", { + directory: event.location?.directory ?? ctx?.directory, + project: ctx?.project.id, + workspace: workspaceID, + payload: { id: event.id, type: event.type, properties: event.data }, + }) + }), + ) yield* Effect.addFinalizer(() => unsubscribe) - return Service.of(events) + + return Service.of({ ...events, publish }) }), ) -export const defaultLayer = layer.pipe( - Layer.provide(EventV2.defaultLayer), - Layer.provide(SyncEvent.defaultLayer), - Layer.provide(ProjectBus.defaultLayer), -) +export const defaultLayer = layer.pipe(Layer.provide(EventV2.defaultLayer)) export * as EventV2Bridge from "./event-v2-bridge" diff --git a/packages/opencode/src/file/index.ts b/packages/opencode/src/file/index.ts index 0992289fe2..c4aa2e1cfb 100644 --- a/packages/opencode/src/file/index.ts +++ b/packages/opencode/src/file/index.ts @@ -1,4 +1,4 @@ -import { BusEvent } from "@/bus/bus-event" +import { EventV2 } from "@opencode-ai/core/event" import { serviceUse } from "@opencode-ai/core/effect/service-use" import { InstanceState } from "@/effect/instance-state" @@ -62,12 +62,12 @@ export const Content = Schema.Struct({ export type Content = DeepMutable> export const Event = { - Edited: BusEvent.define( - "file.edited", - Schema.Struct({ + Edited: EventV2.define({ + type: "file.edited", + schema: { file: Schema.String, - }), - ), + }, + }), } const log = Log.create({ service: "file" }) diff --git a/packages/opencode/src/file/watcher.ts b/packages/opencode/src/file/watcher.ts index 91ad9ff4de..eeb3e5f5f5 100644 --- a/packages/opencode/src/file/watcher.ts +++ b/packages/opencode/src/file/watcher.ts @@ -4,8 +4,8 @@ import { createWrapper } from "@parcel/watcher/wrapper" import type ParcelWatcher from "@parcel/watcher" import { readdir, realpath } from "fs/promises" import path from "path" -import { Bus } from "@/bus" -import { BusEvent } from "@/bus/bus-event" +import { EventV2 } from "@opencode-ai/core/event" +import { EventV2Bridge } from "@/event-v2-bridge" import { EffectBridge } from "@/effect/bridge" import { InstanceState } from "@/effect/instance-state" import { Flag } from "@opencode-ai/core/flag/flag" @@ -22,13 +22,13 @@ const log = Log.create({ service: "file.watcher" }) const SUBSCRIBE_TIMEOUT_MS = 10_000 export const Event = { - Updated: BusEvent.define( - "file.watcher.updated", - Schema.Struct({ + Updated: EventV2.define({ + type: "file.watcher.updated", + schema: { file: Schema.String, event: Schema.Literals(["add", "change", "unlink"]), - }), - ), + }, + }), } const watcher = lazy((): typeof import("@parcel/watcher") | undefined => { @@ -69,6 +69,7 @@ export const layer = Layer.effect( Effect.gen(function* () { const config = yield* Config.Service const git = yield* Git.Service + const events = yield* EventV2Bridge.Service const state = yield* InstanceState.make( Effect.fn("FileWatcher.state")( @@ -98,9 +99,9 @@ export const layer = Layer.effect( const cb: ParcelWatcher.SubscribeCallback = bridge.bind((err, evts) => { // if (err) return for (const evt of evts) { - if (evt.type === "create") void Bus.publish(ctx, Event.Updated, { file: evt.path, event: "add" }) - if (evt.type === "update") void Bus.publish(ctx, Event.Updated, { file: evt.path, event: "change" }) - if (evt.type === "delete") void Bus.publish(ctx, Event.Updated, { file: evt.path, event: "unlink" }) + if (evt.type === "create") bridge.fork(events.publish(Event.Updated, { file: evt.path, event: "add" })) + if (evt.type === "update") bridge.fork(events.publish(Event.Updated, { file: evt.path, event: "change" })) + if (evt.type === "delete") bridge.fork(events.publish(Event.Updated, { file: evt.path, event: "unlink" })) } }) @@ -162,6 +163,10 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer), Layer.provide(Git.defaultLayer)) +export const defaultLayer = layer.pipe( + Layer.provide(Config.defaultLayer), + Layer.provide(Git.defaultLayer), + Layer.provide(EventV2Bridge.defaultLayer), +) export * as FileWatcher from "./watcher" diff --git a/packages/opencode/src/ide/index.ts b/packages/opencode/src/ide/index.ts index a31c5bd057..4df2ce8c2e 100644 --- a/packages/opencode/src/ide/index.ts +++ b/packages/opencode/src/ide/index.ts @@ -1,4 +1,4 @@ -import { BusEvent } from "@/bus/bus-event" +import { EventV2 } from "@opencode-ai/core/event" import { Schema } from "effect" import { NamedError } from "@opencode-ai/core/util/error" import * as Log from "@opencode-ai/core/util/log" @@ -15,12 +15,12 @@ const SUPPORTED_IDES = [ const log = Log.create({ service: "ide" }) export const Event = { - Installed: BusEvent.define( - "ide.installed", - Schema.Struct({ + Installed: EventV2.define({ + type: "ide.installed", + schema: { ide: Schema.String, - }), - ), + }, + }), } export const AlreadyInstalledError = NamedError.create("AlreadyInstalledError", {}) diff --git a/packages/opencode/src/image/image.ts b/packages/opencode/src/image/image.ts index 2a3c4fa5c0..8ecf0dcfcb 100644 --- a/packages/opencode/src/image/image.ts +++ b/packages/opencode/src/image/image.ts @@ -1,4 +1,5 @@ import { Config } from "@/config/config" +import { SessionLegacy } from "@opencode-ai/core/session/legacy" import type { MessageV2 } from "@/session/message-v2" import * as Log from "@opencode-ai/core/util/log" import photonWasm from "@silvia-odwyer/photon-node/photon_rs_bg.wasm" with { type: "file" } @@ -52,7 +53,7 @@ export class SizeError extends Schema.TaggedErrorClass()("ImageSizeEr export type Error = ResizerUnavailableError | InvalidDataUrlError | DecodeError | SizeError export interface Interface { - readonly normalize: (input: MessageV2.FilePart) => Effect.Effect + readonly normalize: (input: SessionLegacy.FilePart) => Effect.Effect } export class Service extends Context.Service()("@opencode/Image") {} @@ -73,7 +74,7 @@ export const layer = Layer.effect( ), ) - const normalize = Effect.fn("Image.normalize")(function* (input: MessageV2.FilePart) { + const normalize = Effect.fn("Image.normalize")(function* (input: SessionLegacy.FilePart) { const image = (yield* config.get()).attachment?.image const info = { autoResize: image?.auto_resize ?? AUTO_RESIZE, diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index d20f29dd4d..d8bcdff7b2 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -30,10 +30,9 @@ import { WebCommand } from "./cli/cmd/web" import { PrCommand } from "./cli/cmd/pr" import { SessionCommand } from "./cli/cmd/session" import { DbCommand } from "./cli/cmd/db" -import path from "path" import { Global } from "@opencode-ai/core/global" import { JsonMigration } from "@/storage/json-migration" -import { Database } from "@/storage/db" +import { Database } from "@opencode-ai/core/database/database" import { errorMessage } from "./util/error" import { PluginCommand } from "./cli/cmd/plug" import { Heap } from "./cli/heap" @@ -116,7 +115,7 @@ const cli = yargs(args) run_id: processMetadata.runID, }) - const marker = path.join(Global.Path.data, "opencode.db") + const marker = Database.path() if (!(await Filesystem.exists(marker))) { const tty = process.stderr.isTTY process.stderr.write("Performing one time database migration, may take a few minutes..." + EOL) @@ -126,8 +125,9 @@ const cli = yargs(args) const reset = "\x1b[0m" let last = -1 if (tty) process.stderr.write("\x1b[?25l") + const sqlite = new (await import("bun:sqlite")).Database(marker) try { - await JsonMigration.run(drizzle({ client: Database.Client().$client }), { + await JsonMigration.run(drizzle({ client: sqlite }), { progress: (event) => { const percent = Math.floor((event.current / event.total) * 100) if (percent === last && event.current !== event.total) return @@ -145,6 +145,7 @@ const cli = yargs(args) }, }) } finally { + sqlite.close() if (tty) process.stderr.write("\x1b[?25h") else { process.stderr.write(`sqlite-migration:done${EOL}`) diff --git a/packages/opencode/src/installation/index.ts b/packages/opencode/src/installation/index.ts index 1f8dc116b1..4367a26796 100644 --- a/packages/opencode/src/installation/index.ts +++ b/packages/opencode/src/installation/index.ts @@ -6,7 +6,7 @@ import { errorMessage } from "@/util/error" import { ChildProcess } from "effect/unstable/process" import { AppProcess } from "@opencode-ai/core/process" import path from "path" -import { BusEvent } from "@/bus/bus-event" +import { EventV2 } from "@opencode-ai/core/event" import * as Log from "@opencode-ai/core/util/log" import { makeRuntime } from "@opencode-ai/core/effect/runtime" import semver from "semver" @@ -20,18 +20,18 @@ export type Method = "curl" | "npm" | "yarn" | "pnpm" | "bun" | "brew" | "scoop" export type ReleaseType = "patch" | "minor" | "major" export const Event = { - Updated: BusEvent.define( - "installation.updated", - Schema.Struct({ + Updated: EventV2.define({ + type: "installation.updated", + schema: { version: Schema.String, - }), - ), - UpdateAvailable: BusEvent.define( - "installation.update-available", - Schema.Struct({ + }, + }), + UpdateAvailable: EventV2.define({ + type: "installation.update-available", + schema: { version: Schema.String, - }), - ), + }, + }), } export function getReleaseType(current: string, latest: string): ReleaseType { diff --git a/packages/opencode/src/lsp/client.ts b/packages/opencode/src/lsp/client.ts index 205cba6f29..25da0b10cb 100644 --- a/packages/opencode/src/lsp/client.ts +++ b/packages/opencode/src/lsp/client.ts @@ -1,5 +1,3 @@ -import { BusEvent } from "@/bus/bus-event" -import { Bus } from "@/bus" import path from "path" import { pathToFileURL, fileURLToPath } from "url" import { createMessageConnection, StreamMessageReader, StreamMessageWriter } from "vscode-jsonrpc/node" @@ -11,8 +9,6 @@ import { Effect, Schema } from "effect" import type * as LSPServer from "./server" import { withTimeout } from "../util/timeout" import { Filesystem } from "@/util/filesystem" -import { InstanceRef } from "@/effect/instance-ref" -import { makeRuntime } from "@/effect/run-service" import type { InstanceContext } from "@/project/instance-context" const DIAGNOSTICS_DEBOUNCE_MS = 150 @@ -28,8 +24,6 @@ const FILE_CHANGE_CHANGED = 2 const TEXT_DOCUMENT_SYNC_INCREMENTAL = 2 const log = Log.create({ service: "lsp.client" }) -const busRuntime = makeRuntime(Bus.Service, Bus.layer) - export type Info = NonNullable>> export type Diagnostic = VSCodeDiagnostic @@ -39,16 +33,6 @@ export class InitializeError extends Schema.TaggedErrorClass()( cause: Schema.optional(Schema.Defect), }) {} -export const Event = { - Diagnostics: BusEvent.define( - "lsp.client.diagnostics", - Schema.Struct({ - serverID: Schema.String, - path: Schema.String, - }), - ), -} - type DocumentDiagnosticReport = { items?: Diagnostic[] relatedDocuments?: Record @@ -169,15 +153,12 @@ export async function create(input: { const published = new Map() const diagnosticRegistrations = new Map() const registrationListeners = new Set<() => void>() + const diagnosticListeners = new Set<(input: { path: string; serverID: string }) => void>() const mergedDiagnostics = (filePath: string) => dedupeDiagnostics([...(pushDiagnostics.get(filePath) ?? []), ...(pullDiagnostics.get(filePath) ?? [])]) const updatePushDiagnostics = (filePath: string, next: Diagnostic[]) => { pushDiagnostics.set(filePath, next) - void busRuntime.runPromise((svc) => - svc - .publish(Event.Diagnostics, { path: filePath, serverID: input.serverID }) - .pipe(Effect.provideService(InstanceRef, instance)), - ) + for (const listener of diagnosticListeners) listener({ path: filePath, serverID: input.serverID }) } const updatePullDiagnostics = (filePath: string, next: Diagnostic[]) => { pullDiagnostics.set(filePath, next) @@ -525,14 +506,12 @@ export async function create(input: { } timeoutTimer = setTimeout(() => finish(false), request.timeout) - unsub = busRuntime.runSync((svc) => - svc - .subscribeCallback(Event.Diagnostics, (event) => { - if (event.properties.path !== request.path || event.properties.serverID !== input.serverID) return - schedule() - }) - .pipe(Effect.provideService(InstanceRef, instance)), - ) + const listener = (event: { path: string; serverID: string }) => { + if (event.path !== request.path || event.serverID !== input.serverID) return + schedule() + } + diagnosticListeners.add(listener) + unsub = () => diagnosticListeners.delete(listener) schedule() }) } diff --git a/packages/opencode/src/lsp/lsp.ts b/packages/opencode/src/lsp/lsp.ts index 3117b834c5..a0fcfb3fcd 100644 --- a/packages/opencode/src/lsp/lsp.ts +++ b/packages/opencode/src/lsp/lsp.ts @@ -1,5 +1,5 @@ -import { BusEvent } from "@/bus/bus-event" -import { Bus } from "@/bus" +import { EventV2Bridge } from "@/event-v2-bridge" +import { EventV2 } from "@opencode-ai/core/event" import * as Log from "@opencode-ai/core/util/log" import * as LSPClient from "./client" import path from "path" @@ -17,7 +17,7 @@ import { RuntimeFlags } from "@/effect/runtime-flags" const log = Log.create({ service: "lsp" }) export const Event = { - Updated: BusEvent.define("lsp.updated", Schema.Struct({})), + Updated: EventV2.define({ type: "lsp.updated", schema: {} }), } const Position = Schema.Struct({ @@ -144,6 +144,7 @@ export const layer = Layer.effect( Effect.gen(function* () { const config = yield* Config.Service const flags = yield* RuntimeFlags.Service + const events = yield* EventV2Bridge.Service const state = yield* InstanceState.make( Effect.fn("LSP.state")(function* (ctx) { @@ -212,9 +213,10 @@ export const layer = Layer.effect( const ctx = yield* InstanceState.context if (!containsPath(file, ctx)) return [] as LSPClient.Info[] const s = yield* InstanceState.get(state) - return yield* Effect.promise(async () => { + const clients = yield* Effect.promise(async () => { const extension = path.parse(file).ext || file const result: LSPClient.Info[] = [] + let updated = 0 async function schedule(server: LSPServer.Info, root: string, key: string) { const handle = await server @@ -291,11 +293,15 @@ export const layer = Layer.effect( if (!client) continue result.push(client) - await Bus.publish(ctx, Event.Updated, {}) + updated++ } - return result + return { result, updated } }) + yield* Effect.forEach(Array.from({ length: clients.updated }), () => events.publish(Event.Updated, {}), { + discard: true, + }) + return clients.result }) const run = Effect.fnUntraced(function* (file: string, fn: (client: LSPClient.Info) => Promise) { @@ -500,7 +506,11 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer), Layer.provide(RuntimeFlags.defaultLayer)) +export const defaultLayer = layer.pipe( + Layer.provide(Config.defaultLayer), + Layer.provide(RuntimeFlags.defaultLayer), + Layer.provide(EventV2Bridge.defaultLayer), +) export * as Diagnostic from "./diagnostic" diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index 2bac6cc84b..1717799e33 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -22,8 +22,8 @@ import { AppFileSystem } from "@opencode-ai/core/filesystem" import { McpOAuthProvider, OAUTH_CALLBACK_PATH } from "./oauth-provider" import { McpOAuthCallback } from "./oauth-callback" import { McpAuth } from "./auth" -import { BusEvent } from "../bus/bus-event" -import { Bus } from "@/bus" +import { EventV2Bridge } from "@/event-v2-bridge" +import { EventV2 } from "@opencode-ai/core/event" import { TuiEvent } from "@/cli/cmd/tui/event" import open from "open" import { Effect, Exit, Layer, Option, Context, Schema, Stream } from "effect" @@ -48,20 +48,20 @@ export const Resource = Schema.Struct({ }).annotate({ identifier: "McpResource" }) export type Resource = Schema.Schema.Type -export const ToolsChanged = BusEvent.define( - "mcp.tools.changed", - Schema.Struct({ +export const ToolsChanged = EventV2.define({ + type: "mcp.tools.changed", + schema: { server: Schema.String, - }), -) + }, +}) -export const BrowserOpenFailed = BusEvent.define( - "mcp.browser.open.failed", - Schema.Struct({ +export const BrowserOpenFailed = EventV2.define({ + type: "mcp.browser.open.failed", + schema: { mcpName: Schema.String, url: Schema.String, - }), -) + }, +}) export const Failed = NamedError.create("MCPFailed", { name: Schema.String, @@ -278,7 +278,7 @@ export const layer = Layer.effect( Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner const auth = yield* McpAuth.Service - const bus = yield* Bus.Service + const events = yield* EventV2Bridge.Service type Transport = StdioClientTransport | StreamableHTTPClientTransport | SSEClientTransport @@ -373,7 +373,7 @@ export const layer = Layer.effect( status: "needs_client_registration" as const, error: "Server does not support dynamic client registration. Please provide clientId in config.", } - return bus + return events .publish(TuiEvent.ToastShow, { title: "MCP Authentication Required", message: `Server "${key}" requires a pre-registered client ID. Add clientId to your config.`, @@ -384,7 +384,7 @@ export const layer = Layer.effect( } else { pendingOAuthTransports.set(key, transport) lastStatus = { status: "needs_auth" as const } - return bus + return events .publish(TuiEvent.ToastShow, { title: "MCP Authentication Required", message: `Server "${key}" requires authentication. Run: opencode mcp auth ${key}`, @@ -516,7 +516,7 @@ export const layer = Layer.effect( if (s.clients[name] !== client || s.status[name]?.status !== "connected") return s.defs[name] = listed - await bridge.promise(bus.publish(ToolsChanged, { server: name }).pipe(Effect.ignore)) + await bridge.promise(events.publish(ToolsChanged, { server: name }).pipe(Effect.ignore)) }) } @@ -880,7 +880,7 @@ export const layer = Layer.effect( ), Effect.catch(() => { log.warn("failed to open browser, user must open URL manually", { mcpName }) - return bus.publish(BrowserOpenFailed, { mcpName, url: result.authorizationUrl }).pipe(Effect.ignore) + return events.publish(BrowserOpenFailed, { mcpName, url: result.authorizationUrl }).pipe(Effect.ignore) }), ) @@ -972,7 +972,7 @@ export type AuthStatus = "authenticated" | "expired" | "not_authenticated" export const defaultLayer = layer.pipe( Layer.provide(McpAuth.defaultLayer), - Layer.provide(Bus.layer), + Layer.provide(EventV2Bridge.defaultLayer), Layer.provide(Config.defaultLayer), Layer.provide(CrossSpawnSpawner.defaultLayer), Layer.provide(AppFileSystem.defaultLayer), diff --git a/packages/opencode/src/node.ts b/packages/opencode/src/node.ts index 9c29dcd984..48b4293d19 100644 --- a/packages/opencode/src/node.ts +++ b/packages/opencode/src/node.ts @@ -2,5 +2,5 @@ export { Config } from "@/config/config" export { Server } from "./server/server" export { bootstrap } from "./cli/bootstrap" export * as Log from "@opencode-ai/core/util/log" -export { Database } from "@/storage/db" +export { Database } from "@opencode-ai/core/database/database" export { JsonMigration } from "@/storage/json-migration" diff --git a/packages/opencode/src/permission/index.ts b/packages/opencode/src/permission/index.ts index 1814c5ab2b..35546db84d 100644 --- a/packages/opencode/src/permission/index.ts +++ b/packages/opencode/src/permission/index.ts @@ -1,11 +1,9 @@ -import { Bus } from "@/bus" -import { BusEvent } from "@/bus/bus-event" import { ConfigPermission } from "@/config/permission" import { InstanceState } from "@/effect/instance-state" -import { ProjectID } from "@/project/schema" +import { ProjectV2 } from "@opencode-ai/core/project" import { MessageID, SessionID } from "@/session/schema" -import { PermissionTable } from "@/session/session.sql" -import { Database } from "@/storage/db" +import { PermissionTable } from "@opencode-ai/core/session/sql" +import { Database } from "@opencode-ai/core/database/database" import { eq } from "drizzle-orm" import * as Log from "@opencode-ai/core/util/log" import { Wildcard } from "@opencode-ai/core/util/wildcard" @@ -13,6 +11,8 @@ import { Deferred, Effect, Layer, Schema, Context } from "effect" import os from "os" import { PermissionV2 } from "@opencode-ai/core/permission" import { PermissionID } from "./schema" +import { EventV2Bridge } from "@/event-v2-bridge" +import { EventV2 } from "@opencode-ai/core/event" const log = Log.create({ service: "permission" }) @@ -61,21 +61,21 @@ export const ReplyBody = Schema.Struct(reply).annotate({ identifier: "Permission export type ReplyBody = Schema.Schema.Type export const Approval = Schema.Struct({ - projectID: ProjectID, + projectID: ProjectV2.ID, patterns: Schema.Array(Schema.String), }).annotate({ identifier: "PermissionApproval" }) export type Approval = Schema.Schema.Type export const Event = { - Asked: BusEvent.define("permission.asked", Request), - Replied: BusEvent.define( - "permission.replied", - Schema.Struct({ + Asked: EventV2.define({ type: "permission.asked", schema: Request.fields }), + Replied: EventV2.define({ + type: "permission.replied", + schema: { sessionID: SessionID, requestID: PermissionID, reply: Reply, - }), - ), + }, + }), } export class RejectedError extends Schema.TaggedErrorClass()("PermissionRejectedError", {}) { @@ -144,12 +144,11 @@ export class Service extends Context.Service()("@opencode/Pe export const layer = Layer.effect( Service, Effect.gen(function* () { - const bus = yield* Bus.Service + const events = yield* EventV2Bridge.Service + const { db } = yield* Database.Service const state = yield* InstanceState.make( Effect.fn("Permission.state")(function* (ctx) { - const row = Database.use((db) => - db.select().from(PermissionTable).where(eq(PermissionTable.project_id, ctx.project.id)).get(), - ) + const row = yield* db.select().from(PermissionTable).where(eq(PermissionTable.project_id, ctx.project.id)).get().pipe(Effect.orDie) const state = { pending: new Map(), approved: [...(row?.data ?? [])], @@ -201,7 +200,7 @@ export const layer = Layer.effect( const deferred = yield* Deferred.make() pending.set(id, { info, deferred }) - yield* bus.publish(Event.Asked, info) + yield* events.publish(Event.Asked, info) return yield* Effect.ensuring( Deferred.await(deferred), Effect.sync(() => { @@ -216,7 +215,7 @@ export const layer = Layer.effect( if (!existing) return yield* new NotFoundError({ requestID: input.requestID }) pending.delete(input.requestID) - yield* bus.publish(Event.Replied, { + yield* events.publish(Event.Replied, { sessionID: existing.info.sessionID, requestID: existing.info.id, reply: input.reply, @@ -231,7 +230,7 @@ export const layer = Layer.effect( for (const [id, item] of pending.entries()) { if (item.info.sessionID !== existing.info.sessionID) continue pending.delete(id) - yield* bus.publish(Event.Replied, { + yield* events.publish(Event.Replied, { sessionID: item.info.sessionID, requestID: item.info.id, reply: "reject", @@ -259,7 +258,7 @@ export const layer = Layer.effect( ) if (!ok) continue pending.delete(id) - yield* bus.publish(Event.Replied, { + yield* events.publish(Event.Replied, { sessionID: item.info.sessionID, requestID: item.info.id, reply: "always", @@ -307,6 +306,6 @@ export function disabled(tools: string[], ruleset: Ruleset): Set { return PermissionV2.disabled(tools, ruleset) } -export const defaultLayer = layer.pipe(Layer.provide(Bus.layer)) +export const defaultLayer = layer.pipe(Layer.provide(Database.defaultLayer), Layer.provide(EventV2Bridge.defaultLayer)) export * as Permission from "." diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index 717dff8db2..4781142092 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -6,7 +6,6 @@ import type { WorkspaceAdapter as PluginWorkspaceAdapter, } from "@opencode-ai/plugin" import { Config } from "@/config/config" -import { Bus } from "../bus" import * as Log from "@opencode-ai/core/util/log" import { createOpencodeClient } from "@opencode-ai/sdk" import { ServerAuth } from "@/server/auth" @@ -20,7 +19,7 @@ import { CloudflareAIGatewayAuthPlugin, CloudflareWorkersAuthPlugin } from "./cl import { AzureAuthPlugin } from "./azure" import { DigitalOceanAuthPlugin } from "./digitalocean" import { XaiAuthPlugin } from "./xai" -import { Effect, Layer, Context, Stream } from "effect" +import { Effect, Layer, Context } from "effect" import { EffectBridge } from "@/effect/bridge" import { InstanceState } from "@/effect/instance-state" import { errorMessage } from "@/util/error" @@ -29,6 +28,7 @@ import { parsePluginSpecifier, readPluginId, readV1Plugin, resolvePluginId } fro import { registerAdapter } from "@/control-plane/adapters" import type { WorkspaceAdapter } from "@/control-plane/types" import { RuntimeFlags } from "@/effect/runtime-flags" +import { EventV2Bridge } from "@/event-v2-bridge" import { InstallationChannel } from "@opencode-ai/core/installation/version" const log = Log.create({ service: "plugin" }) @@ -123,7 +123,7 @@ async function applyPlugin(load: PluginLoader.Loaded, input: PluginInput, hooks: export const layer = Layer.effect( Service, Effect.gen(function* () { - const bus = yield* Bus.Service + const events = yield* EventV2Bridge.Service const config = yield* Config.Service const flags = yield* RuntimeFlags.Service @@ -133,7 +133,7 @@ export const layer = Layer.effect( const bridge = yield* EffectBridge.make() function publishPluginError(message: string) { - bridge.fork(bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })) + bridge.fork(events.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })) } const { Server } = yield* Effect.promise(() => import("../server/server")) @@ -235,7 +235,7 @@ export const layer = Layer.effect( }).pipe( Effect.catch(() => { // TODO: make proper events for this - // bus.publish(Session.Event.Error, { + // events.publish(Session.Event.Error, { // error: new NamedError.Unknown({ // message: `Failed to load plugin ${load.spec}: ${message}`, // }).toObject(), @@ -255,6 +255,16 @@ export const layer = Layer.effect( }).pipe(Effect.ignore) } + const unsubscribe = yield* events.listen((event) => { + if (event.location?.directory !== ctx.directory) return Effect.void + return Effect.sync(() => { + for (const hook of hooks) { + void hook["event"]?.({ event: { id: event.id, type: event.type, properties: event.data } as any }) + } + }) + }) + yield* Effect.addFinalizer(() => unsubscribe) + yield* Effect.addFinalizer(() => Effect.forEach( hooks, @@ -269,18 +279,6 @@ export const layer = Layer.effect( ), ) - // Subscribe to bus events, fiber interrupted when scope closes - yield* (yield* bus.subscribeAll()).pipe( - Stream.runForEach((input) => - Effect.sync(() => { - for (const hook of hooks) { - void hook["event"]?.({ event: input as any }) - } - }), - ), - Effect.forkScoped, - ) - return { hooks } }), ) @@ -314,7 +312,7 @@ export const layer = Layer.effect( ) export const defaultLayer = layer.pipe( - Layer.provide(Bus.layer), + Layer.provide(EventV2Bridge.defaultLayer), Layer.provide(Config.defaultLayer), Layer.provide(RuntimeFlags.defaultLayer), ) diff --git a/packages/opencode/src/project/bootstrap.ts b/packages/opencode/src/project/bootstrap.ts index a7e67d45e9..e6c5d698ac 100644 --- a/packages/opencode/src/project/bootstrap.ts +++ b/packages/opencode/src/project/bootstrap.ts @@ -5,7 +5,6 @@ import { File } from "../file" import { Snapshot } from "../snapshot" import * as Project from "./project" import * as Vcs from "./vcs" -import { Bus } from "../bus" import { InstanceState } from "@/effect/instance-state" import { FileWatcher } from "@/file/watcher" import { ShareNext } from "@/share/share-next" @@ -57,7 +56,6 @@ export const layer = Layer.effect( export const defaultLayer: Layer.Layer = layer.pipe( Layer.provide([ - Bus.layer, Config.defaultLayer, File.defaultLayer, FileWatcher.defaultLayer, diff --git a/packages/opencode/src/project/instance-store.ts b/packages/opencode/src/project/instance-store.ts index 1f513fb1b4..ccac93ae15 100644 --- a/packages/opencode/src/project/instance-store.ts +++ b/packages/opencode/src/project/instance-store.ts @@ -19,6 +19,7 @@ export interface Interface { readonly load: (input: LoadInput) => Effect.Effect readonly reload: (input: LoadInput) => Effect.Effect readonly dispose: (ctx: InstanceContext) => Effect.Effect + readonly disposeDirectory: (directory: string) => Effect.Effect readonly disposeAll: () => Effect.Effect readonly provide: (input: LoadInput, effect: Effect.Effect) => Effect.Effect } @@ -151,6 +152,15 @@ export const layer: Layer.Layer> export const Event = { - Updated: BusEvent.define("project.updated", Info), + Updated: EventV2.define({ type: "project.updated", schema: Info.fields }), } type Row = typeof ProjectTable.$inferSelect @@ -92,7 +91,7 @@ function mergePermissionRules(oldRules: T, newRule } export const UpdateInput = Schema.Struct({ - projectID: ProjectID, + projectID: ProjectV2.ID, name: Schema.optional(Schema.String), icon: Schema.optional(ProjectIcon), commands: Schema.optional(ProjectCommands), @@ -107,7 +106,7 @@ export const UpdatePayload = Schema.Struct({ export type UpdatePayload = Types.DeepMutable> export class NotFoundError extends Schema.TaggedErrorClass()("Project.NotFoundError", { - projectID: ProjectID, + projectID: ProjectV2.ID, }) {} // --------------------------------------------------------------------------- @@ -124,13 +123,13 @@ export interface Interface { readonly fromDirectory: (directory: string) => Effect.Effect<{ project: Info; sandbox: string }> readonly discover: (input: Info) => Effect.Effect readonly list: () => Effect.Effect - readonly get: (id: ProjectID) => Effect.Effect + readonly get: (id: ProjectV2.ID) => Effect.Effect readonly update: (input: UpdateInput) => Effect.Effect readonly initGit: (input: { directory: string; project: Info }) => Effect.Effect - readonly setInitialized: (id: ProjectID) => Effect.Effect - readonly sandboxes: (id: ProjectID) => Effect.Effect - readonly addSandbox: (id: ProjectID, directory: string) => Effect.Effect - readonly removeSandbox: (id: ProjectID, directory: string) => Effect.Effect + readonly setInitialized: (id: ProjectV2.ID) => Effect.Effect + readonly sandboxes: (id: ProjectV2.ID) => Effect.Effect + readonly addSandbox: (id: ProjectV2.ID, directory: string) => Effect.Effect + readonly removeSandbox: (id: ProjectV2.ID, directory: string) => Effect.Effect } export class Service extends Context.Service()("@opencode/Project") {} @@ -144,8 +143,9 @@ export const layer = Layer.effect( const proc = yield* AppProcess.Service const spawner = yield* ChildProcessSpawner.ChildProcessSpawner const projectV2 = yield* ProjectV2.Service - const bus = yield* Bus.Service + const events = yield* EventV2Bridge.Service const flags = yield* RuntimeFlags.Service + const { db } = yield* Database.Service const git = Effect.fnUntraced( function* (args: string[], opts?: { cwd?: string }) { @@ -163,9 +163,6 @@ export const layer = Layer.effect( Effect.catch(() => Effect.succeed({ code: 1, text: "", stderr: "" } satisfies GitResult)), ) - const db = (fn: (d: Parameters[0] extends (trx: infer D) => any ? D : never) => T) => - Effect.sync(() => Database.use(fn)) - const emitUpdated = (data: Info) => Effect.sync(() => GlobalBus.emit("event", { @@ -180,20 +177,22 @@ export const layer = Layer.effect( const scope = yield* Scope.Scope const migrateProjectId = Effect.fn("Project.migrateProjectId")(function* ( - oldID: ProjectID | undefined, - newID: ProjectID, + oldID: ProjectV2.ID | undefined, + newID: ProjectV2.ID, ) { if (!oldID) return - if (oldID === ProjectID.global) return + if (oldID === ProjectV2.ID.global) return if (oldID === newID) return - yield* Effect.sync(() => - Database.transaction( - (d) => { - const oldProject = d.select().from(ProjectTable).where(eq(ProjectTable.id, oldID)).get() - const newProject = d.select().from(ProjectTable).where(eq(ProjectTable.id, newID)).get() + yield* db + .transaction( + (d) => + Effect.gen(function* () { + const oldProject = yield* d.select().from(ProjectTable).where(eq(ProjectTable.id, oldID)).get() + const newProject = yield* d.select().from(ProjectTable).where(eq(ProjectTable.id, newID)).get() if (oldProject && !newProject) { - d.insert(ProjectTable) + yield* d + .insert(ProjectTable) .values({ ...oldProject, id: newID, @@ -202,10 +201,11 @@ export const layer = Layer.effect( .run() } - const oldPermission = d.select().from(PermissionTable).where(eq(PermissionTable.project_id, oldID)).get() - const newPermission = d.select().from(PermissionTable).where(eq(PermissionTable.project_id, newID)).get() + const oldPermission = yield* d.select().from(PermissionTable).where(eq(PermissionTable.project_id, oldID)).get() + const newPermission = yield* d.select().from(PermissionTable).where(eq(PermissionTable.project_id, newID)).get() if (oldPermission && newPermission) { - d.update(PermissionTable) + yield* d + .update(PermissionTable) .set({ data: mergePermissionRules(oldPermission.data, newPermission.data), time_created: Math.min(oldPermission.time_created, newPermission.time_created), @@ -213,23 +213,24 @@ export const layer = Layer.effect( }) .where(eq(PermissionTable.project_id, newID)) .run() - d.delete(PermissionTable).where(eq(PermissionTable.project_id, oldID)).run() + yield* d.delete(PermissionTable).where(eq(PermissionTable.project_id, oldID)).run() } if (oldPermission && !newPermission) { - d.update(PermissionTable).set({ project_id: newID }).where(eq(PermissionTable.project_id, oldID)).run() + yield* d.update(PermissionTable).set({ project_id: newID }).where(eq(PermissionTable.project_id, oldID)).run() } - d.update(SessionTable) + yield* d + .update(SessionTable) .set({ project_id: newID, time_updated: sql`${SessionTable.time_updated}` }) .where(eq(SessionTable.project_id, oldID)) .run() - d.update(WorkspaceTable).set({ project_id: newID }).where(eq(WorkspaceTable.project_id, oldID)).run() + yield* d.update(WorkspaceTable).set({ project_id: newID }).where(eq(WorkspaceTable.project_id, oldID)).run() - if (oldProject) d.delete(ProjectTable).where(eq(ProjectTable.id, oldID)).run() - }, + if (oldProject) yield* d.delete(ProjectTable).where(eq(ProjectTable.id, oldID)).run() + }), { behavior: "immediate" }, - ), - ) + ) + .pipe(Effect.orDie) }) const fromDirectory = Effect.fn("Project.fromDirectory")(function* (directory: string) { @@ -239,9 +240,9 @@ export const layer = Layer.effect( const worktree = data.id === ProjectV2.ID.make("global") && !data.vcs ? "/" : data.directory // Phase 2: upsert - const projectID = ProjectID.make(data.id) - yield* migrateProjectId(data.previous ? ProjectID.make(data.previous) : undefined, projectID) - const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, projectID)).get()) + const projectID = ProjectV2.ID.make(data.id) + yield* migrateProjectId(data.previous ? ProjectV2.ID.make(data.previous) : undefined, projectID) + const row = yield* db.select().from(ProjectTable).where(eq(ProjectTable.id, projectID)).get().pipe(Effect.orDie) const existing = row ? fromRow(row) : { @@ -256,12 +257,12 @@ export const layer = Layer.effect( const result: Info = { ...existing, - worktree: projectID === ProjectID.global ? worktree : existing.worktree, + worktree: projectID === ProjectV2.ID.global ? worktree : existing.worktree, vcs: data.vcs?.type ?? fakeVcs, time: { ...existing.time, updated: Date.now() }, } if ( - projectID !== ProjectID.global && + projectID !== ProjectV2.ID.global && data.directory !== result.worktree && !result.sandboxes.includes(data.directory) ) @@ -276,8 +277,7 @@ export const layer = Layer.effect( { concurrency: "unbounded" }, ).pipe(Effect.map((arr) => arr.filter((x): x is string => x !== undefined))) - yield* db((d) => - d + yield* db .insert(ProjectTable) .values({ id: result.id, @@ -308,21 +308,20 @@ export const layer = Layer.effect( commands: result.commands, }, }) - .run(), - ) + .run() + .pipe(Effect.orDie) - if (projectID !== ProjectID.global) { - yield* db((d) => - d + if (projectID !== ProjectV2.ID.global) { + yield* db .update(SessionTable) .set({ project_id: projectID }) - .where(and(eq(SessionTable.project_id, ProjectID.global), eq(SessionTable.directory, data.directory))) - .run(), - ) + .where(and(eq(SessionTable.project_id, ProjectV2.ID.global), eq(SessionTable.directory, data.directory))) + .run() + .pipe(Effect.orDie) } yield* emitUpdated(result) - if (projectID !== ProjectID.global && data.vcs?.type === "git") { + if (projectID !== ProjectV2.ID.global && data.vcs?.type === "git") { yield* projectV2.commit({ store: data.vcs.store, id: data.id }) } return { project: result, sandbox: data.vcs ? data.directory : worktree } @@ -353,17 +352,16 @@ export const layer = Layer.effect( }) const list = Effect.fn("Project.list")(function* () { - return yield* db((d) => d.select().from(ProjectTable).all().map(fromRow)) + return (yield* db.select().from(ProjectTable).all().pipe(Effect.orDie)).map(fromRow) }) - const get = Effect.fn("Project.get")(function* (id: ProjectID) { - const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get()) + const get = Effect.fn("Project.get")(function* (id: ProjectV2.ID) { + const row = yield* db.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get().pipe(Effect.orDie) return row ? fromRow(row) : undefined }) const update = Effect.fn("Project.update")(function* (input: UpdateInput) { - const result = yield* db((d) => - d + const result = yield* db .update(ProjectTable) .set({ name: input.name, @@ -375,8 +373,8 @@ export const layer = Layer.effect( }) .where(eq(ProjectTable.id, input.projectID)) .returning() - .get(), - ) + .get() + .pipe(Effect.orDie) if (!result) return yield* new NotFoundError({ projectID: input.projectID }) const data = fromRow(result) yield* emitUpdated(data) @@ -394,20 +392,18 @@ export const layer = Layer.effect( return project }) - const setInitialized = Effect.fn("Project.setInitialized")(function* (id: ProjectID) { - yield* db((d) => - d.update(ProjectTable).set({ time_initialized: Date.now() }).where(eq(ProjectTable.id, id)).run(), - ) + const setInitialized = Effect.fn("Project.setInitialized")(function* (id: ProjectV2.ID) { + yield* db.update(ProjectTable).set({ time_initialized: Date.now() }).where(eq(ProjectTable.id, id)).run().pipe(Effect.orDie) }) const initState = yield* InstanceState.make( Effect.fn("Project.initState")(function* (ctx) { - yield* (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 unsubscribe = yield* events.listen((event) => { + if (event.type !== Command.Event.Executed.type || event.location?.directory !== ctx.directory) return Effect.void + const data = event.data as EventV2.Data + return data.name === Command.Default.INIT ? setInitialized(ctx.project.id) : Effect.void + }) + yield* Effect.addFinalizer(() => unsubscribe) }), ) @@ -415,8 +411,8 @@ export const layer = Layer.effect( 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()) + const sandboxes = Effect.fn("Project.sandboxes")(function* (id: ProjectV2.ID) { + const row = yield* db.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get().pipe(Effect.orDie) if (!row) return [] const data = fromRow(row) return yield* Effect.forEach( @@ -430,35 +426,33 @@ export const layer = Layer.effect( ).pipe(Effect.map((arr) => arr.filter((x): x is string => x !== undefined))) }) - const addSandbox = Effect.fn("Project.addSandbox")(function* (id: ProjectID, directory: string) { - const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get()) + const addSandbox = Effect.fn("Project.addSandbox")(function* (id: ProjectV2.ID, directory: string) { + const row = yield* db.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get().pipe(Effect.orDie) if (!row) throw new Error(`Project not found: ${id}`) const sboxes = [...row.sandboxes] if (!sboxes.includes(directory)) sboxes.push(directory) - const result = yield* db((d) => - d + const result = yield* db .update(ProjectTable) .set({ sandboxes: sboxes, time_updated: Date.now() }) .where(eq(ProjectTable.id, id)) .returning() - .get(), - ) + .get() + .pipe(Effect.orDie) if (!result) throw new Error(`Project not found: ${id}`) yield* emitUpdated(fromRow(result)) }) - const removeSandbox = Effect.fn("Project.removeSandbox")(function* (id: ProjectID, directory: string) { - const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get()) + const removeSandbox = Effect.fn("Project.removeSandbox")(function* (id: ProjectV2.ID, directory: string) { + const row = yield* db.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get().pipe(Effect.orDie) if (!row) throw new Error(`Project not found: ${id}`) const sboxes = row.sandboxes.filter((s) => s !== directory) - const result = yield* db((d) => - d + const result = yield* db .update(ProjectTable) .set({ sandboxes: sboxes, time_updated: Date.now() }) .where(eq(ProjectTable.id, id)) .returning() - .get(), - ) + .get() + .pipe(Effect.orDie) if (!result) throw new Error(`Project not found: ${id}`) yield* emitUpdated(fromRow(result)) }) @@ -480,36 +474,15 @@ export const layer = Layer.effect( ) export const defaultLayer = layer.pipe( - Layer.provide(Bus.defaultLayer), + Layer.provide(EventV2Bridge.defaultLayer), Layer.provide(ProjectV2.defaultLayer), Layer.provide(AppProcess.defaultLayer), Layer.provide(CrossSpawnSpawner.defaultLayer), Layer.provide(AppFileSystem.defaultLayer), + Layer.provide(Database.defaultLayer), Layer.provide(RuntimeFlags.defaultLayer), ) export const use = serviceUse(Service) -export function list() { - return Database.use((db) => - db - .select() - .from(ProjectTable) - .all() - .map((row) => fromRow(row)), - ) -} - -export function get(id: ProjectID): Info | undefined { - const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get()) - if (!row) return undefined - return fromRow(row) -} - -export function setInitialized(id: ProjectID) { - Database.use((db) => - db.update(ProjectTable).set({ time_initialized: Date.now() }).where(eq(ProjectTable.id, id)).run(), - ) -} - export * as Project from "./project" diff --git a/packages/opencode/src/project/schema.ts b/packages/opencode/src/project/schema.ts deleted file mode 100644 index e511a75ffa..0000000000 --- a/packages/opencode/src/project/schema.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Schema } from "effect" - -import { withStatics } from "@opencode-ai/core/schema" - -const projectIdSchema = Schema.String.pipe(Schema.brand("ProjectID")) - -export type ProjectID = typeof projectIdSchema.Type - -export const ProjectID = projectIdSchema.pipe( - withStatics((schema: typeof projectIdSchema) => ({ - global: schema.make("global"), - })), -) diff --git a/packages/opencode/src/project/vcs.ts b/packages/opencode/src/project/vcs.ts index d2b5729dd4..69e3ca9aa1 100644 --- a/packages/opencode/src/project/vcs.ts +++ b/packages/opencode/src/project/vcs.ts @@ -1,11 +1,11 @@ import { Effect, Layer, Context, Schema, Stream, Scope } from "effect" import { formatPatch, structuredPatch } from "diff" -import { Bus } from "@/bus" -import { BusEvent } from "@/bus/bus-event" import { InstanceState } from "@/effect/instance-state" import { FileWatcher } from "@/file/watcher" import { Git } from "@/git" import * as Log from "@opencode-ai/core/util/log" +import { EventV2Bridge } from "@/event-v2-bridge" +import { EventV2 } from "@opencode-ai/core/event" const log = Log.create({ service: "vcs" }) const PATCH_CONTEXT_LINES = 2_147_483_647 @@ -239,12 +239,12 @@ export const Mode = Schema.Literals(["git", "branch"]) export type Mode = Schema.Schema.Type export const Event = { - BranchUpdated: BusEvent.define( - "vcs.branch.updated", - Schema.Struct({ + BranchUpdated: EventV2.define({ + type: "vcs.branch.updated", + schema: { branch: Schema.optional(Schema.String), - }), - ), + }, + }), } export const Info = Schema.Struct({ @@ -305,11 +305,11 @@ interface State { export class Service extends Context.Service()("@opencode/Vcs") {} -export const layer: Layer.Layer = Layer.effect( +export const layer: Layer.Layer = Layer.effect( Service, Effect.gen(function* () { const git = yield* Git.Service - const bus = yield* Bus.Service + const events = yield* EventV2Bridge.Service const scope = yield* Scope.Scope const state = yield* InstanceState.make( @@ -327,20 +327,20 @@ export const layer: Layer.Layer = Lay const value = { current, root } log.info("initialized", { branch: value.current, default_branch: value.root?.name }) - yield* (yield* bus.subscribe(FileWatcher.Event.Updated)).pipe( - Stream.filter((evt) => evt.properties.file.endsWith("HEAD")), - Stream.runForEach((_evt) => - Effect.gen(function* () { - const next = yield* get() - if (next !== value.current) { - log.info("branch changed", { from: value.current, to: next }) - value.current = next - yield* bus.publish(Event.BranchUpdated, { branch: next }) - } - }), - ), - Effect.forkScoped, - ) + const unsubscribe = yield* events.listen((event) => { + if (event.type !== FileWatcher.Event.Updated.type || event.location?.directory !== ctx.directory) return Effect.void + const data = event.data as EventV2.Data + if (!data.file.endsWith("HEAD")) return Effect.void + return Effect.gen(function* () { + const next = yield* get() + if (next !== value.current) { + log.info("branch changed", { from: value.current, to: next }) + value.current = next + yield* events.publish(Event.BranchUpdated, { branch: next }) + } + }) + }) + yield* Effect.addFinalizer(() => unsubscribe) return value }), @@ -429,6 +429,9 @@ export const layer: Layer.Layer = Lay }), ) -export const defaultLayer = layer.pipe(Layer.provide(Git.defaultLayer), Layer.provide(Bus.layer)) +export const defaultLayer = layer.pipe( + Layer.provide(Git.defaultLayer), + Layer.provide(EventV2Bridge.defaultLayer), +) export * as Vcs from "./vcs" diff --git a/packages/opencode/src/provider/auth.ts b/packages/opencode/src/provider/auth.ts index a304fec540..3dfc1aafe2 100644 --- a/packages/opencode/src/provider/auth.ts +++ b/packages/opencode/src/provider/auth.ts @@ -4,7 +4,7 @@ import { Auth } from "@/auth" import { InstanceState } from "@/effect/instance-state" import { optionalOmitUndefined } from "@opencode-ai/core/schema" import { Plugin } from "../plugin" -import { ProviderID } from "./schema" +import { ProviderV2 } from "@opencode-ai/core/provider" import { Array as Arr, Effect, Layer, Record, Result, Context, Schema } from "effect" const When = Schema.Struct({ @@ -65,11 +65,11 @@ export const CallbackInput = Schema.Struct({ export type CallbackInput = Schema.Schema.Type export class OauthMissing extends Schema.TaggedErrorClass()("ProviderAuthOauthMissing", { - providerID: ProviderID, + providerID: ProviderV2.ID, }) {} export class OauthCodeMissing extends Schema.TaggedErrorClass()("ProviderAuthOauthCodeMissing", { - providerID: ProviderID, + providerID: ProviderV2.ID, }) {} export class OauthCallbackFailed extends Schema.TaggedErrorClass()( @@ -90,15 +90,15 @@ export interface Interface { readonly methods: () => Effect.Effect readonly authorize: ( input: { - providerID: ProviderID + providerID: ProviderV2.ID } & AuthorizeInput, ) => Effect.Effect - readonly callback: (input: { providerID: ProviderID } & CallbackInput) => Effect.Effect + readonly callback: (input: { providerID: ProviderV2.ID } & CallbackInput) => Effect.Effect } interface State { - hooks: Record - pending: Map + hooks: Record + pending: Map } export class Service extends Context.Service()("@opencode/ProviderAuth") {} @@ -117,11 +117,11 @@ export const layer: Layer.Layer = hooks: Record.fromEntries( Arr.filterMap(plugins, (x) => x.auth?.provider !== undefined - ? Result.succeed([ProviderID.make(x.auth.provider), x.auth] as const) + ? Result.succeed([ProviderV2.ID.make(x.auth.provider), x.auth] as const) : Result.failVoid, ), ), - pending: new Map(), + pending: new Map(), } }), ) @@ -160,7 +160,7 @@ export const layer: Layer.Layer = }) const authorize = Effect.fn("ProviderAuth.authorize")(function* ( - input: { providerID: ProviderID } & AuthorizeInput, + input: { providerID: ProviderV2.ID } & AuthorizeInput, ) { const { hooks, pending } = yield* InstanceState.get(state) const method = hooks[input.providerID].methods[input.method] @@ -184,7 +184,7 @@ export const layer: Layer.Layer = } }) - const callback = Effect.fn("ProviderAuth.callback")(function* (input: { providerID: ProviderID } & CallbackInput) { + const callback = Effect.fn("ProviderAuth.callback")(function* (input: { providerID: ProviderV2.ID } & CallbackInput) { const pending = (yield* InstanceState.get(state)).pending const match = pending.get(input.providerID) if (!match) return yield* new OauthMissing({ providerID: input.providerID }) diff --git a/packages/opencode/src/provider/error.ts b/packages/opencode/src/provider/error.ts index 06b388b48d..0d5a5130c4 100644 --- a/packages/opencode/src/provider/error.ts +++ b/packages/opencode/src/provider/error.ts @@ -1,7 +1,7 @@ import { APICallError } from "ai" import { STATUS_CODES } from "http" import { iife } from "@/util/iife" -import type { ProviderID } from "./schema" +import type { ProviderV2 } from "@opencode-ai/core/provider" export class HeaderTimeoutError extends Error { public override readonly name = "ProviderHeaderTimeoutError" @@ -61,7 +61,7 @@ function isOverflow(message: string) { return /^4(00|13)\s*(status code)?\s*\(no body\)/i.test(message) } -function message(providerID: ProviderID, e: APICallError) { +function message(providerID: ProviderV2.ID, e: APICallError) { return iife(() => { const msg = e.message if (msg === "") { @@ -194,7 +194,7 @@ export type ParsedAPICallError = metadata?: Record } -export function parseAPICallError(input: { providerID: ProviderID; error: APICallError }): ParsedAPICallError { +export function parseAPICallError(input: { providerID: ProviderV2.ID; error: APICallError }): ParsedAPICallError { const m = message(input.providerID, input.error) const body = json(input.error.responseBody) if (isOverflow(m) || input.error.statusCode === 413 || body?.error?.code === "context_length_exceeded") { diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 49c582e0a9..468ee34ab0 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -25,7 +25,7 @@ import { AppFileSystem } from "@opencode-ai/core/filesystem" import { isRecord } from "@/util/record" import { optionalOmitUndefined } from "@opencode-ai/core/schema" import * as ProviderTransform from "./transform" -import { ModelID, ProviderID } from "./schema" +import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelStatus } from "./model-status" import { RuntimeFlags } from "@/effect/runtime-flags" import { ProviderError } from "./error" @@ -663,8 +663,8 @@ function custom(dep: CustomDep): Record { for (const m of result.models) { if (!input.models[m.id]) { models[m.id] = { - id: ModelID.make(m.id), - providerID: ProviderID.make("gitlab"), + id: ProviderV2.ModelID.make(m.id), + providerID: ProviderV2.ID.make("gitlab"), name: `Agent Platform (${m.name})`, family: "", api: { @@ -928,8 +928,8 @@ const ProviderLimit = Schema.Struct({ }) export const Model = Schema.Struct({ - id: ModelID, - providerID: ProviderID, + id: ProviderV2.ModelID, + providerID: ProviderV2.ID, api: ProviderApiInfo, name: Schema.String, family: optionalOmitUndefined(Schema.String), @@ -945,7 +945,7 @@ export const Model = Schema.Struct({ export type Model = Types.DeepMutable> export const Info = Schema.Struct({ - id: ProviderID, + id: ProviderV2.ID, name: Schema.String, source: Schema.Literals(["env", "config", "custom", "api"]), env: Schema.Array(Schema.String), @@ -985,8 +985,8 @@ export function defaultModelIDs()("ProviderModelNotFoundError", { - providerID: ProviderID, - modelID: ModelID, + providerID: ProviderV2.ID, + modelID: ProviderV2.ModelID, suggestions: Schema.optional(Schema.Array(Schema.String)), cause: Schema.optional(Schema.Defect), }) { @@ -996,7 +996,7 @@ export class ModelNotFoundError extends Schema.TaggedErrorClass()("ProviderInitError", { - providerID: ProviderID, + providerID: ProviderV2.ID, cause: Schema.optional(Schema.Defect), }) { static isInstance(input: unknown): input is InitError { @@ -1011,7 +1011,7 @@ export class NoProvidersError extends Schema.TaggedErrorClass( } export class NoModelsError extends Schema.TaggedErrorClass()("ProviderNoModelsError", { - providerID: ProviderID, + providerID: ProviderV2.ID, }) { static isInstance(input: unknown): input is NoModelsError { return input instanceof NoModelsError @@ -1022,22 +1022,22 @@ export type DefaultModelError = ModelNotFoundError | NoProvidersError | NoModels export type Error = ModelNotFoundError | InitError | NoProvidersError | NoModelsError export interface Interface { - readonly list: () => Effect.Effect> - readonly getProvider: (providerID: ProviderID) => Effect.Effect - readonly getModel: (providerID: ProviderID, modelID: ModelID) => Effect.Effect + readonly list: () => Effect.Effect> + readonly getProvider: (providerID: ProviderV2.ID) => Effect.Effect + readonly getModel: (providerID: ProviderV2.ID, modelID: ProviderV2.ModelID) => Effect.Effect readonly getLanguage: (model: Model) => Effect.Effect readonly closest: ( - providerID: ProviderID, + providerID: ProviderV2.ID, query: string[], - ) => Effect.Effect<{ providerID: ProviderID; modelID: string } | undefined> - readonly getSmallModel: (providerID: ProviderID) => Effect.Effect - readonly defaultModel: () => Effect.Effect<{ providerID: ProviderID; modelID: ModelID }, DefaultModelError> + ) => Effect.Effect<{ providerID: ProviderV2.ID; modelID: string } | undefined> + readonly getSmallModel: (providerID: ProviderV2.ID) => Effect.Effect + readonly defaultModel: () => Effect.Effect<{ providerID: ProviderV2.ID; modelID: ProviderV2.ModelID }, DefaultModelError> } interface State { models: Map - providers: Record - catalog: Record + providers: Record + catalog: Record sdk: Map modelLoaders: Record varsLoaders: Record @@ -1082,8 +1082,8 @@ function cost(c: ModelsDev.Model["cost"]): Model["cost"] { function fromModelsDevModel(provider: ModelsDev.Provider, model: ModelsDev.Model): Model { const base: Model = { - id: ModelID.make(model.id), - providerID: ProviderID.make(provider.id), + id: ProviderV2.ModelID.make(model.id), + providerID: ProviderV2.ID.make(provider.id), name: model.name, family: model.family, api: { @@ -1140,7 +1140,7 @@ export function fromModelsDevProvider(provider: ModelsDev.Provider): Info { const base = fromModelsDevModel(provider, model) models[id] = { ...base, - id: ModelID.make(id), + id: ProviderV2.ModelID.make(id), name: `${model.name} ${mode[0].toUpperCase()}${mode.slice(1)}`, cost: opts.cost ? mergeDeep(base.cost, cost(opts.cost)) : base.cost, options: opts.provider?.body @@ -1156,7 +1156,7 @@ export function fromModelsDevProvider(provider: ModelsDev.Provider): Info { } } return { - id: ProviderID.make(provider.id), + id: ProviderV2.ID.make(provider.id), source: "custom", name: provider.name, env: [...(provider.env ?? [])], @@ -1175,7 +1175,7 @@ function suggestionModelIDs(provider: Info | undefined, enableExperimentalModels }) } -function modelSuggestions(provider: Info | undefined, modelID: ModelID, enableExperimentalModels: boolean) { +function modelSuggestions(provider: Info | undefined, modelID: ProviderV2.ModelID, enableExperimentalModels: boolean) { const available = suggestionModelIDs(provider, enableExperimentalModels) const fuzzy = fuzzysort.go(modelID, available, { limit: 3, threshold: -10000 }).map((m) => m.target) if (fuzzy.length) return fuzzy @@ -1217,7 +1217,7 @@ export const layer = Layer.effect( const catalog = mapValues(modelsDev, fromModelsDevProvider) const database = mapValues(catalog, toPublicInfo) - const providers: Record = {} as Record + const providers: Record = {} as Record const languages = new Map() const modelLoaders: { [providerID: string]: CustomModelLoader @@ -1238,7 +1238,7 @@ export const layer = Layer.effect( log.info("init") - function mergeProvider(providerID: ProviderID, provider: Partial) { + function mergeProvider(providerID: ProviderV2.ID, provider: Partial) { const existing = providers[providerID] if (existing) { // @ts-expect-error @@ -1259,7 +1259,7 @@ export const layer = Layer.effect( const disabled = new Set(cfg.disabled_providers ?? []) const enabled = cfg.enabled_providers ? new Set(cfg.enabled_providers) : null - function isProviderAllowed(providerID: ProviderID): boolean { + function isProviderAllowed(providerID: ProviderV2.ID): boolean { if (enabled && !enabled.has(providerID)) return false if (disabled.has(providerID)) return false return true @@ -1270,7 +1270,7 @@ export const layer = Layer.effect( const models = p?.models if (!p || !models) continue - const providerID = ProviderID.make(p.id) + const providerID = ProviderV2.ID.make(p.id) if (disabled.has(providerID)) continue const provider = database[providerID] @@ -1284,7 +1284,7 @@ export const layer = Layer.effect( id, { ...model, - id: ModelID.make(id), + id: ProviderV2.ModelID.make(id), providerID, }, ]), @@ -1296,7 +1296,7 @@ export const layer = Layer.effect( for (const [providerID, provider] of configProviders) { const existing = database[providerID] const parsed: Info = { - id: ProviderID.make(providerID), + id: ProviderV2.ID.make(providerID), name: provider.name ?? existing?.name ?? providerID, env: provider.env ?? existing?.env ?? [], options: mergeDeep(existing?.options ?? {}, provider.options ?? {}), @@ -1319,7 +1319,7 @@ export const layer = Layer.effect( return existingModel?.name ?? modelID }) const parsedModel: Model = { - id: ModelID.make(modelID), + id: ProviderV2.ModelID.make(modelID), api: { id: apiID, npm: apiNpm, @@ -1327,7 +1327,7 @@ export const layer = Layer.effect( }, status: model.status ?? existingModel?.status ?? "active", name, - providerID: ProviderID.make(providerID), + providerID: ProviderV2.ID.make(providerID), capabilities: { temperature: model.temperature ?? existingModel?.capabilities.temperature ?? false, reasoning: model.reasoning ?? existingModel?.capabilities.reasoning ?? false, @@ -1389,7 +1389,7 @@ export const layer = Layer.effect( // load env const envs = yield* env.all() for (const [id, provider] of Object.entries(database)) { - const providerID = ProviderID.make(id) + const providerID = ProviderV2.ID.make(id) if (disabled.has(providerID)) continue const apiKey = provider.env.map((item) => envs[item]).find(Boolean) if (!apiKey) continue @@ -1402,7 +1402,7 @@ export const layer = Layer.effect( // load apikeys const auths = yield* auth.all().pipe(Effect.orDie) for (const [id, provider] of Object.entries(auths)) { - const providerID = ProviderID.make(id) + const providerID = ProviderV2.ID.make(id) if (disabled.has(providerID)) continue if (provider.type === "api") { mergeProvider(providerID, { @@ -1415,7 +1415,7 @@ export const layer = Layer.effect( // plugin auth loader - database now has entries for config providers for (const plugin of plugins) { if (!plugin.auth) continue - const providerID = ProviderID.make(plugin.auth.provider) + const providerID = ProviderV2.ID.make(plugin.auth.provider) if (disabled.has(providerID)) continue const stored = yield* auth.get(providerID).pipe(Effect.orDie) @@ -1434,7 +1434,7 @@ export const layer = Layer.effect( } for (const [id, fn] of Object.entries(custom(dep))) { - const providerID = ProviderID.make(id) + const providerID = ProviderV2.ID.make(id) if (disabled.has(providerID)) continue const data = database[providerID] if (!data) { @@ -1454,7 +1454,7 @@ export const layer = Layer.effect( // load config - re-apply with updated data for (const [id, provider] of configProviders) { - const providerID = ProviderID.make(id) + const providerID = ProviderV2.ID.make(id) const partial: Partial = { source: "config" } if (provider.env) partial.env = provider.env if (provider.name) partial.name = provider.name @@ -1462,7 +1462,7 @@ export const layer = Layer.effect( mergeProvider(providerID, partial) } - const gitlab = ProviderID.make("gitlab") + const gitlab = ProviderV2.ID.make("gitlab") if (discoveryLoaders[gitlab] && providers[gitlab] && isProviderAllowed(gitlab)) { yield* Effect.promise(async () => { try { @@ -1479,7 +1479,7 @@ export const layer = Layer.effect( } for (const [id, provider] of Object.entries(providers)) { - const providerID = ProviderID.make(id) + const providerID = ProviderV2.ID.make(id) if (!isProviderAllowed(providerID)) { delete providers[providerID] continue @@ -1493,10 +1493,10 @@ export const layer = Layer.effect( // These chat aliases are invalid for the special handling in the // built-in providers below, but custom providers may support them. (modelID === "gpt-5-chat-latest" && - (providerID === ProviderID.openai || - providerID === ProviderID.githubCopilot || - providerID === ProviderID.openrouter)) || - (providerID === ProviderID.openrouter && modelID === "openai/gpt-5-chat") + (providerID === ProviderV2.ID.openai || + providerID === ProviderV2.ID.githubCopilot || + providerID === ProviderV2.ID.openrouter)) || + (providerID === ProviderV2.ID.openrouter && modelID === "openai/gpt-5-chat") ) delete provider.models[modelID] if (model.status === "alpha" && !runtimeFlags.enableExperimentalModels) delete provider.models[modelID] @@ -1702,11 +1702,11 @@ export const layer = Layer.effect( } } - const getProvider = Effect.fn("Provider.getProvider")((providerID: ProviderID) => + const getProvider = Effect.fn("Provider.getProvider")((providerID: ProviderV2.ID) => InstanceState.use(state, (s) => s.providers[providerID]), ) - const getModel = Effect.fn("Provider.getModel")(function* (providerID: ProviderID, modelID: ModelID) { + const getModel = Effect.fn("Provider.getModel")(function* (providerID: ProviderV2.ID, modelID: ProviderV2.ModelID) { const s = yield* InstanceState.get(state) const provider = s.providers[providerID] if (!provider) { @@ -1756,7 +1756,7 @@ export const layer = Layer.effect( ) }) - const closest = Effect.fn("Provider.closest")(function* (providerID: ProviderID, query: string[]) { + const closest = Effect.fn("Provider.closest")(function* (providerID: ProviderV2.ID, query: string[]) { const s = yield* InstanceState.get(state) const provider = s.providers[providerID] if (!provider) return undefined @@ -1768,7 +1768,7 @@ export const layer = Layer.effect( return undefined }) - const getSmallModel = Effect.fn("Provider.getSmallModel")(function* (providerID: ProviderID) { + const getSmallModel = Effect.fn("Provider.getSmallModel")(function* (providerID: ProviderV2.ID) { const cfg = yield* config.get() if (cfg.small_model) { @@ -1798,7 +1798,7 @@ export const layer = Layer.effect( priority = ["gpt-5-mini", "claude-haiku-4.5", ...priority] } for (const item of priority) { - if (providerID === ProviderID.amazonBedrock) { + if (providerID === ProviderV2.ID.amazonBedrock) { const crossRegionPrefixes = ["global.", "us.", "eu."] const candidates = Object.keys(provider.models).filter((m) => m.includes(item)) @@ -1832,16 +1832,16 @@ export const layer = Layer.effect( const s = yield* InstanceState.get(state) const recent = yield* fs.readJson(path.join(Global.Path.state, "model.json")).pipe( - Effect.map((x): { providerID: ProviderID; modelID: ModelID }[] => { + Effect.map((x): { providerID: ProviderV2.ID; modelID: ProviderV2.ModelID }[] => { if (!isRecord(x) || !Array.isArray(x.recent)) return [] return x.recent.flatMap((item) => { if (!isRecord(item)) return [] if (typeof item.providerID !== "string") return [] if (typeof item.modelID !== "string") return [] - return [{ providerID: ProviderID.make(item.providerID), modelID: ModelID.make(item.modelID) }] + return [{ providerID: ProviderV2.ID.make(item.providerID), modelID: ProviderV2.ModelID.make(item.modelID) }] }) }), - Effect.catch(() => Effect.succeed([] as { providerID: ProviderID; modelID: ModelID }[])), + Effect.catch(() => Effect.succeed([] as { providerID: ProviderV2.ID; modelID: ProviderV2.ModelID }[])), ) for (const entry of recent) { const provider = s.providers[entry.providerID] @@ -1889,8 +1889,8 @@ export function sort(models: T[]) { export function parseModel(model: string) { const [providerID, ...rest] = model.split("/") return { - providerID: ProviderID.make(providerID), - modelID: ModelID.make(rest.join("/")), + providerID: ProviderV2.ID.make(providerID), + modelID: ProviderV2.ModelID.make(rest.join("/")), } } diff --git a/packages/opencode/src/provider/schema.ts b/packages/opencode/src/provider/schema.ts deleted file mode 100644 index db05b47843..0000000000 --- a/packages/opencode/src/provider/schema.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { Schema } from "effect" - -import { withStatics } from "@opencode-ai/core/schema" - -const providerIdSchema = Schema.String.pipe(Schema.brand("ProviderID")) - -export type ProviderID = typeof providerIdSchema.Type - -export const ProviderID = providerIdSchema.pipe( - withStatics((schema: typeof providerIdSchema) => ({ - // Well-known providers - opencode: schema.make("opencode"), - anthropic: schema.make("anthropic"), - openai: schema.make("openai"), - google: schema.make("google"), - googleVertex: schema.make("google-vertex"), - githubCopilot: schema.make("github-copilot"), - amazonBedrock: schema.make("amazon-bedrock"), - azure: schema.make("azure"), - openrouter: schema.make("openrouter"), - mistral: schema.make("mistral"), - gitlab: schema.make("gitlab"), - })), -) - -const modelIdSchema = Schema.String.pipe(Schema.brand("ModelID")) - -export type ModelID = typeof modelIdSchema.Type - -export const ModelID = modelIdSchema diff --git a/packages/opencode/src/pty/index.ts b/packages/opencode/src/pty/index.ts index 9905aa4f64..9816faffe8 100644 --- a/packages/opencode/src/pty/index.ts +++ b/packages/opencode/src/pty/index.ts @@ -1,5 +1,5 @@ -import { BusEvent } from "@/bus/bus-event" -import { Bus } from "@/bus" +import { EventV2Bridge } from "@/event-v2-bridge" +import { EventV2 } from "@opencode-ai/core/event" import { Config } from "@/config/config" import { InstanceState } from "@/effect/instance-state" import { EffectBridge } from "@/effect/bridge" @@ -96,10 +96,10 @@ export class NotFoundError extends Schema.TaggedErrorClass()("Pty }) {} export const Event = { - Created: BusEvent.define("pty.created", Schema.Struct({ info: Info })), - Updated: BusEvent.define("pty.updated", Schema.Struct({ info: Info })), - Exited: BusEvent.define("pty.exited", Schema.Struct({ id: PtyID, exitCode: NonNegativeInt })), - Deleted: BusEvent.define("pty.deleted", Schema.Struct({ id: PtyID })), + Created: EventV2.define({ type: "pty.created", schema: { info: Info } }), + Updated: EventV2.define({ type: "pty.updated", schema: { info: Info } }), + Exited: EventV2.define({ type: "pty.exited", schema: { id: PtyID, exitCode: NonNegativeInt } }), + Deleted: EventV2.define({ type: "pty.deleted", schema: { id: PtyID } }), } export interface Interface { @@ -126,7 +126,7 @@ export const layer = Layer.effect( Service, Effect.gen(function* () { const config = yield* Config.Service - const bus = yield* Bus.Service + const events = yield* EventV2Bridge.Service const plugin = yield* Plugin.Service function teardown(session: Active) { @@ -173,7 +173,7 @@ export const layer = Layer.effect( s.sessions.delete(id) log.info("removing session", { id }) teardown(session) - yield* bus.publish(Event.Deleted, { id: session.info.id }) + yield* events.publish(Event.Deleted, { id: session.info.id }) }) const list = Effect.fn("Pty.list")(function* () { @@ -269,10 +269,10 @@ export const layer = Layer.effect( if (session.info.status === "exited") return log.info("session exited", { id, exitCode }) session.info.status = "exited" - bridge.fork(bus.publish(Event.Exited, { id, exitCode })) + bridge.fork(events.publish(Event.Exited, { id, exitCode })) bridge.fork(remove(id)) }) - yield* bus.publish(Event.Created, { info }) + yield* events.publish(Event.Created, { info }) return info }) @@ -284,7 +284,7 @@ export const layer = Layer.effect( if (input.size) { session.process.resize(input.size.cols, input.size.rows) } - yield* bus.publish(Event.Updated, { info: session.info }) + yield* events.publish(Event.Updated, { info: session.info }) return session.info }) @@ -369,7 +369,7 @@ export const layer = Layer.effect( ) export const defaultLayer = layer.pipe( - Layer.provide(Bus.layer), + Layer.provide(EventV2Bridge.defaultLayer), Layer.provide(Plugin.defaultLayer), Layer.provide(Config.defaultLayer), ) diff --git a/packages/opencode/src/pty/ticket.ts b/packages/opencode/src/pty/ticket.ts index 0978e52083..cf6751fb18 100644 --- a/packages/opencode/src/pty/ticket.ts +++ b/packages/opencode/src/pty/ticket.ts @@ -1,6 +1,6 @@ export * as PtyTicket from "./ticket" -import { WorkspaceID } from "@/control-plane/schema" +import { WorkspaceV2 } from "@opencode-ai/core/workspace" import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref" import { PtyID } from "@/pty/schema" import { PositiveInt } from "@opencode-ai/core/schema" @@ -17,7 +17,7 @@ export const ConnectToken = Schema.Struct({ export type Scope = { readonly ptyID: PtyID readonly directory?: string - readonly workspaceID?: WorkspaceID + readonly workspaceID?: WorkspaceV2.ID } export interface Interface { diff --git a/packages/opencode/src/question/index.ts b/packages/opencode/src/question/index.ts index e03af848b0..051ae7afd6 100644 --- a/packages/opencode/src/question/index.ts +++ b/packages/opencode/src/question/index.ts @@ -1,10 +1,10 @@ import { Deferred, Effect, Layer, Schema, Context } from "effect" -import { Bus } from "@/bus" -import { BusEvent } from "@/bus/bus-event" import { InstanceState } from "@/effect/instance-state" import { SessionID, MessageID } from "@/session/schema" import * as Log from "@opencode-ai/core/util/log" import { QuestionID } from "./schema" +import { EventV2Bridge } from "@/event-v2-bridge" +import { EventV2 } from "@opencode-ai/core/event" const log = Log.create({ service: "question" }) @@ -87,9 +87,9 @@ const Rejected = Schema.Struct({ }).annotate({ identifier: "QuestionRejected" }) export const Event = { - Asked: BusEvent.define("question.asked", Request), - Replied: BusEvent.define("question.replied", Replied), - Rejected: BusEvent.define("question.rejected", Rejected), + Asked: EventV2.define({ type: "question.asked", schema: Request.fields }), + Replied: EventV2.define({ type: "question.replied", schema: Replied.fields }), + Rejected: EventV2.define({ type: "question.rejected", schema: Rejected.fields }), } export class RejectedError extends Schema.TaggedErrorClass()("QuestionRejectedError", {}) { @@ -132,7 +132,7 @@ export class Service extends Context.Service()("@opencode/Qu export const layer = Layer.effect( Service, Effect.gen(function* () { - const bus = yield* Bus.Service + const events = yield* EventV2Bridge.Service const state = yield* InstanceState.make( Effect.fn("Question.state")(function* () { const state = { @@ -169,7 +169,7 @@ export const layer = Layer.effect( tool: input.tool, } pending.set(id, { info, deferred }) - yield* bus.publish(Event.Asked, info) + yield* events.publish(Event.Asked, info) return yield* Effect.ensuring( Deferred.await(deferred), @@ -191,7 +191,7 @@ export const layer = Layer.effect( } pending.delete(input.requestID) log.info("replied", { requestID: input.requestID, answers: input.answers }) - yield* bus.publish(Event.Replied, { + yield* events.publish(Event.Replied, { sessionID: existing.info.sessionID, requestID: existing.info.id, answers: input.answers.map((a) => [...a]), @@ -208,7 +208,7 @@ export const layer = Layer.effect( } pending.delete(requestID) log.info("rejected", { requestID }) - yield* bus.publish(Event.Rejected, { + yield* events.publish(Event.Rejected, { sessionID: existing.info.sessionID, requestID: existing.info.id, }) @@ -224,6 +224,6 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer.pipe(Layer.provide(Bus.layer)) +export const defaultLayer = layer.pipe(Layer.provide(EventV2Bridge.defaultLayer)) export * as Question from "." diff --git a/packages/opencode/src/server/event.ts b/packages/opencode/src/server/event.ts index d5f10f47db..f7b657da33 100644 --- a/packages/opencode/src/server/event.ts +++ b/packages/opencode/src/server/event.ts @@ -1,7 +1,6 @@ -import { BusEvent } from "@/bus/bus-event" -import { Schema } from "effect" +import { EventV2 } from "@opencode-ai/core/event" export const Event = { - Connected: BusEvent.define("server.connected", Schema.Struct({})), - Disposed: BusEvent.define("global.disposed", Schema.Struct({})), + Connected: EventV2.define({ type: "server.connected", schema: {} }), + Disposed: EventV2.define({ type: "global.disposed", schema: {} }), } diff --git a/packages/opencode/src/server/projectors.ts b/packages/opencode/src/server/projectors.ts index c5fb2420a0..2ded2c2cd1 100644 --- a/packages/opencode/src/server/projectors.ts +++ b/packages/opencode/src/server/projectors.ts @@ -1,26 +1,2 @@ -import sessionProjectors from "../session/projectors" -import { SyncEvent } from "@/sync" -import { Session } from "@/session/session" -import { SessionTable } from "@/session/session.sql" -import { Database } from "@/storage/db" -import { eq } from "drizzle-orm" - export function initProjectors() { - SyncEvent.init({ - projectors: sessionProjectors, - convertEvent: (type, data) => { - if (type === "session.updated") { - const id = (data as SyncEvent.Event["data"]).sessionID - const row = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, id)).get()) - - if (!row) return data - - return { - sessionID: id, - info: Session.fromRow(row), - } - } - return data - }, - }) } diff --git a/packages/opencode/src/server/routes/instance/httpapi/api.ts b/packages/opencode/src/server/routes/instance/httpapi/api.ts index eff336b3c6..0f0b695ea8 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/api.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/api.ts @@ -1,7 +1,6 @@ import { Schema } from "effect" import { HttpApi } from "effect/unstable/httpapi" -import { BusEvent } from "@/bus/bus-event" -import { SyncEvent } from "@/sync" +import { EventV2 } from "@opencode-ai/core/event" import { ConfigApi } from "./groups/config" import { ControlApi } from "./groups/control" import { EventApi } from "./groups/event" @@ -23,9 +22,18 @@ import { V2Api } from "./groups/v2" import { Authorization } from "./middleware/authorization" import { SchemaErrorMiddleware } from "./middleware/schema-error" -// SSE event schemas built from the BusEvent/SyncEvent registries. -const EventSchema = Schema.Union(BusEvent.effectPayloads()).annotate({ identifier: "Event" }) -const SyncEventSchemas = SyncEvent.effectPayloads() +const EventSchema = Schema.Union( + EventV2.registry + .values() + .map((definition) => + Schema.Struct({ + id: Schema.String, + type: Schema.Literal(definition.type), + properties: definition.data, + }).annotate({ identifier: `Event.${definition.type}` }), + ) + .toArray(), +).annotate({ identifier: "Event" }) export const RootHttpApi = HttpApi.make("opencode-root") .addHttpApi(ControlApi) @@ -56,7 +64,7 @@ export const OpenCodeHttpApi = HttpApi.make("opencode") .addHttpApi(EventApi) .addHttpApi(InstanceHttpApi) .addHttpApi(PtyConnectApi) - .annotate(HttpApi.AdditionalSchemas, [EventSchema, ...SyncEventSchemas]) + .annotate(HttpApi.AdditionalSchemas, [EventSchema]) export type RootHttpApiType = typeof RootHttpApi export type InstanceHttpApiType = typeof InstanceHttpApi diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/control.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/control.ts index 33e6a8e4a0..49f43f0154 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/control.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/control.ts @@ -1,11 +1,12 @@ import { Auth } from "@/auth" -import { ProviderID } from "@/provider/schema" + import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { described } from "./metadata" +import { ProviderV2 } from "@opencode-ai/core/provider" const AuthParams = Schema.Struct({ - providerID: ProviderID, + providerID: ProviderV2.ID, }) const LogQuery = Schema.Struct({ diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts index 90be9f218f..c40a3bf006 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts @@ -1,6 +1,6 @@ import { AccountID, OrgID } from "@/account/schema" import { MCP } from "@/mcp" -import { ProviderID, ModelID } from "@/provider/schema" + import { Session } from "@/session/session" import { Worktree } from "@/worktree" import { NonNegativeInt } from "@opencode-ai/core/schema" @@ -15,6 +15,7 @@ import { } from "../middleware/workspace-routing" import { described } from "./metadata" import { QueryBoolean } from "./query" +import { ProviderV2 } from "@opencode-ai/core/provider" const ConsoleStateResponse = Schema.Struct({ consoleManagedProviders: Schema.mutable(Schema.Array(Schema.String)), @@ -49,8 +50,8 @@ const ToolListItem = Schema.Struct({ const ToolList = Schema.Array(ToolListItem).annotate({ identifier: "ToolList" }) export const ToolListQuery = Schema.Struct({ ...WorkspaceRoutingQueryFields, - provider: ProviderID, - model: ModelID, + provider: ProviderV2.ID, + model: ProviderV2.ModelID, }) const WorktreeList = Schema.Array(Schema.String) diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/global.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/global.ts index f50fd3351e..b7a50962dd 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/global.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/global.ts @@ -1,6 +1,5 @@ import { Config } from "@/config/config" -import { BusEvent } from "@/bus/bus-event" -import { SyncEvent } from "@/sync" +import { EventV2 } from "@opencode-ai/core/event" import "@/server/event" import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" @@ -15,7 +14,14 @@ const GlobalEventSchema = Schema.Struct({ directory: Schema.String, project: Schema.optional(Schema.String), workspace: Schema.optional(Schema.String), - payload: Schema.Union([...BusEvent.effectPayloads(), ...SyncEvent.effectPayloads()]), + payload: Schema.Union( + EventV2.registry + .values() + .map((definition) => + Schema.Struct({ id: Schema.String, type: Schema.Literal(definition.type), properties: definition.data }), + ) + .toArray(), + ), }).annotate({ identifier: "GlobalEvent" }) export const GlobalUpgradeInput = Schema.Struct({ diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/project.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/project.ts index b7be4044fc..c6b2fab40a 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/project.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/project.ts @@ -1,5 +1,5 @@ import { Project } from "@/project/project" -import { ProjectID } from "@/project/schema" +import { ProjectV2 } from "@opencode-ai/core/project" import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { ProjectNotFoundError } from "../errors" @@ -50,7 +50,7 @@ export const ProjectApi = HttpApi.make("project") }), ), HttpApiEndpoint.patch("update", `${root}/:projectID`, { - params: { projectID: ProjectID }, + params: { projectID: ProjectV2.ID }, query: WorkspaceRoutingQuery, payload: UpdatePayload, success: described(Project.Info, "Updated project information"), diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/provider.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/provider.ts index 0d8e49022b..3a9ae0c6d3 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/provider.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/provider.ts @@ -1,12 +1,13 @@ import { ProviderAuth } from "@/provider/auth" import { Provider } from "@/provider/provider" -import { ProviderID } from "@/provider/schema" + import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../middleware/authorization" import { InstanceContextMiddleware } from "../middleware/instance-context" import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing" import { described } from "./metadata" +import { ProviderV2 } from "@opencode-ai/core/provider" const root = "/provider" @@ -21,7 +22,7 @@ export class ProviderAuthApiError extends Schema.ErrorClass Effect.gen(function* () { const auth = yield* Auth.Service const authSet = Effect.fn("ControlHttpApi.authSet")(function* (ctx: { - params: { providerID: ProviderID } + params: { providerID: ProviderV2.ID } payload: Auth.Info }) { yield* auth.set(ctx.params.providerID, ctx.payload).pipe(Effect.orDie) return true }) - const authRemove = Effect.fn("ControlHttpApi.authRemove")(function* (ctx: { params: { providerID: ProviderID } }) { + const authRemove = Effect.fn("ControlHttpApi.authRemove")(function* (ctx: { params: { providerID: ProviderV2.ID } }) { yield* auth.remove(ctx.params.providerID).pipe(Effect.orDie) return true }) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/event.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/event.ts index e770a7cfba..edf50927ad 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/event.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/event.ts @@ -1,6 +1,9 @@ -import { Bus } from "@/bus" +import { EventV2Bridge } from "@/event-v2-bridge" +import { InstanceState } from "@/effect/instance-state" +import { GlobalBus } from "@/bus/global" +import { EventV2 } from "@opencode-ai/core/event" import * as Log from "@opencode-ai/core/util/log" -import { Effect } from "effect" +import { Effect, Queue } from "effect" import * as Stream from "effect/Stream" import { HttpServerResponse } from "effect/unstable/http" import { HttpApiBuilder } from "effect/unstable/httpapi" @@ -18,24 +21,51 @@ function eventData(data: unknown): Sse.Event { } } -function eventResponse(bus: Bus.Interface) { +function eventID() { + return EventV2.ID.create() +} + +function eventResponse(events: EventV2.Interface) { return Effect.gen(function* () { - // Subscribe eagerly: the bus subscription is acquired in the request scope - // at this yield, so any publish from now on is queued for the body-pump - // fiber to drain — closing the race where Stream.concat(server.connected, - // lazy-subscribe) used to drop publishes in the prefix-consume window. - const events = (yield* bus.subscribeAll()).pipe( - Stream.takeUntil((event) => event.type === Bus.InstanceDisposed.type), + const instance = yield* InstanceState.context + const workspaceID = yield* InstanceState.workspaceID + // Listener registration is eager, so events published after this point cannot + // be lost while the HTTP body fiber is starting or emitting server.connected. + const queue = yield* Queue.unbounded() + const unsubscribe = yield* events.listen((event) => Effect.sync(() => Queue.offerUnsafe(queue, event))) + yield* Effect.addFinalizer(() => unsubscribe) + const stream = Stream.fromQueue(queue).pipe( + Stream.filter( + (event) => + event.location?.directory === instance.directory && + (event.location.workspaceID === undefined || event.location.workspaceID === workspaceID), + ), + Stream.map((event) => ({ id: event.id, type: event.type, properties: event.data })), ) + const disposed = Stream.callback<{ id: string; type: string; properties: unknown }>((queue) => { + const listener = (event: { directory?: string; payload: { id?: string; type?: string; properties?: unknown } }) => { + if (event.directory !== instance.directory || event.payload.type !== "server.instance.disposed") return + Queue.offerUnsafe(queue, { + id: event.payload.id ?? eventID(), + type: "server.instance.disposed", + properties: event.payload.properties ?? {}, + }) + } + return Effect.acquireRelease( + Effect.sync(() => GlobalBus.on("event", listener)), + () => Effect.sync(() => GlobalBus.off("event", listener)), + ) + }) + const output = stream.pipe(Stream.merge(disposed, { haltStrategy: "left" }), Stream.takeUntil((event) => event.type === "server.instance.disposed")) const heartbeat = Stream.tick("10 seconds").pipe( Stream.drop(1), - Stream.map(() => ({ id: Bus.createID(), type: "server.heartbeat", properties: {} })), + Stream.map(() => ({ id: eventID(), type: "server.heartbeat", properties: {} })), ) log.info("event connected") return HttpServerResponse.stream( - Stream.make({ id: Bus.createID(), type: "server.connected", properties: {} }).pipe( - Stream.concat(events.pipe(Stream.merge(heartbeat, { haltStrategy: "left" }))), + Stream.make({ id: eventID(), type: "server.connected", properties: {} }).pipe( + Stream.concat(output.pipe(Stream.merge(heartbeat, { haltStrategy: "left" }))), Stream.map(eventData), Stream.pipeThroughChannel(Sse.encode()), Stream.encodeText, @@ -55,11 +85,11 @@ function eventResponse(bus: Bus.Interface) { export const eventHandlers = HttpApiBuilder.group(EventApi, "event", (handlers) => Effect.gen(function* () { - const bus = yield* Bus.Service + const events = yield* EventV2Bridge.Service return handlers.handleRaw( "subscribe", Effect.fn("EventHttpApi.subscribe")(function* () { - return yield* eventResponse(bus) + return yield* eventResponse(events) }), ) }), diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts index 4061bec298..e995c21602 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts @@ -29,6 +29,7 @@ export const experimentalHandlers = HttpApiBuilder.group(InstanceHttpApi, "exper const project = yield* Project.Service const registry = yield* ToolRegistry.Service const worktreeSvc = yield* Worktree.Service + const sessions = yield* Session.Service const getConsole = Effect.fn("ExperimentalHttpApi.console")(function* () { const [state, groups] = yield* Effect.all( @@ -127,21 +128,19 @@ export const experimentalHandlers = HttpApiBuilder.group(InstanceHttpApi, "exper const session = Effect.fn("ExperimentalHttpApi.session")(function* (ctx: { query: typeof SessionListQuery.Type }) { const limit = ctx.query.limit ?? 100 - const sessions = Array.from( - Session.listGlobal({ - directory: ctx.query.directory, - roots: ctx.query.roots, - start: ctx.query.start, - cursor: ctx.query.cursor, - search: ctx.query.search, - limit: limit + 1, - archived: ctx.query.archived, - }), - ) - const list = sessions.length > limit ? sessions.slice(0, limit) : sessions + const all = yield* sessions.listGlobal({ + directory: ctx.query.directory, + roots: ctx.query.roots, + start: ctx.query.start, + cursor: ctx.query.cursor, + search: ctx.query.search, + limit: limit + 1, + archived: ctx.query.archived, + }) + const list = all.length > limit ? all.slice(0, limit) : all return HttpServerResponse.jsonUnsafe(list, { headers: - sessions.length > limit && list.length > 0 + all.length > limit && list.length > 0 ? { "x-next-cursor": String(list[list.length - 1].time.updated) } : undefined, }) 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 f80869b64d..a63a9a958f 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/global.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/global.ts @@ -1,7 +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 { EventV2 } from "@opencode-ai/core/event" import { Installation } from "@/installation" import { disposeAllInstancesAndEmitGlobalDisposed } from "@/server/global-lifecycle" import { InstallationVersion } from "@opencode-ai/core/installation/version" @@ -44,11 +44,11 @@ function eventResponse() { }) const heartbeat = Stream.tick("10 seconds").pipe( Stream.drop(1), - Stream.map(() => ({ payload: { id: Bus.createID(), type: "server.heartbeat", properties: {} } })), + Stream.map(() => ({ payload: { id: EventV2.ID.create(), type: "server.heartbeat", properties: {} } })), ) return HttpServerResponse.stream( - Stream.make({ payload: { id: Bus.createID(), type: "server.connected", properties: {} } }).pipe( + Stream.make({ payload: { id: EventV2.ID.create(), 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/project.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/project.ts index 1b61204c4c..8b4fc608fb 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/project.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/project.ts @@ -1,6 +1,6 @@ import * as InstanceState from "@/effect/instance-state" import { Project } from "@/project/project" -import { ProjectID } from "@/project/schema" +import { ProjectV2 } from "@opencode-ai/core/project" import { Effect } from "effect" import { HttpApiBuilder } from "effect/unstable/httpapi" import { InstanceHttpApi } from "../api" @@ -33,7 +33,7 @@ export const projectHandlers = HttpApiBuilder.group(InstanceHttpApi, "project", }) const update = Effect.fn("ProjectHttpApi.update")(function* (ctx: { - params: { projectID: ProjectID } + params: { projectID: ProjectV2.ID } payload: Project.UpdatePayload }) { return yield* svc.update({ ...ctx.payload, projectID: ctx.params.projectID }).pipe( diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts index b9766ca97b..e1377b6f75 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts @@ -2,13 +2,14 @@ import { ProviderAuth } from "@/provider/auth" import { Config } from "@/config/config" import { ModelsDev } from "@opencode-ai/core/models-dev" import { Provider } from "@/provider/provider" -import { ProviderID } from "@/provider/schema" + import { mapValues } from "remeda" import { Effect, Schema } from "effect" import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http" import { HttpApiBuilder } from "effect/unstable/httpapi" import { InstanceHttpApi } from "../api" import { ProviderAuthApiError } from "../groups/provider" +import { ProviderV2 } from "@opencode-ai/core/provider" function mapProviderAuthError(self: Effect.Effect) { return self.pipe( @@ -62,7 +63,7 @@ export const providerHandlers = HttpApiBuilder.group(InstanceHttpApi, "provider" }) const authorize = Effect.fn("ProviderHttpApi.authorize")(function* (ctx: { - params: { providerID: ProviderID } + params: { providerID: ProviderV2.ID } payload: ProviderAuth.AuthorizeInput }) { return yield* mapProviderAuthError( @@ -75,7 +76,7 @@ export const providerHandlers = HttpApiBuilder.group(InstanceHttpApi, "provider" }) const authorizeRaw = Effect.fn("ProviderHttpApi.authorizeRaw")(function* (ctx: { - params: { providerID: ProviderID } + params: { providerID: ProviderV2.ID } request: HttpServerRequest.HttpServerRequest }) { const body = yield* Effect.orDie(ctx.request.text) @@ -90,7 +91,7 @@ export const providerHandlers = HttpApiBuilder.group(InstanceHttpApi, "provider" }) const callback = Effect.fn("ProviderHttpApi.callback")(function* (ctx: { - params: { providerID: ProviderID } + params: { providerID: ProviderV2.ID } payload: ProviderAuth.CallbackInput }) { yield* mapProviderAuthError( diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts index 0d4b9ff989..773fb41236 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts @@ -1,5 +1,6 @@ import { Agent } from "@/agent/agent" -import { Bus } from "@/bus" +import { SessionLegacy } from "@opencode-ai/core/session/legacy" +import { EventV2Bridge } from "@/event-v2-bridge" import { Command } from "@/command" import { Permission } from "@/permission" import { PermissionID } from "@/permission/schema" @@ -56,7 +57,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", const statusSvc = yield* SessionStatus.Service const todoSvc = yield* Todo.Service const summary = yield* SessionSummary.Service - const bus = yield* Bus.Service + const events = yield* EventV2Bridge.Service const scope = yield* Scope.Scope const list = Effect.fn("SessionHttpApi.list")(function* (ctx: { query: typeof ListQuery.Type }) { @@ -316,7 +317,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", yield* Effect.logError("prompt_async failed").pipe( Effect.annotateLogs({ sessionID: ctx.params.sessionID, cause }), ) - yield* bus.publish(Session.Event.Error, { + yield* events.publish(Session.Event.Error, { sessionID: ctx.params.sessionID, error: new NamedError.Unknown({ message: Cause.pretty(cause) }).toObject(), }) @@ -395,10 +396,10 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", const updatePart = Effect.fn("SessionHttpApi.updatePart")(function* (ctx: { params: { sessionID: SessionID; messageID: MessageID; partID: PartID } - payload: typeof MessageV2.Part.Type + payload: typeof SessionLegacy.Part.Type }) { yield* requireSession(ctx.params.sessionID) - const payload = ctx.payload as MessageV2.Part + const payload = ctx.payload as SessionLegacy.Part if ( payload.id !== ctx.params.partID || payload.messageID !== ctx.params.messageID || diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/sync.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/sync.ts index ffe8d0baa4..5269f35469 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/sync.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/sync.ts @@ -1,9 +1,10 @@ import { Workspace } from "@/control-plane/workspace" import * as InstanceState from "@/effect/instance-state" import { Session } from "@/session/session" -import { Database } from "@/storage/db" -import { SyncEvent } from "@/sync" -import { EventTable } from "@/sync/event.sql" +import { Database } from "@opencode-ai/core/database/database" +import { EventV2 } from "@opencode-ai/core/event" +import { EventV2Bridge } from "@/event-v2-bridge" +import { EventTable } from "@opencode-ai/core/event/sql" import { asc } from "drizzle-orm" import { and } from "drizzle-orm" import { eq } from "drizzle-orm" @@ -21,8 +22,10 @@ const log = Log.create({ service: "server.sync" }) export const syncHandlers = HttpApiBuilder.group(InstanceHttpApi, "sync", (handlers) => Effect.gen(function* () { const workspace = yield* Workspace.Service + const session = yield* Session.Service const scope = yield* Scope.Scope - const sync = yield* SyncEvent.Service + const events = yield* EventV2Bridge.Service + const { db } = yield* Database.Service const start = Effect.fn("SyncHttpApi.start")(function* () { yield* workspace @@ -32,27 +35,27 @@ export const syncHandlers = HttpApiBuilder.group(InstanceHttpApi, "sync", (handl }) const replay = Effect.fn("SyncHttpApi.replay")(function* (ctx: { payload: typeof ReplayPayload.Type }) { - const events: SyncEvent.SerializedEvent[] = ctx.payload.events.map((event) => ({ - id: event.id, + const payload: EventV2.SerializedEvent[] = ctx.payload.events.map((event) => ({ + id: EventV2.ID.make(event.id), aggregateID: event.aggregateID, seq: event.seq, type: event.type, data: { ...event.data }, })) - const source = events[0].aggregateID + const source = payload[0].aggregateID log.info("sync replay requested", { sessionID: source, - events: events.length, - first: events[0]?.seq, - last: events.at(-1)?.seq, + events: payload.length, + first: payload[0]?.seq, + last: payload.at(-1)?.seq, directory: ctx.payload.directory, }) - yield* sync.replayAll(events) + yield* events.replayAll(payload) log.info("sync replay complete", { sessionID: source, - events: events.length, - first: events[0]?.seq, - last: events.at(-1)?.seq, + events: payload.length, + first: payload[0]?.seq, + last: payload.at(-1)?.seq, }) return { sessionID: source } }) @@ -61,12 +64,7 @@ export const syncHandlers = HttpApiBuilder.group(InstanceHttpApi, "sync", (handl const workspaceID = yield* InstanceState.workspaceID if (!workspaceID) return yield* new HttpApiError.BadRequest({}) - yield* sync.run(Session.Event.Updated, { - sessionID: ctx.payload.sessionID, - info: { - workspaceID, - }, - }) + yield* session.setWorkspace({ sessionID: ctx.payload.sessionID, workspaceID }) log.info("sync session stolen", { sessionID: ctx.payload.sessionID, @@ -78,18 +76,17 @@ export const syncHandlers = HttpApiBuilder.group(InstanceHttpApi, "sync", (handl const history = Effect.fn("SyncHttpApi.history")(function* (ctx: { payload: typeof HistoryPayload.Type }) { const exclude = Object.entries(ctx.payload) - return Database.use((db) => - db - .select() - .from(EventTable) - .where( - exclude.length > 0 - ? not(or(...exclude.map(([id, seq]) => and(eq(EventTable.aggregate_id, id), lte(EventTable.seq, seq))))!) - : undefined, - ) - .orderBy(asc(EventTable.seq)) - .all(), - ) + return yield* db + .select() + .from(EventTable) + .where( + exclude.length > 0 + ? not(or(...exclude.map(([id, seq]) => and(eq(EventTable.aggregate_id, id), lte(EventTable.seq, seq))))!) + : undefined, + ) + .orderBy(asc(EventTable.seq)) + .all() + .pipe(Effect.orDie) }) return handlers.handle("start", start).handle("replay", replay).handle("steal", steal).handle("history", history) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/tui.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/tui.ts index 0ecebf451f..31ecd5effb 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/tui.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/tui.ts @@ -1,4 +1,4 @@ -import { Bus } from "@/bus" +import { EventV2Bridge } from "@/event-v2-bridge" import { TuiEvent } from "@/cli/cmd/tui/event" import { Session } from "@/session/session" import { Effect } from "effect" @@ -26,15 +26,15 @@ const commandAliases = { export const tuiHandlers = HttpApiBuilder.group(InstanceHttpApi, "tui", (handlers) => Effect.gen(function* () { - const bus = yield* Bus.Service + const events = yield* EventV2Bridge.Service const session = yield* Session.Service - const publishCommand = (command: typeof TuiEvent.CommandExecute.properties.Type.command | undefined) => - bus.publish(TuiEvent.CommandExecute, { command } as typeof TuiEvent.CommandExecute.properties.Type) + const publishCommand = (command: typeof TuiEvent.CommandExecute.data.Type.command | undefined) => + events.publish(TuiEvent.CommandExecute, { command } as typeof TuiEvent.CommandExecute.data.Type) const appendPrompt = Effect.fn("TuiHttpApi.appendPrompt")(function* (ctx: { - payload: typeof TuiEvent.PromptAppend.properties.Type + payload: typeof TuiEvent.PromptAppend.data.Type }) { - yield* bus.publish(TuiEvent.PromptAppend, ctx.payload) + yield* events.publish(TuiEvent.PromptAppend, ctx.payload) return true }) @@ -77,29 +77,29 @@ export const tuiHandlers = HttpApiBuilder.group(InstanceHttpApi, "tui", (handler }) const showToast = Effect.fn("TuiHttpApi.showToast")(function* (ctx: { - payload: typeof TuiEvent.ToastShow.properties.Type + payload: typeof TuiEvent.ToastShow.data.Type }) { - yield* bus.publish(TuiEvent.ToastShow, ctx.payload) + yield* events.publish(TuiEvent.ToastShow, ctx.payload) return true }) const publish = Effect.fn("TuiHttpApi.publish")(function* (ctx: { payload: typeof TuiPublishPayload.Type }) { if (ctx.payload.type === TuiEvent.PromptAppend.type) - yield* bus.publish(TuiEvent.PromptAppend, ctx.payload.properties) + yield* events.publish(TuiEvent.PromptAppend, ctx.payload.properties) if (ctx.payload.type === TuiEvent.CommandExecute.type) - yield* bus.publish(TuiEvent.CommandExecute, ctx.payload.properties) - if (ctx.payload.type === TuiEvent.ToastShow.type) yield* bus.publish(TuiEvent.ToastShow, ctx.payload.properties) + yield* events.publish(TuiEvent.CommandExecute, ctx.payload.properties) + if (ctx.payload.type === TuiEvent.ToastShow.type) yield* events.publish(TuiEvent.ToastShow, ctx.payload.properties) if (ctx.payload.type === TuiEvent.SessionSelect.type) - yield* bus.publish(TuiEvent.SessionSelect, ctx.payload.properties) + yield* events.publish(TuiEvent.SessionSelect, ctx.payload.properties) return true }) const selectSession = Effect.fn("TuiHttpApi.selectSession")(function* (ctx: { - payload: typeof TuiEvent.SessionSelect.properties.Type + payload: typeof TuiEvent.SessionSelect.data.Type }) { if (!ctx.payload.sessionID.startsWith("ses")) return yield* new HttpApiError.BadRequest({}) yield* SessionError.mapStorageNotFound(session.get(ctx.payload.sessionID)) - yield* bus.publish(TuiEvent.SessionSelect, ctx.payload) + yield* events.publish(TuiEvent.SessionSelect, ctx.payload) return true }) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2.ts index daa799b7a8..0514ea56a3 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2.ts @@ -1,4 +1,4 @@ -import { SessionV2 } from "@/v2/session" +import { SessionV2 } from "@opencode-ai/core/session" import { Layer } from "effect" import { layer as v2LocationLayer } from "../groups/v2/location" import { messageHandlers } from "./v2/message" 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 index 0d9273d8cd..c9cfe33bc8 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/message.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/message.ts @@ -1,5 +1,5 @@ -import { SessionMessage } from "@opencode-ai/core/session-message" -import { SessionV2 } from "@/v2/session" +import { SessionMessage } from "@opencode-ai/core/session/message" +import { SessionV2 } from "@opencode-ai/core/session" import { Effect, Schema } from "effect" import * as DateTime from "effect/DateTime" import { HttpApiBuilder } from "effect/unstable/httpapi" 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 index ff4e098fb4..f6f1263352 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/session.ts @@ -1,5 +1,6 @@ -import { WorkspaceID } from "@/control-plane/schema" -import { SessionV2 } from "@/v2/session" +import { WorkspaceV2 } from "@opencode-ai/core/workspace" +import { SessionV2 } from "@opencode-ai/core/session" +import { AbsolutePath } from "@opencode-ai/core/schema" import { DateTime, Effect, Option, Schema } from "effect" import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi" import { InstanceHttpApi } from "../../api" @@ -20,7 +21,7 @@ const SessionCursor = Schema.Struct({ 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), + workspaceID: WorkspaceV2.ID.pipe(Schema.optional), roots: Schema.Boolean.pipe(Schema.optional), start: Schema.Finite.pipe(Schema.optional), search: Schema.String.pipe(Schema.optional), @@ -78,7 +79,7 @@ const sessionCursor = { function decodeWorkspaceID(input: string | undefined) { if (input === undefined) return Effect.succeed(undefined) - const workspaceID = Schema.decodeUnknownOption(WorkspaceID)(input) + const workspaceID = Schema.decodeUnknownOption(WorkspaceV2.ID)(input) if (Option.isSome(workspaceID)) return Effect.succeed(workspaceID.value) return Effect.fail( new InvalidRequestError({ @@ -114,17 +115,21 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.session start: ctx.query.start, search: ctx.query.search, } - const sessions = yield* session.list({ + const input = { 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 sessions = yield* session.list( + filters.directory + ? { + ...input, + directory: AbsolutePath.make(filters.directory), + } + : input, + ) const first = sessions[0] const last = sessions.at(-1) return { @@ -168,7 +173,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.session .handle( "compact", Effect.fn(function* (ctx) { - yield* session.compact(ctx.params.sessionID).pipe( + yield* session.compact({ sessionID: ctx.params.sessionID }).pipe( Effect.catchTag("Session.NotFoundError", (error) => Effect.fail( new SessionNotFoundError({ diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/fence.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/fence.ts index f3bfe06689..c5cbc7b820 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/middleware/fence.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/fence.ts @@ -1,20 +1,25 @@ import { Flag } from "@opencode-ai/core/flag/flag" +import { Database } from "@opencode-ai/core/database/database" import { Effect } from "effect" import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" import * as Fence from "@/server/shared/fence" const ignoredMethods = new Set(["GET", "HEAD", "OPTIONS"]) -export const fenceLayer = HttpRouter.middleware<{ handles: unknown }>()((effect) => +export const fenceLayer = HttpRouter.middleware<{ requires: Database.Service; handles: unknown }>()( Effect.gen(function* () { - const request = yield* HttpServerRequest.HttpServerRequest - if (!Flag.OPENCODE_WORKSPACE_ID || ignoredMethods.has(request.method)) return yield* effect + const { db } = yield* Database.Service + return (effect) => + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest + if (!Flag.OPENCODE_WORKSPACE_ID || ignoredMethods.has(request.method)) return yield* effect - const previous = Fence.load() - const response = yield* effect - const current = Fence.diff(previous, Fence.load()) - if (Object.keys(current).length === 0) return response + const previous = yield* Fence.load(db) + const response = yield* effect + const current = Fence.diff(previous, yield* Fence.load(db)) + if (Object.keys(current).length === 0) return response - return HttpServerResponse.setHeader(response, Fence.HEADER, JSON.stringify(current)) + return HttpServerResponse.setHeader(response, Fence.HEADER, JSON.stringify(current)) + }) }), ).layer diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts index 66fe1e12e1..873abd8349 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts @@ -1,4 +1,4 @@ -import { WorkspaceID } from "@/control-plane/schema" +import { WorkspaceV2 } from "@opencode-ai/core/workspace" import type { Target } from "@/control-plane/types" import { Workspace } from "@/control-plane/workspace" import { WorkspaceAdapterRuntime } from "@/control-plane/workspace-adapter-runtime" @@ -30,8 +30,8 @@ type RemoteTarget = Extract type RequestPlan = Data.TaggedEnum<{ InvalidWorkspace: {} - MissingWorkspace: { readonly workspaceID: WorkspaceID } - Local: { readonly directory: string; readonly workspaceID?: WorkspaceID } + MissingWorkspace: { readonly workspaceID: WorkspaceV2.ID } + Local: { readonly directory: string; readonly workspaceID?: WorkspaceV2.ID } Remote: { readonly request: HttpServerRequest.HttpServerRequest readonly workspace: Workspace.Info @@ -46,7 +46,7 @@ export class WorkspaceRouteContext extends Context.Service< WorkspaceRouteContext, { readonly directory: string - readonly workspaceID?: WorkspaceID + readonly workspaceID?: WorkspaceV2.ID } >()("@opencode/ExperimentalHttpApiWorkspaceRouteContext") {} @@ -62,23 +62,23 @@ function requestURL(request: HttpServerRequest.HttpServerRequest): URL { return new URL(request.url, "http://localhost") } -function configuredWorkspaceID(): WorkspaceID | undefined { - return Flag.OPENCODE_WORKSPACE_ID ? WorkspaceID.make(Flag.OPENCODE_WORKSPACE_ID) : undefined +function configuredWorkspaceID(): WorkspaceV2.ID | undefined { + return Flag.OPENCODE_WORKSPACE_ID ? WorkspaceV2.ID.make(Flag.OPENCODE_WORKSPACE_ID) : undefined } -function selectedWorkspaceID(url: URL, sessionWorkspaceID?: WorkspaceID): WorkspaceID | undefined { +function selectedWorkspaceID(url: URL, sessionWorkspaceID?: WorkspaceV2.ID): WorkspaceV2.ID | undefined { const workspaceParam = url.searchParams.get("workspace") - return sessionWorkspaceID ?? (workspaceParam ? WorkspaceID.make(workspaceParam) : undefined) + return sessionWorkspaceID ?? (workspaceParam ? WorkspaceV2.ID.make(workspaceParam) : undefined) } function selectedV2WorkspaceID( url: URL, - sessionWorkspaceID?: WorkspaceID, -): WorkspaceID | typeof InvalidWorkspaceID | undefined { + sessionWorkspaceID?: WorkspaceV2.ID, +): WorkspaceV2.ID | typeof InvalidWorkspaceID | undefined { if (sessionWorkspaceID) return sessionWorkspaceID const workspaceParam = url.searchParams.get("workspace") if (!workspaceParam) return undefined - const workspaceID = Schema.decodeUnknownOption(WorkspaceID)(workspaceParam) + const workspaceID = Schema.decodeUnknownOption(WorkspaceV2.ID)(workspaceParam) if (Option.isNone(workspaceID)) return InvalidWorkspaceID return workspaceID.value } @@ -92,14 +92,14 @@ function shouldStayOnControlPlane(request: HttpServerRequest.HttpServerRequest, } function resolveWorkspace( - id: WorkspaceID | undefined, - envWorkspaceID: WorkspaceID | undefined, + id: WorkspaceV2.ID | undefined, + envWorkspaceID: WorkspaceV2.ID | undefined, ): Effect.Effect { if (!id || envWorkspaceID) return Effect.void return Workspace.Service.use((workspace) => workspace.get(id)) } -function missingWorkspaceResponse(id: WorkspaceID): HttpServerResponse.HttpServerResponse { +function missingWorkspaceResponse(id: WorkspaceV2.ID): HttpServerResponse.HttpServerResponse { return HttpServerResponse.text(`Workspace not found: ${id}`, { status: 500, contentType: "text/plain; charset=utf-8", diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index 45a71a7bf3..12761ab7ff 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -13,7 +13,6 @@ import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Account } from "@/account/account" import { Agent } from "@/agent/agent" import { Auth } from "@/auth" -import { Bus } from "@/bus" import { Config } from "@/config/config" import { Command } from "@/command" import * as Observability from "@opencode-ai/core/effect/observability" @@ -46,9 +45,9 @@ import { Todo } from "@/session/todo" import { SessionShare } from "@/share/session" import { ShareNext } from "@/share/share-next" import { EventV2Bridge } from "@/event-v2-bridge" +import { Database } from "@opencode-ai/core/database/database" import { Skill } from "@/skill" import { Snapshot } from "@/snapshot" -import { SyncEvent } from "@/sync" import { ToolRegistry } from "@/tool/registry" import { lazy } from "@/util/lazy" import { Vcs } from "@/project/vcs" @@ -191,8 +190,9 @@ export function createRoutes( errorLayer, compressionLayer, corsVaryFix, - fenceLayer, + fenceLayer.pipe(Layer.provide(Database.defaultLayer)), cors(corsOptions), + Database.defaultLayer, Account.defaultLayer, Agent.defaultLayer, Auth.defaultLayer, @@ -225,7 +225,6 @@ export function createRoutes( SessionSummary.defaultLayer, ShareNext.defaultLayer, Snapshot.defaultLayer, - SyncEvent.defaultLayer, EventV2Bridge.defaultLayer, Skill.defaultLayer, Todo.defaultLayer, @@ -233,7 +232,6 @@ export function createRoutes( Vcs.defaultLayer, Workspace.defaultLayer, Worktree.appLayer, - Bus.layer, AppFileSystem.defaultLayer, FetchHttpClient.layer, HttpServer.layerServices, diff --git a/packages/opencode/src/server/shared/fence.ts b/packages/opencode/src/server/shared/fence.ts index 770e4588bf..d01f15d218 100644 --- a/packages/opencode/src/server/shared/fence.ts +++ b/packages/opencode/src/server/shared/fence.ts @@ -1,8 +1,8 @@ -import { Database } from "@/storage/db" +import { Database } from "@opencode-ai/core/database/database" import { inArray } from "drizzle-orm" -import { EventSequenceTable } from "@/sync/event.sql" +import { EventSequenceTable } from "@opencode-ai/core/event/sql" import { Workspace } from "@/control-plane/workspace" -import type { WorkspaceID } from "@/control-plane/schema" +import type { WorkspaceV2 } from "@opencode-ai/core/workspace" import * as Log from "@opencode-ai/core/util/log" import { Effect } from "effect" @@ -10,16 +10,16 @@ export const HEADER = "x-opencode-sync" export type State = Record const log = Log.create({ service: "fence" }) -export function load(ids?: string[]) { - const rows = Database.use((db) => { - if (!ids?.length) { - return db.select().from(EventSequenceTable).all() - } +export function load(db: Database.Interface["db"], ids?: string[]) { + return Effect.gen(function* () { + const rows = yield* ( + ids?.length + ? db.select().from(EventSequenceTable).where(inArray(EventSequenceTable.aggregate_id, ids)).all() + : db.select().from(EventSequenceTable).all() + ).pipe(Effect.orDie) - return db.select().from(EventSequenceTable).where(inArray(EventSequenceTable.aggregate_id, ids)).all() + return Object.fromEntries(rows.map((row) => [row.aggregate_id, row.seq])) }) - - return Object.fromEntries(rows.map((row) => [row.aggregate_id, row.seq])) } export function diff(prev: State, next: State) { @@ -53,7 +53,7 @@ export function parse(headers: Headers): State | undefined { ) } -export function wait(workspaceID: WorkspaceID, state: State, signal?: AbortSignal) { +export function wait(workspaceID: WorkspaceV2.ID, state: State, signal?: AbortSignal) { return Effect.gen(function* () { log.info("waiting for state", { workspaceID, diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index 4f87edf64a..c20cf7e072 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -1,5 +1,4 @@ -import { BusEvent } from "@/bus/bus-event" -import { Bus } from "@/bus" +import { SessionLegacy } from "@opencode-ai/core/session/legacy" import * as Session from "./session" import { SessionID, MessageID, PartID } from "./schema" import { Provider } from "@/provider/provider" @@ -11,7 +10,7 @@ import { Agent } from "@/agent/agent" import { Plugin } from "@/plugin" 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" @@ -19,17 +18,19 @@ import { isOverflow as overflow, usable } from "./overflow" import { serviceUse } from "@opencode-ai/core/effect/service-use" import { RuntimeFlags } from "@/effect/runtime-flags" import { EventV2Bridge } from "@/event-v2-bridge" -import { SessionEvent } from "@opencode-ai/core/session-event" +import { SessionEvent } from "@opencode-ai/core/session/event" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { EventV2 } from "@opencode-ai/core/event" const log = Log.create({ service: "session.compaction" }) export const Event = { - Compacted: BusEvent.define( - "session.compacted", - Schema.Struct({ + Compacted: EventV2.define({ + type: "session.compacted", + schema: { sessionID: SessionID, - }), - ), + }, + }), } export const PRUNE_MINIMUM = 20_000 @@ -92,9 +93,9 @@ type CompletedCompaction = { summary: string | undefined } -function summaryText(message: MessageV2.WithParts) { +function summaryText(message: SessionLegacy.WithParts) { const text = message.parts - .filter((part): part is MessageV2.TextPart => part.type === "text") + .filter((part): part is SessionLegacy.TextPart => part.type === "text") .map((part) => part.text.trim()) .filter(Boolean) .join("\n\n") @@ -102,7 +103,7 @@ function summaryText(message: MessageV2.WithParts) { return text || undefined } -function completedCompactions(messages: MessageV2.WithParts[]) { +function completedCompactions(messages: SessionLegacy.WithParts[]) { const users = new Map() for (let i = 0; i < messages.length; i++) { const msg = messages[i] @@ -140,7 +141,7 @@ function preserveRecentBudget(input: { cfg: Config.Info; model: Provider.Model } ) } -function turns(messages: MessageV2.WithParts[]) { +function turns(messages: SessionLegacy.WithParts[]) { const result: Turn[] = [] for (let i = 0; i < messages.length; i++) { const msg = messages[i] @@ -159,11 +160,11 @@ function turns(messages: MessageV2.WithParts[]) { } function splitTurn(input: { - messages: MessageV2.WithParts[] + messages: SessionLegacy.WithParts[] turn: Turn model: Provider.Model budget: number - estimate: (input: { messages: MessageV2.WithParts[]; model: Provider.Model }) => Effect.Effect + estimate: (input: { messages: SessionLegacy.WithParts[]; model: Provider.Model }) => Effect.Effect }) { return Effect.gen(function* () { if (input.budget <= 0) return undefined @@ -185,13 +186,13 @@ function splitTurn(input: { export interface Interface { readonly isOverflow: (input: { - tokens: MessageV2.Assistant["tokens"] + tokens: SessionLegacy.Assistant["tokens"] model: Provider.Model }) => Effect.Effect readonly prune: (input: { sessionID: SessionID }) => Effect.Effect readonly process: (input: { parentID: MessageID - messages: MessageV2.WithParts[] + messages: SessionLegacy.WithParts[] sessionID: SessionID auto: boolean overflow?: boolean @@ -199,7 +200,7 @@ export interface Interface { readonly create: (input: { sessionID: SessionID agent: string - model: { providerID: ProviderID; modelID: ModelID } + model: { providerID: ProviderV2.ID; modelID: ProviderV2.ModelID } auto: boolean overflow?: boolean }) => Effect.Effect @@ -212,7 +213,6 @@ export const use = serviceUse(Service) export const layer = Layer.effect( Service, Effect.gen(function* () { - const bus = yield* Bus.Service const config = yield* Config.Service const session = yield* Session.Service const agents = yield* Agent.Service @@ -223,7 +223,7 @@ export const layer = Layer.effect( const flags = yield* RuntimeFlags.Service const isOverflow = Effect.fn("SessionCompaction.isOverflow")(function* (input: { - tokens: MessageV2.Assistant["tokens"] + tokens: SessionLegacy.Assistant["tokens"] model: Provider.Model }) { return overflow({ @@ -235,7 +235,7 @@ export const layer = Layer.effect( }) const estimate = Effect.fn("SessionCompaction.estimate")(function* (input: { - messages: MessageV2.WithParts[] + messages: SessionLegacy.WithParts[] model: Provider.Model }) { const msgs = yield* MessageV2.toModelMessagesEffect(input.messages, input.model) @@ -243,7 +243,7 @@ export const layer = Layer.effect( }) const select = Effect.fn("SessionCompaction.select")(function* (input: { - messages: MessageV2.WithParts[] + messages: SessionLegacy.WithParts[] cfg: Config.Info model: Provider.Model }) { @@ -307,7 +307,7 @@ export const layer = Layer.effect( let total = 0 let pruned = 0 - const toPrune: MessageV2.ToolPart[] = [] + const toPrune: SessionLegacy.ToolPart[] = [] let turns = 0 loop: for (let msgIndex = msgs.length - 1; msgIndex >= 0; msgIndex--) { @@ -343,7 +343,7 @@ export const layer = Layer.effect( const processCompaction = Effect.fn("SessionCompaction.process")(function* (input: { parentID: MessageID - messages: MessageV2.WithParts[] + messages: SessionLegacy.WithParts[] sessionID: SessionID auto: boolean overflow?: boolean @@ -353,13 +353,13 @@ export const layer = Layer.effect( throw new Error(`Compaction parent must be a user message: ${input.parentID}`) } const userMessage = parent.info - const compactionPart = parent.parts.find((part): part is MessageV2.CompactionPart => part.type === "compaction") + const compactionPart = parent.parts.find((part): part is SessionLegacy.CompactionPart => part.type === "compaction") let messages = input.messages let replay: | { - info: MessageV2.User - parts: MessageV2.Part[] + info: SessionLegacy.User + parts: SessionLegacy.Part[] } | undefined if (input.overflow) { @@ -408,7 +408,7 @@ export const layer = Layer.effect( toolOutputMaxChars: TOOL_OUTPUT_MAX_CHARS, }) const ctx = yield* InstanceState.context - const msg: MessageV2.Assistant = { + const msg: SessionLegacy.Assistant = { id: MessageID.ascending(), role: "assistant", parentID: input.parentID, @@ -457,7 +457,7 @@ export const layer = Layer.effect( }) if (result === "compact") { - processor.message.error = new MessageV2.ContextOverflowError({ + processor.message.error = new SessionLegacy.ContextOverflowError({ message: replay ? "Conversation history too large to compact - exceeds model context limit" : "Session too large to compact - context exceeds model limit even after stripping media", @@ -576,7 +576,7 @@ export const layer = Layer.effect( include: selected.tail_start_id, }) } - yield* bus.publish(Event.Compacted, { sessionID: input.sessionID }) + yield* events.publish(Event.Compacted, { sessionID: input.sessionID }) } return result }) @@ -584,7 +584,7 @@ export const layer = Layer.effect( const create = Effect.fn("SessionCompaction.create")(function* (input: { sessionID: SessionID agent: string - model: { providerID: ProviderID; modelID: ModelID } + model: { providerID: ProviderV2.ID; modelID: ProviderV2.ModelID } auto: boolean overflow?: boolean }) { @@ -629,7 +629,6 @@ export const defaultLayer = Layer.suspend(() => Layer.provide(SessionProcessor.defaultLayer), Layer.provide(Agent.defaultLayer), Layer.provide(Plugin.defaultLayer), - Layer.provide(Bus.layer), Layer.provide(Config.defaultLayer), Layer.provide(RuntimeFlags.defaultLayer), Layer.provide(EventV2Bridge.defaultLayer), diff --git a/packages/opencode/src/session/instruction.ts b/packages/opencode/src/session/instruction.ts index ad9a74445b..855c58ba5c 100644 --- a/packages/opencode/src/session/instruction.ts +++ b/packages/opencode/src/session/instruction.ts @@ -1,4 +1,5 @@ import path from "path" +import { SessionLegacy } from "@opencode-ai/core/session/legacy" import { Effect, Layer, Context } from "effect" import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http" import { Config } from "@/config/config" @@ -17,7 +18,7 @@ const files = (disableClaudeCodePrompt: boolean) => [ "CONTEXT.md", // deprecated ] -function extract(messages: MessageV2.WithParts[]) { +function extract(messages: SessionLegacy.WithParts[]) { const paths = new Set() for (const msg of messages) { for (const part of msg.parts) { @@ -40,7 +41,7 @@ export interface Interface { readonly system: () => Effect.Effect readonly find: (dir: string) => Effect.Effect readonly resolve: ( - messages: MessageV2.WithParts[], + messages: SessionLegacy.WithParts[], filepath: string, messageID: MessageID, ) => Effect.Effect<{ filepath: string; content: string }[], AppFileSystem.Error> @@ -176,7 +177,7 @@ export const layer: Layer.Layer< }) const resolve = Effect.fn("Instruction.resolve")(function* ( - messages: MessageV2.WithParts[], + messages: SessionLegacy.WithParts[], filepath: string, messageID: MessageID, ) { @@ -231,7 +232,7 @@ export const defaultLayer = layer.pipe( Layer.provide(RuntimeFlags.defaultLayer), ) -export function loaded(messages: MessageV2.WithParts[]) { +export function loaded(messages: SessionLegacy.WithParts[]) { return extract(messages) } diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index ea2efc99d0..0ea62147c3 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -1,4 +1,5 @@ import { Provider } from "@/provider/provider" +import { SessionLegacy } from "@opencode-ai/core/session/legacy" import { serviceUse } from "@opencode-ai/core/effect/service-use" import * as Log from "@opencode-ai/core/util/log" import { Context, Effect, Layer } from "effect" @@ -15,7 +16,8 @@ import type { MessageV2 } from "./message-v2" import { Plugin } from "@/plugin" import { Permission } from "@/permission" import { PermissionID } from "@/permission/schema" -import { Bus } from "@/bus" +import { EventV2Bridge } from "@/event-v2-bridge" +import { EventV2 } from "@opencode-ai/core/event" import { Wildcard } from "@/util/wildcard" import { SessionID } from "@/session/schema" import { Auth } from "@/auth" @@ -31,7 +33,7 @@ const log = Log.create({ service: "llm" }) export const OUTPUT_TOKEN_MAX = ProviderTransform.OUTPUT_TOKEN_MAX export type StreamInput = { - user: MessageV2.User + user: SessionLegacy.User sessionID: string parentSessionID?: string model: Provider.Model @@ -65,6 +67,7 @@ const live: Layer.Layer< | Provider.Service | Plugin.Service | Permission.Service + | EventV2Bridge.Service | LLMClientService | RuntimeFlags.Service > = Layer.effect( @@ -75,6 +78,7 @@ const live: Layer.Layer< const provider = yield* Provider.Service const plugin = yield* Plugin.Service const perm = yield* Permission.Service + const events = yield* EventV2Bridge.Service const llmClient = yield* LLMClient.Service const flags = yield* RuntimeFlags.Service @@ -162,11 +166,15 @@ const live: Layer.Layer< } const id = PermissionID.ascending() - let unsub: (() => void) | undefined + let unsub: EventV2.Unsubscribe | undefined try { - unsub = Bus.subscribe(Permission.Event.Replied, (evt) => { - if (evt.properties.requestID === id) void evt.properties.reply - }) + unsub = await bridge.promise(events.listen((event) => { + if (event.type !== Permission.Event.Replied.type) return Effect.void + const data = event.data as EventV2.Data + if (data.requestID !== id) return Effect.void + void data.reply + return Effect.void + })) const toolPatterns = approvalTools.map((t: { name: string; args: string }) => { try { const parsed = JSON.parse(t.args) as Record @@ -194,7 +202,7 @@ const live: Layer.Layer< } catch { return { approved: false } } finally { - unsub?.() + if (unsub) await bridge.promise(unsub) } }) } @@ -370,7 +378,7 @@ const live: Layer.Layer< }), ) -export const layer = live.pipe(Layer.provide(Permission.defaultLayer)) +export const layer = live.pipe(Layer.provide(Permission.defaultLayer), Layer.provide(EventV2Bridge.defaultLayer)) export const defaultLayer = Layer.suspend(() => layer.pipe( diff --git a/packages/opencode/src/session/llm/request.ts b/packages/opencode/src/session/llm/request.ts index 3471342405..60847dab3f 100644 --- a/packages/opencode/src/session/llm/request.ts +++ b/packages/opencode/src/session/llm/request.ts @@ -1,4 +1,5 @@ import type { Auth } from "@/auth" +import { SessionLegacy } from "@opencode-ai/core/session/legacy" import type { RuntimeFlags } from "@/effect/runtime-flags" import { InstanceState } from "@/effect/instance-state" import { Permission } from "@/permission" @@ -16,7 +17,7 @@ import { mergeDeep } from "remeda" const USER_AGENT = `opencode/${InstallationVersion}` type PrepareInput = { - readonly user: MessageV2.User + readonly user: SessionLegacy.User readonly sessionID: string readonly parentSessionID?: string readonly model: Provider.Model diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 68157f5f4d..209b1e05d9 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -1,11 +1,27 @@ -import { BusEvent } from "@/bus/bus-event" +import { EventV2 } from "@opencode-ai/core/event" import { SessionID, MessageID, PartID } from "./schema" +import { SessionLegacy } from "@opencode-ai/core/session/legacy" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { + APIError, + AbortedError, + Assistant, + AuthError, + CompactionPart, + ContextOverflowError, + Info, + OutputLengthError, + Part, + StructuredOutputError, + SubtaskPart, + User, + WithParts, + type ToolPart, +} from "@opencode-ai/core/session/legacy" + import { NamedError } from "@opencode-ai/core/util/error" import { APICallError, convertToModelMessages, LoadAPIKeyError, type ModelMessage, type UIMessage } from "ai" -import { LSP } from "@/lsp/lsp" -import { Snapshot } from "@/snapshot" -import { SyncEvent } from "../sync" -import { Database } from "@/storage/db" +import { Database } from "@opencode-ai/core/database/database" import { NotFoundError } from "@/storage/storage" import { and } from "drizzle-orm" import { desc } from "drizzle-orm" @@ -13,20 +29,15 @@ import { eq } from "drizzle-orm" import { inArray } from "drizzle-orm" import { lt } from "drizzle-orm" import { or } from "drizzle-orm" -import { MessageTable, PartTable, SessionTable } from "./session.sql" +import { MessageTable, PartTable, SessionTable } from "@opencode-ai/core/session/sql" import * as ProviderError from "@/provider/error" import { iife } from "@/util/iife" import { errorMessage } from "@/util/error" import { isMedia } from "@/util/media" import type { SystemError } from "bun" import type { Provider } from "@/provider/provider" -import { ModelID, ProviderID } from "@/provider/schema" -import { Effect, Schema, Types } from "effect" -import { NonNegativeInt } from "@opencode-ai/core/schema" +import { Effect, Schema } from "effect" import * as EffectLogger from "@opencode-ai/core/effect/logger" -import { MessageError } from "./message-error" -import { AuthError, OutputLengthError } from "./message-error" -export { AuthError, OutputLengthError } from "./message-error" /** Error shape thrown by Bun's fetch() when gzip/br decompression fails mid-stream */ interface FetchDecompressionError extends Error { @@ -38,526 +49,27 @@ interface FetchDecompressionError extends Error { export const SYNTHETIC_ATTACHMENT_PROMPT = "Attached media from tool result:" export { isMedia } -export const AbortedError = NamedError.create("MessageAbortedError", { message: Schema.String }) -export const StructuredOutputError = NamedError.create("StructuredOutputError", { - message: Schema.String, - retries: NonNegativeInt, -}) -export const APIError = NamedError.create("APIError", { - message: Schema.String, - statusCode: Schema.optional(NonNegativeInt), - isRetryable: Schema.Boolean, - responseHeaders: Schema.optional(Schema.Record(Schema.String, Schema.String)), - responseBody: Schema.optional(Schema.String), - metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)), -}) -export type APIError = Schema.Schema.Type -export const ContextOverflowError = NamedError.create("ContextOverflowError", { - message: Schema.String, - responseBody: Schema.optional(Schema.String), -}) - -export class OutputFormatText extends Schema.Class("OutputFormatText")({ - type: Schema.Literal("text"), -}) {} - -export class OutputFormatJsonSchema extends Schema.Class("OutputFormatJsonSchema")({ - type: Schema.Literal("json_schema"), - schema: Schema.Record(Schema.String, Schema.Any).annotate({ identifier: "JSONSchema" }), - retryCount: NonNegativeInt.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed(2))), -}) {} - -export const Format = Schema.Union([OutputFormatText, OutputFormatJsonSchema]).annotate({ - discriminator: "type", - identifier: "OutputFormat", -}) -export type OutputFormat = Schema.Schema.Type - -const partBase = { - id: PartID, - sessionID: SessionID, - messageID: MessageID, -} - -export const SnapshotPart = Schema.Struct({ - ...partBase, - type: Schema.Literal("snapshot"), - snapshot: Schema.String, -}).annotate({ identifier: "SnapshotPart" }) -export type SnapshotPart = Types.DeepMutable> - -export const PatchPart = Schema.Struct({ - ...partBase, - type: Schema.Literal("patch"), - hash: Schema.String, - files: Schema.Array(Schema.String), -}).annotate({ identifier: "PatchPart" }) -export type PatchPart = Types.DeepMutable> - -export const TextPart = Schema.Struct({ - ...partBase, - type: Schema.Literal("text"), - text: Schema.String, - synthetic: Schema.optional(Schema.Boolean), - ignored: Schema.optional(Schema.Boolean), - time: Schema.optional( - Schema.Struct({ - start: NonNegativeInt, - end: Schema.optional(NonNegativeInt), - }), - ), - metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)), -}).annotate({ identifier: "TextPart" }) -export type TextPart = Types.DeepMutable> - -export const ReasoningPart = Schema.Struct({ - ...partBase, - type: Schema.Literal("reasoning"), - text: Schema.String, - metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)), - time: Schema.Struct({ - start: NonNegativeInt, - end: Schema.optional(NonNegativeInt), - }), -}).annotate({ identifier: "ReasoningPart" }) -export type ReasoningPart = Types.DeepMutable> - -const filePartSourceBase = { - text: Schema.Struct({ - value: Schema.String, - start: Schema.Finite, - end: Schema.Finite, - }).annotate({ identifier: "FilePartSourceText" }), -} - -export const FileSource = Schema.Struct({ - ...filePartSourceBase, - type: Schema.Literal("file"), - path: Schema.String, -}).annotate({ identifier: "FileSource" }) - -export const SymbolSource = Schema.Struct({ - ...filePartSourceBase, - type: Schema.Literal("symbol"), - path: Schema.String, - range: LSP.Range, - name: Schema.String, - kind: NonNegativeInt, -}).annotate({ identifier: "SymbolSource" }) - -export const ResourceSource = Schema.Struct({ - ...filePartSourceBase, - type: Schema.Literal("resource"), - clientName: Schema.String, - uri: Schema.String, -}).annotate({ identifier: "ResourceSource" }) - -export const FilePartSource = Schema.Union([FileSource, SymbolSource, ResourceSource]).annotate({ - discriminator: "type", - identifier: "FilePartSource", -}) - -export const FilePart = Schema.Struct({ - ...partBase, - type: Schema.Literal("file"), - mime: Schema.String, - filename: Schema.optional(Schema.String), - url: Schema.String, - source: Schema.optional(FilePartSource), -}).annotate({ identifier: "FilePart" }) -export type FilePart = Types.DeepMutable> - -export const AgentPart = Schema.Struct({ - ...partBase, - type: Schema.Literal("agent"), - name: Schema.String, - source: Schema.optional( - Schema.Struct({ - value: Schema.String, - start: NonNegativeInt, - end: NonNegativeInt, - }), - ), -}).annotate({ identifier: "AgentPart" }) -export type AgentPart = Types.DeepMutable> - -export const CompactionPart = Schema.Struct({ - ...partBase, - type: Schema.Literal("compaction"), - auto: Schema.Boolean, - overflow: Schema.optional(Schema.Boolean), - tail_start_id: Schema.optional(MessageID), -}).annotate({ identifier: "CompactionPart" }) -export type CompactionPart = Types.DeepMutable> - -export const SubtaskPart = Schema.Struct({ - ...partBase, - type: Schema.Literal("subtask"), - prompt: Schema.String, - description: Schema.String, - agent: Schema.String, - model: Schema.optional( - Schema.Struct({ - providerID: ProviderID, - modelID: ModelID, - }), - ), - command: Schema.optional(Schema.String), -}).annotate({ identifier: "SubtaskPart" }) -export type SubtaskPart = Types.DeepMutable> - -export const RetryPart = Schema.Struct({ - ...partBase, - type: Schema.Literal("retry"), - attempt: NonNegativeInt, - error: APIError.EffectSchema, - time: Schema.Struct({ - created: NonNegativeInt, - }), -}).annotate({ identifier: "RetryPart" }) -export type RetryPart = Omit>, "error"> & { - error: APIError -} - -export const StepStartPart = Schema.Struct({ - ...partBase, - type: Schema.Literal("step-start"), - snapshot: Schema.optional(Schema.String), -}).annotate({ identifier: "StepStartPart" }) -export type StepStartPart = Types.DeepMutable> - -export const StepFinishPart = Schema.Struct({ - ...partBase, - type: Schema.Literal("step-finish"), - reason: Schema.String, - snapshot: Schema.optional(Schema.String), - cost: Schema.Finite, - tokens: Schema.Struct({ - total: Schema.optional(Schema.Finite), - input: Schema.Finite, - output: Schema.Finite, - reasoning: Schema.Finite, - cache: Schema.Struct({ - read: Schema.Finite, - write: Schema.Finite, - }), - }), -}).annotate({ identifier: "StepFinishPart" }) -export type StepFinishPart = Types.DeepMutable> - -export const ToolStatePending = Schema.Struct({ - status: Schema.Literal("pending"), - input: Schema.Record(Schema.String, Schema.Any), - raw: Schema.String, -}).annotate({ identifier: "ToolStatePending" }) -export type ToolStatePending = Types.DeepMutable> - -export const ToolStateRunning = Schema.Struct({ - status: Schema.Literal("running"), - input: Schema.Record(Schema.String, Schema.Any), - title: Schema.optional(Schema.String), - metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)), - time: Schema.Struct({ - start: NonNegativeInt, - }), -}).annotate({ identifier: "ToolStateRunning" }) -export type ToolStateRunning = Types.DeepMutable> - -export const ToolStateCompleted = Schema.Struct({ - status: Schema.Literal("completed"), - input: Schema.Record(Schema.String, Schema.Any), - output: Schema.String, - title: Schema.String, - metadata: Schema.Record(Schema.String, Schema.Any), - time: Schema.Struct({ - start: NonNegativeInt, - end: NonNegativeInt, - compacted: Schema.optional(NonNegativeInt), - }), - attachments: Schema.optional(Schema.Array(FilePart)), -}).annotate({ identifier: "ToolStateCompleted" }) -export type ToolStateCompleted = Types.DeepMutable> - function truncateToolOutput(text: string, maxChars?: number) { if (!maxChars || text.length <= maxChars) return text const omitted = text.length - maxChars return `${text.slice(0, maxChars)}\n[Tool output truncated for compaction: omitted ${omitted} chars]` } -export const ToolStateError = Schema.Struct({ - status: Schema.Literal("error"), - input: Schema.Record(Schema.String, Schema.Any), - error: Schema.String, - metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)), - time: Schema.Struct({ - start: NonNegativeInt, - end: NonNegativeInt, - }), -}).annotate({ identifier: "ToolStateError" }) -export type ToolStateError = Types.DeepMutable> - -export const ToolState = Schema.Union([ - ToolStatePending, - ToolStateRunning, - ToolStateCompleted, - ToolStateError, -]).annotate({ - discriminator: "status", - identifier: "ToolState", -}) -export type ToolState = ToolStatePending | ToolStateRunning | ToolStateCompleted | ToolStateError - -export const ToolPart = Schema.Struct({ - ...partBase, - type: Schema.Literal("tool"), - callID: Schema.String, - tool: Schema.String, - state: ToolState, - metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)), -}).annotate({ identifier: "ToolPart" }) -export type ToolPart = Omit>, "state"> & { - state: ToolState -} - -const messageBase = { - id: MessageID, - sessionID: SessionID, -} - -export const User = Schema.Struct({ - ...messageBase, - role: Schema.Literal("user"), - time: Schema.Struct({ - created: NonNegativeInt, - }), - format: Schema.optional(Format), - summary: Schema.optional( - Schema.Struct({ - title: Schema.optional(Schema.String), - body: Schema.optional(Schema.String), - diffs: Schema.Array(Snapshot.FileDiff), - }), - ), - agent: Schema.String, - model: Schema.Struct({ - providerID: ProviderID, - modelID: ModelID, - variant: Schema.optional(Schema.String), - }), - system: Schema.optional(Schema.String), - tools: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)), -}).annotate({ identifier: "UserMessage" }) -export type User = Types.DeepMutable> - -export const Part = Schema.Union([ - TextPart, - SubtaskPart, - ReasoningPart, - FilePart, - ToolPart, - StepStartPart, - StepFinishPart, - SnapshotPart, - PatchPart, - AgentPart, - RetryPart, - CompactionPart, -]).annotate({ discriminator: "type", identifier: "Part" }) -export type Part = - | TextPart - | SubtaskPart - | ReasoningPart - | FilePart - | ToolPart - | StepStartPart - | StepFinishPart - | SnapshotPart - | PatchPart - | AgentPart - | RetryPart - | CompactionPart - -const AssistantErrorSchema = Schema.Union([ - ...MessageError.Shared, - AbortedError.EffectSchema, - StructuredOutputError.EffectSchema, - ContextOverflowError.EffectSchema, - APIError.EffectSchema, -]).annotate({ discriminator: "name" }) -type AssistantError = Schema.Schema.Type - -// ── Prompt input schemas ───────────────────────────────────────────────────── -// -// Consumers of `SessionPrompt.PromptInput.parts` send part drafts without the -// ambient IDs (`messageID`, `sessionID`) that live on stored parts, and may -// omit `id` to let the server allocate one. These Schema-Struct variants -// carry that shape so prompt decoding can accept drafts without stored IDs. - -export const TextPartInput = Schema.Struct({ - id: Schema.optional(PartID), - type: Schema.Literal("text"), - text: Schema.String, - synthetic: Schema.optional(Schema.Boolean), - ignored: Schema.optional(Schema.Boolean), - time: Schema.optional( - Schema.Struct({ - start: NonNegativeInt, - end: Schema.optional(NonNegativeInt), - }), - ), - metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)), -}).annotate({ identifier: "TextPartInput" }) -export type TextPartInput = Types.DeepMutable> - -export const FilePartInput = Schema.Struct({ - id: Schema.optional(PartID), - type: Schema.Literal("file"), - mime: Schema.String, - filename: Schema.optional(Schema.String), - url: Schema.String, - source: Schema.optional(FilePartSource), -}).annotate({ identifier: "FilePartInput" }) -export type FilePartInput = Types.DeepMutable> - -export const AgentPartInput = Schema.Struct({ - id: Schema.optional(PartID), - type: Schema.Literal("agent"), - name: Schema.String, - source: Schema.optional( - Schema.Struct({ - value: Schema.String, - start: NonNegativeInt, - end: NonNegativeInt, - }), - ), -}).annotate({ identifier: "AgentPartInput" }) -export type AgentPartInput = Types.DeepMutable> - -export const SubtaskPartInput = Schema.Struct({ - id: Schema.optional(PartID), - type: Schema.Literal("subtask"), - prompt: Schema.String, - description: Schema.String, - agent: Schema.String, - model: Schema.optional( - Schema.Struct({ - providerID: ProviderID, - modelID: ModelID, - }), - ), - command: Schema.optional(Schema.String), -}).annotate({ identifier: "SubtaskPartInput" }) -export type SubtaskPartInput = Types.DeepMutable> - -export const Assistant = Schema.Struct({ - ...messageBase, - role: Schema.Literal("assistant"), - time: Schema.Struct({ - created: NonNegativeInt, - completed: Schema.optional(NonNegativeInt), - }), - error: Schema.optional(AssistantErrorSchema), - parentID: MessageID, - modelID: ModelID, - providerID: ProviderID, - /** - * @deprecated - */ - mode: Schema.String, - agent: Schema.String, - path: Schema.Struct({ - cwd: Schema.String, - root: Schema.String, - }), - summary: Schema.optional(Schema.Boolean), - cost: Schema.Finite, - tokens: Schema.Struct({ - total: Schema.optional(Schema.Finite), - input: Schema.Finite, - output: Schema.Finite, - reasoning: Schema.Finite, - cache: Schema.Struct({ - read: Schema.Finite, - write: Schema.Finite, - }), - }), - structured: Schema.optional(Schema.Any), - variant: Schema.optional(Schema.String), - finish: Schema.optional(Schema.String), -}).annotate({ identifier: "AssistantMessage" }) -export type Assistant = Omit>, "error"> & { - error?: AssistantError -} - -export const Info = Schema.Union([User, Assistant]).annotate({ discriminator: "role", identifier: "Message" }) -export type Info = User | Assistant - -const UpdatedEventSchema = Schema.Struct({ - sessionID: SessionID, - info: Info, -}) - -const RemovedEventSchema = Schema.Struct({ - sessionID: SessionID, - messageID: MessageID, -}) - -const PartUpdatedEventSchema = Schema.Struct({ - sessionID: SessionID, - part: Part, - time: NonNegativeInt, -}) - -const PartRemovedEventSchema = Schema.Struct({ - sessionID: SessionID, - messageID: MessageID, - partID: PartID, -}) - export const Event = { - Updated: SyncEvent.define({ - type: "message.updated", - version: 1, - aggregate: "sessionID", - schema: UpdatedEventSchema, - }), - Removed: SyncEvent.define({ - type: "message.removed", - version: 1, - aggregate: "sessionID", - schema: RemovedEventSchema, - }), - PartUpdated: SyncEvent.define({ - type: "message.part.updated", - version: 1, - aggregate: "sessionID", - schema: PartUpdatedEventSchema, - }), - PartDelta: BusEvent.define( - "message.part.delta", - Schema.Struct({ + Updated: SessionLegacy.Event.MessageUpdated, + Removed: SessionLegacy.Event.MessageRemoved, + PartUpdated: SessionLegacy.Event.PartUpdated, + PartDelta: EventV2.define({ + type: "message.part.delta", + schema: { sessionID: SessionID, messageID: MessageID, partID: PartID, field: Schema.String, delta: Schema.String, - }), - ), - PartRemoved: SyncEvent.define({ - type: "message.part.removed", - version: 1, - aggregate: "sessionID", - schema: PartRemovedEventSchema, + }, }), -} - -export const WithParts = Schema.Struct({ - info: Info, - parts: Schema.Array(Part), -}) -export type WithParts = { - info: Info - parts: Part[] + PartRemoved: SessionLegacy.Event.PartRemoved, } const Cursor = Schema.Struct({ @@ -595,30 +107,31 @@ const part = (row: typeof PartTable.$inferSelect) => const older = (row: Cursor) => or(lt(MessageTable.time_created, row.time), and(eq(MessageTable.time_created, row.time), lt(MessageTable.id, row.id))) -function hydrate(rows: (typeof MessageTable.$inferSelect)[]) { +function hydrate(db: Database.Interface["db"], rows: (typeof MessageTable.$inferSelect)[]) { const ids = rows.map((row) => row.id) const partByMessage = new Map() - if (ids.length > 0) { - const partRows = Database.use((db) => - db + return Effect.gen(function* () { + if (ids.length > 0) { + const partRows = yield* db .select() .from(PartTable) .where(inArray(PartTable.message_id, ids)) .orderBy(PartTable.message_id, PartTable.id) - .all(), - ) - for (const row of partRows) { - const next = part(row) - const list = partByMessage.get(row.message_id) - if (list) list.push(next) - else partByMessage.set(row.message_id, [next]) + .all() + .pipe(Effect.orDie) + for (const row of partRows) { + const next = part(row) + const list = partByMessage.get(row.message_id) + if (list) list.push(next) + else partByMessage.set(row.message_id, [next]) + } } - } - return rows.map((row) => ({ - info: info(row), - parts: partByMessage.get(row.id) ?? [], - })) + return rows.map((row) => ({ + info: info(row), + parts: partByMessage.get(row.id) ?? [], + })) + }) } function providerMeta(metadata: Record | undefined) { @@ -925,23 +438,26 @@ export const page = Effect.fn("MessageV2.page")(function* (input: { limit: number before?: string }) { + const { db } = yield* Database.Service const before = input.before ? cursor.decode(input.before) : undefined const where = before ? and(eq(MessageTable.session_id, input.sessionID), older(before)) : eq(MessageTable.session_id, input.sessionID) - const rows = Database.use((db) => - db - .select() - .from(MessageTable) - .where(where) - .orderBy(desc(MessageTable.time_created), desc(MessageTable.id)) - .limit(input.limit + 1) - .all(), - ) + const rows = yield* db + .select() + .from(MessageTable) + .where(where) + .orderBy(desc(MessageTable.time_created), desc(MessageTable.id)) + .limit(input.limit + 1) + .all() + .pipe(Effect.orDie) if (rows.length === 0) { - const row = Database.use((db) => - db.select({ id: SessionTable.id }).from(SessionTable).where(eq(SessionTable.id, input.sessionID)).get(), - ) + const row = yield* db + .select({ id: SessionTable.id }) + .from(SessionTable) + .where(eq(SessionTable.id, input.sessionID)) + .get() + .pipe(Effect.orDie) if (!row) return yield* new NotFoundError({ message: `Session not found: ${input.sessionID}` }) return { items: [] as WithParts[], @@ -951,7 +467,7 @@ export const page = Effect.fn("MessageV2.page")(function* (input: { const more = rows.length > input.limit const slice = more ? rows.slice(0, input.limit) : rows - const items = hydrate(slice) + const items = yield* hydrate(db, slice) items.reverse() const tail = slice.at(-1) return { @@ -961,53 +477,55 @@ export const page = Effect.fn("MessageV2.page")(function* (input: { } }) -export function* stream(sessionID: SessionID) { +export function stream(sessionID: SessionID) { const size = 50 - let before: string | undefined - while (true) { - const next = Effect.runSync( - page({ sessionID, limit: size, before }).pipe( + return Effect.gen(function* () { + const result = [] as WithParts[] + let before: string | undefined + while (true) { + const next = yield* page({ sessionID, limit: size, before }).pipe( Effect.catchIf(NotFoundError.isInstance, () => Effect.succeed({ items: [] as WithParts[], more: false, cursor: undefined }), ), - ), - ) - if (next.items.length === 0) break - for (let i = next.items.length - 1; i >= 0; i--) { - yield next.items[i] + ) + if (next.items.length === 0) break + for (let i = next.items.length - 1; i >= 0; i--) { + const item = next.items[i] + if (item) result.push(item) + } + if (!next.more || !next.cursor) break + before = next.cursor } - if (!next.more || !next.cursor) break - before = next.cursor - } + return result + }) } -export function parts(message_id: MessageID) { - const rows = Database.use((db) => - db.select().from(PartTable).where(eq(PartTable.message_id, message_id)).orderBy(PartTable.id).all(), - ) - return rows.map( - (row) => - ({ - ...row.data, - id: row.id, - sessionID: row.session_id, - messageID: row.message_id, - }) as Part, - ) +export function parts(messageID: MessageID) { + return Effect.gen(function* () { + const { db } = yield* Database.Service + const rows = yield* db + .select() + .from(PartTable) + .where(eq(PartTable.message_id, messageID)) + .orderBy(PartTable.id) + .all() + .pipe(Effect.orDie) + return rows.map(part) + }) } export const get = Effect.fn("MessageV2.get")(function* (input: { sessionID: SessionID; messageID: MessageID }) { - const row = Database.use((db) => - db - .select() - .from(MessageTable) - .where(and(eq(MessageTable.id, input.messageID), eq(MessageTable.session_id, input.sessionID))) - .get(), - ) + const { db } = yield* Database.Service + const row = yield* db + .select() + .from(MessageTable) + .where(and(eq(MessageTable.id, input.messageID), eq(MessageTable.session_id, input.sessionID))) + .get() + .pipe(Effect.orDie) if (!row) return yield* new NotFoundError({ message: `Message not found: ${input.messageID}` }) return { info: info(row), - parts: parts(input.messageID), + parts: yield* parts(input.messageID), } }) @@ -1065,7 +583,7 @@ export function filterCompacted(msgs: Iterable) { } export const filterCompactedEffect = Effect.fnUntraced(function* (sessionID: SessionID) { - return filterCompacted(stream(sessionID)) + return filterCompacted(yield* stream(sessionID)) }) // filterCompacted reorders messages for model consumption @@ -1095,7 +613,7 @@ export function latest(msgs: WithParts[]) { export function fromError( e: unknown, - ctx: { providerID: ProviderID; aborted?: boolean }, + ctx: { providerID: ProviderV2.ID; aborted?: boolean }, ): NonNullable { switch (true) { case e instanceof DOMException && e.name === "AbortError": diff --git a/packages/opencode/src/session/message.ts b/packages/opencode/src/session/message.ts index 39c842f94b..e5332992f5 100644 --- a/packages/opencode/src/session/message.ts +++ b/packages/opencode/src/session/message.ts @@ -1,9 +1,10 @@ import { Schema } from "effect" import { SessionID } from "./schema" -import { ModelID, ProviderID } from "../provider/schema" + import { NonNegativeInt } from "@opencode-ai/core/schema" import { MessageError } from "./message-error" import { AuthError, OutputLengthError } from "./message-error" +import { ProviderV2 } from "@opencode-ai/core/provider" export { AuthError, OutputLengthError } from "./message-error" export const ToolCall = Schema.Struct({ @@ -119,8 +120,8 @@ export const Info = Schema.Struct({ assistant: Schema.optional( Schema.Struct({ system: Schema.Array(Schema.String), - modelID: ModelID, - providerID: ProviderID, + modelID: ProviderV2.ModelID, + providerID: ProviderV2.ID, path: Schema.Struct({ cwd: Schema.String, root: Schema.String, diff --git a/packages/opencode/src/session/overflow.ts b/packages/opencode/src/session/overflow.ts index d01fe5c624..343c8408e9 100644 --- a/packages/opencode/src/session/overflow.ts +++ b/packages/opencode/src/session/overflow.ts @@ -1,4 +1,5 @@ import type { Config } from "@/config/config" +import { SessionLegacy } from "@opencode-ai/core/session/legacy" import type { Provider } from "@/provider/provider" import { ProviderTransform } from "@/provider/transform" import type { MessageV2 } from "./message-v2" @@ -19,7 +20,7 @@ export function usable(input: { cfg: Config.Info; model: Provider.Model; outputT export function isOverflow(input: { cfg: Config.Info - tokens: MessageV2.Assistant["tokens"] + tokens: SessionLegacy.Assistant["tokens"] model: Provider.Model outputTokenMax?: number }) { diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index a287c3b006..f124f7eea4 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -1,8 +1,8 @@ import { Image } from "@/image/image" +import { SessionLegacy } from "@opencode-ai/core/session/legacy" import { Cause, Deferred, Effect, Exit, Layer, Context, Scope, Schema } from "effect" import * as Stream from "effect/Stream" import { Agent } from "@/agent/agent" -import { Bus } from "@/bus" import { Config } from "@/config/config" import { Permission } from "@/permission" import { Plugin } from "@/plugin" @@ -22,7 +22,8 @@ import { errorMessage } from "@/util/error" import * as Log from "@opencode-ai/core/util/log" import { isRecord } from "@/util/record" import { EventV2Bridge } from "@/event-v2-bridge" -import { SessionEvent } from "@opencode-ai/core/session-event" +import { Database } from "@opencode-ai/core/database/database" +import { SessionEvent } from "@opencode-ai/core/session/event" import { ModelV2 } from "@opencode-ai/core/model" import { ProviderV2 } from "@opencode-ai/core/provider" import * as DateTime from "effect/DateTime" @@ -35,25 +36,25 @@ const log = Log.create({ service: "session.processor" }) export type Result = "compact" | "stop" | "continue" export interface Handle { - readonly message: MessageV2.Assistant + readonly message: SessionLegacy.Assistant readonly updateToolCall: ( toolCallID: string, - update: (part: MessageV2.ToolPart) => MessageV2.ToolPart, - ) => Effect.Effect + update: (part: SessionLegacy.ToolPart) => SessionLegacy.ToolPart, + ) => Effect.Effect readonly completeToolCall: ( toolCallID: string, output: { title: string metadata: Record output: string - attachments?: MessageV2.FilePart[] + attachments?: SessionLegacy.FilePart[] }, ) => Effect.Effect readonly process: (streamInput: LLM.StreamInput) => Effect.Effect } type Input = { - assistantMessage: MessageV2.Assistant + assistantMessage: SessionLegacy.Assistant sessionID: SessionID model: Provider.Model } @@ -63,9 +64,9 @@ export interface Interface { } type ToolCall = { - partID: MessageV2.ToolPart["id"] - messageID: MessageV2.ToolPart["messageID"] - sessionID: MessageV2.ToolPart["sessionID"] + partID: SessionLegacy.ToolPart["id"] + messageID: SessionLegacy.ToolPart["messageID"] + sessionID: SessionLegacy.ToolPart["sessionID"] done: Deferred.Deferred inputEnded: boolean } @@ -76,8 +77,8 @@ interface ProcessorContext extends Input { snapshot: string | undefined blocked: boolean needsCompaction: boolean - currentText: MessageV2.TextPart | undefined - reasoningMap: Record + currentText: SessionLegacy.TextPart | undefined + reasoningMap: Record } type StreamEvent = LLMEvent @@ -89,7 +90,6 @@ export const layer = Layer.effect( Effect.gen(function* () { const session = yield* Session.Service const config = yield* Config.Service - const bus = yield* Bus.Service const snapshot = yield* Snapshot.Service const agents = yield* Agent.Service const llm = yield* LLM.Service @@ -101,6 +101,7 @@ export const layer = Layer.effect( const image = yield* Image.Service const events = yield* EventV2Bridge.Service const flags = yield* RuntimeFlags.Service + const database = yield* Database.Service const create = Effect.fn("SessionProcessor.create")(function* (input: Input) { // Pre-capture snapshot before the LLM stream starts. The AI SDK @@ -151,7 +152,7 @@ export const layer = Layer.effect( const updateToolCall = Effect.fn("SessionProcessor.updateToolCall")(function* ( toolCallID: string, - update: (part: MessageV2.ToolPart) => MessageV2.ToolPart, + update: (part: SessionLegacy.ToolPart) => SessionLegacy.ToolPart, ) { const match = yield* readToolCall(toolCallID) if (!match) return undefined @@ -171,7 +172,7 @@ export const layer = Layer.effect( title: string metadata: Record output: string - attachments?: MessageV2.FilePart[] + attachments?: SessionLegacy.FilePart[] }, ) { const match = yield* readToolCall(toolCallID) @@ -266,7 +267,7 @@ export const layer = Layer.effect( callID: input.id, state: { status: "pending", input: {}, raw: "" }, metadata: input.providerExecuted ? { providerExecuted: true } : undefined, - } satisfies MessageV2.ToolPart) + } satisfies SessionLegacy.ToolPart) ctx.toolcalls[input.id] = { done: yield* Deferred.make(), partID: part.id, @@ -277,11 +278,11 @@ export const layer = Layer.effect( return { call: ctx.toolcalls[input.id], part } }) - const isFilePart = (value: unknown): value is MessageV2.FilePart => Schema.is(MessageV2.FilePart)(value) + const isFilePart = (value: unknown): value is SessionLegacy.FilePart => Schema.is(SessionLegacy.FilePart)(value) const toolResultOutput = ( value: Extract, - ): { title: string; metadata: Record; output: string; attachments?: MessageV2.FilePart[] } => { + ): { title: string; metadata: Record; output: string; attachments?: SessionLegacy.FilePart[] } => { if (isRecord(value.result.value) && typeof value.result.value.output === "string") { return { title: typeof value.result.value.title === "string" ? value.result.value.title : value.name, @@ -421,7 +422,9 @@ export const layer = Layer.effect( : value.providerMetadata, })) - const parts = MessageV2.parts(ctx.assistantMessage.id) + const parts = yield* MessageV2.parts(ctx.assistantMessage.id).pipe( + Effect.provideService(Database.Service, database), + ) const recentParts = parts.slice(-DOOM_LOOP_THRESHOLD) if ( @@ -461,7 +464,7 @@ export const layer = Layer.effect( ), Effect.exit, ) - : Effect.succeed(Exit.succeed(attachment)), + : Effect.succeed(Exit.succeed(attachment)), ) const omitted = normalized.filter(Exit.isFailure).length const attachments = normalized.filter(Exit.isSuccess).map((item) => item.value) @@ -484,7 +487,7 @@ export const layer = Layer.effect( type: "text", text: output.output, }, - ...(output.attachments?.map((item: MessageV2.FilePart) => ({ + ...(output.attachments?.map((item: SessionLegacy.FilePart) => ({ type: "file" as const, uri: item.url, mime: item.mime, @@ -751,9 +754,9 @@ export const layer = Layer.effect( const halt = Effect.fn("SessionProcessor.halt")(function* (e: unknown) { slog.error("process", { error: errorMessage(e), stack: e instanceof Error ? e.stack : undefined }) const error = parse(e) - if (MessageV2.ContextOverflowError.isInstance(error)) { + if (SessionLegacy.ContextOverflowError.isInstance(error)) { ctx.needsCompaction = true - yield* bus.publish(Session.Event.Error, { sessionID: ctx.sessionID, error }) + yield* events.publish(Session.Event.Error, { sessionID: ctx.sessionID, error }) return } if (!ctx.assistantMessage.summary) { @@ -770,7 +773,7 @@ export const layer = Layer.effect( } } ctx.assistantMessage.error = error - yield* bus.publish(Session.Event.Error, { + yield* events.publish(Session.Event.Error, { sessionID: ctx.assistantMessage.sessionID, error: ctx.assistantMessage.error, }) @@ -873,9 +876,9 @@ export const defaultLayer = Layer.suspend(() => Layer.provide(SessionSummary.defaultLayer), Layer.provide(SessionStatus.defaultLayer), Layer.provide(Image.defaultLayer), - Layer.provide(Bus.layer), Layer.provide(Config.defaultLayer), Layer.provide(RuntimeFlags.defaultLayer), + Layer.provide(Database.defaultLayer), Layer.provide(EventV2Bridge.defaultLayer), ), ) diff --git a/packages/opencode/src/session/projectors-next.ts b/packages/opencode/src/session/projectors-next.ts deleted file mode 100644 index ae5b9c5d2f..0000000000 --- a/packages/opencode/src/session/projectors-next.ts +++ /dev/null @@ -1,204 +0,0 @@ -import { and, desc, eq } from "@/storage/db" -import type { Database } from "@/storage/db" -import { SessionMessage } from "@opencode-ai/core/session-message" -import { SessionMessageUpdater } from "@opencode-ai/core/session-message-updater" -import { SessionEvent } from "@opencode-ai/core/session-event" -import * as DateTime from "effect/DateTime" -import { SyncEvent } from "@/sync" -import { EventV2Bridge } from "@/event-v2-bridge" -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(EventV2Bridge.toSyncDefinition(SessionEvent.AgentSwitched), (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(EventV2Bridge.toSyncDefinition(SessionEvent.ModelSwitched), (db, data, event) => { - db.update(SessionTable) - .set({ - model: data.model, - 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(EventV2Bridge.toSyncDefinition(SessionEvent.Prompted), (db, data, event) => { - update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.prompted", data }) - }), - SyncEvent.project(EventV2Bridge.toSyncDefinition(SessionEvent.Synthetic), (db, data, event) => { - update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.synthetic", data }) - }), - SyncEvent.project(EventV2Bridge.toSyncDefinition(SessionEvent.Shell.Started), (db, data, event) => { - update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.shell.started", data }) - }), - SyncEvent.project(EventV2Bridge.toSyncDefinition(SessionEvent.Shell.Ended), (db, data, event) => { - update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.shell.ended", data }) - }), - SyncEvent.project(EventV2Bridge.toSyncDefinition(SessionEvent.Step.Started), (db, data, event) => { - update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.step.started", data }) - }), - SyncEvent.project(EventV2Bridge.toSyncDefinition(SessionEvent.Step.Ended), (db, data, event) => { - update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.step.ended", data }) - }), - SyncEvent.project(EventV2Bridge.toSyncDefinition(SessionEvent.Step.Failed), (db, data, event) => { - update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.step.failed", data }) - }), - SyncEvent.project(EventV2Bridge.toSyncDefinition(SessionEvent.Text.Started), (db, data, event) => { - update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.text.started", data }) - }), - SyncEvent.project(EventV2Bridge.toSyncDefinition(SessionEvent.Text.Delta), () => {}), - SyncEvent.project(EventV2Bridge.toSyncDefinition(SessionEvent.Text.Ended), (db, data, event) => { - update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.text.ended", data }) - }), - SyncEvent.project(EventV2Bridge.toSyncDefinition(SessionEvent.Tool.Input.Started), (db, data, event) => { - update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.tool.input.started", data }) - }), - SyncEvent.project(EventV2Bridge.toSyncDefinition(SessionEvent.Tool.Input.Delta), () => {}), - SyncEvent.project(EventV2Bridge.toSyncDefinition(SessionEvent.Tool.Input.Ended), (db, data, event) => { - update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.tool.input.ended", data }) - }), - SyncEvent.project(EventV2Bridge.toSyncDefinition(SessionEvent.Tool.Called), (db, data, event) => { - update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.tool.called", data }) - }), - SyncEvent.project(EventV2Bridge.toSyncDefinition(SessionEvent.Tool.Success), (db, data, event) => { - update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.tool.success", data }) - }), - SyncEvent.project(EventV2Bridge.toSyncDefinition(SessionEvent.Tool.Failed), (db, data, event) => { - update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.tool.failed", data }) - }), - SyncEvent.project(EventV2Bridge.toSyncDefinition(SessionEvent.Reasoning.Started), (db, data, event) => { - update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.reasoning.started", data }) - }), - SyncEvent.project(EventV2Bridge.toSyncDefinition(SessionEvent.Reasoning.Delta), () => {}), - SyncEvent.project(EventV2Bridge.toSyncDefinition(SessionEvent.Reasoning.Ended), (db, data, event) => { - update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.reasoning.ended", data }) - }), - SyncEvent.project(EventV2Bridge.toSyncDefinition(SessionEvent.Retried), (db, data, event) => { - update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.retried", data }) - }), - SyncEvent.project(EventV2Bridge.toSyncDefinition(SessionEvent.Compaction.Started), (db, data, event) => { - update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.compaction.started", data }) - }), - SyncEvent.project(EventV2Bridge.toSyncDefinition(SessionEvent.Compaction.Delta), () => {}), - SyncEvent.project(EventV2Bridge.toSyncDefinition(SessionEvent.Compaction.Ended), (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 deleted file mode 100644 index 20edf479a4..0000000000 --- a/packages/opencode/src/session/projectors.ts +++ /dev/null @@ -1,200 +0,0 @@ -import { NotFoundError } from "@/storage/storage" -import { eq } from "drizzle-orm" -import { and } from "drizzle-orm" -import { sql } from "drizzle-orm" -import type { TxOrDb } from "@/storage/db" -import { SyncEvent } from "@/sync" -import * as Session from "./session" -import { MessageV2 } from "./message-v2" -import { SessionTable, MessageTable, PartTable } from "./session.sql" -import { WorkspaceTable } from "@/control-plane/workspace.sql" -import { Log } from "@opencode-ai/core/util/log" -import nextProjectors from "./projectors-next" - -const log = Log.create({ service: "session.projector" }) - -function foreign(err: unknown) { - if (typeof err !== "object" || err === null) return false - if ("code" in err && err.code === "SQLITE_CONSTRAINT_FOREIGNKEY") return true - return "message" in err && typeof err.message === "string" && err.message.includes("FOREIGN KEY constraint failed") -} - -export type DeepPartial = T extends object ? { [K in keyof T]?: DeepPartial | null } : T - -type Usage = Pick - -function usage(part: MessageV2.Part | (typeof PartTable.$inferSelect)["data"]): Usage | undefined { - if (part.type !== "step-finish") return undefined - if (!("cost" in part) || !("tokens" in part)) return undefined - return { cost: part.cost, tokens: part.tokens } -} - -function applyUsage(db: TxOrDb, sessionID: Session.Info["id"], value: Usage, sign = 1) { - db.update(SessionTable) - .set({ - cost: sql`${SessionTable.cost} + ${value.cost * sign}`, - tokens_input: sql`${SessionTable.tokens_input} + ${value.tokens.input * sign}`, - tokens_output: sql`${SessionTable.tokens_output} + ${value.tokens.output * sign}`, - tokens_reasoning: sql`${SessionTable.tokens_reasoning} + ${value.tokens.reasoning * sign}`, - tokens_cache_read: sql`${SessionTable.tokens_cache_read} + ${value.tokens.cache.read * sign}`, - tokens_cache_write: sql`${SessionTable.tokens_cache_write} + ${value.tokens.cache.write * sign}`, - time_updated: sql`${SessionTable.time_updated}`, - }) - .where(eq(SessionTable.id, sessionID)) - .run() -} - -function grab( - obj: T, - field1: K1, - cb?: (val: NonNullable) => X, -): X | undefined { - if (obj == undefined || !(field1 in obj)) return undefined - - const val = obj[field1] - if (val && typeof val === "object" && cb) { - return cb(val) - } - if (val === undefined) { - throw new Error( - "Session update failure: pass `null` to clear a field instead of `undefined`: " + JSON.stringify(obj), - ) - } - return val as X | undefined -} - -export function toPartialRow(info: DeepPartial) { - const obj = { - id: grab(info, "id"), - project_id: grab(info, "projectID"), - workspace_id: grab(info, "workspaceID"), - parent_id: grab(info, "parentID"), - slug: grab(info, "slug"), - directory: grab(info, "directory"), - path: grab(info, "path"), - title: grab(info, "title"), - version: grab(info, "version"), - share_url: grab(info, "share", (v) => grab(v, "url")), - summary_additions: grab(info, "summary", (v) => grab(v, "additions")), - summary_deletions: grab(info, "summary", (v) => grab(v, "deletions")), - summary_files: grab(info, "summary", (v) => grab(v, "files")), - summary_diffs: grab(info, "summary", (v) => grab(v, "diffs")), - metadata: grab(info, "metadata"), - cost: grab(info, "cost"), - tokens_input: grab(info, "tokens", (v) => grab(v, "input")), - tokens_output: grab(info, "tokens", (v) => grab(v, "output")), - tokens_reasoning: grab(info, "tokens", (v) => grab(v, "reasoning")), - tokens_cache_read: grab(info, "tokens", (v) => grab(v, "cache", (cache) => grab(cache, "read"))), - tokens_cache_write: grab(info, "tokens", (v) => grab(v, "cache", (cache) => grab(cache, "write"))), - revert: grab(info, "revert"), - permission: grab(info, "permission"), - time_created: grab(info, "time", (v) => grab(v, "created")), - time_updated: grab(info, "time", (v) => grab(v, "updated")), - time_compacting: grab(info, "time", (v) => grab(v, "compacting")), - time_archived: grab(info, "time", (v) => grab(v, "archived")), - } - - return Object.fromEntries(Object.entries(obj).filter(([_, val]) => val !== undefined)) -} - -export default [ - SyncEvent.project(Session.Event.Created, (db, data) => { - db.insert(SessionTable) - .values(Session.toRow(data.info as Session.Info)) - .run() - - if (data.info.workspaceID) { - db.update(WorkspaceTable).set({ time_used: Date.now() }).where(eq(WorkspaceTable.id, data.info.workspaceID)).run() - } - }), - - SyncEvent.project(Session.Event.Updated, (db, data) => { - const info = data.info - const row = db - .update(SessionTable) - .set({ time_updated: sql`${SessionTable.time_updated}`, ...toPartialRow(info as Session.Patch) }) - .where(eq(SessionTable.id, data.sessionID)) - .returning() - .get() - if (!row) throw new NotFoundError({ message: `Session not found: ${data.sessionID}` }) - }), - - SyncEvent.project(Session.Event.Deleted, (db, data) => { - db.delete(SessionTable).where(eq(SessionTable.id, data.sessionID)).run() - }), - - SyncEvent.project(MessageV2.Event.Updated, (db, data) => { - const time_created = data.info.time.created - const { id, sessionID, ...rest } = data.info - - try { - db.insert(MessageTable) - .values({ - id, - session_id: sessionID, - time_created, - data: rest, - }) - .onConflictDoUpdate({ target: MessageTable.id, set: { data: rest } }) - .run() - } catch (err) { - if (!foreign(err)) throw err - log.warn("ignored late message update", { messageID: id, sessionID }) - } - }), - - SyncEvent.project(MessageV2.Event.Removed, (db, data) => { - for (const row of db - .select() - .from(PartTable) - .where(and(eq(PartTable.message_id, data.messageID), eq(PartTable.session_id, data.sessionID))) - .all()) { - const previous = usage(row.data) - if (previous) applyUsage(db, data.sessionID, previous, -1) - } - db.delete(MessageTable) - .where(and(eq(MessageTable.id, data.messageID), eq(MessageTable.session_id, data.sessionID))) - .run() - }), - - SyncEvent.project(MessageV2.Event.PartRemoved, (db, data) => { - const row = db - .select() - .from(PartTable) - .where(and(eq(PartTable.id, data.partID), eq(PartTable.session_id, data.sessionID))) - .get() - const previous = row && usage(row.data) - if (previous) applyUsage(db, data.sessionID, previous, -1) - - db.delete(PartTable) - .where(and(eq(PartTable.id, data.partID), eq(PartTable.session_id, data.sessionID))) - .run() - }), - - SyncEvent.project(MessageV2.Event.PartUpdated, (db, data) => { - const { id, messageID, sessionID, ...rest } = data.part - const row = db.select().from(PartTable).where(eq(PartTable.id, id)).get() - - try { - db.insert(PartTable) - .values({ - id, - message_id: messageID, - session_id: sessionID, - time_created: data.time, - data: rest, - }) - .onConflictDoUpdate({ target: PartTable.id, set: { data: rest } }) - .run() - const previous = row && usage(row.data) - const next = usage(data.part) - if (previous) applyUsage(db, row.session_id, previous, -1) - if (next) applyUsage(db, sessionID, next) - } catch (err) { - if (!foreign(err)) throw err - 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 22fe4d81cd..f51635bf83 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1,4 +1,5 @@ import path from "path" +import { SessionLegacy } from "@opencode-ai/core/session/legacy" import os from "os" import { SessionID, MessageID, PartID } from "./schema" import { MessageV2 } from "./message-v2" @@ -7,11 +8,10 @@ import { SessionRevert } from "./revert" import * as Session from "./session" import { Agent } from "../agent/agent" import { Provider } from "@/provider/provider" -import { ModelID, ProviderID } from "../provider/schema" + import { type Tool as AITool, tool, jsonSchema } from "ai" import type { JSONSchema7 } from "@ai-sdk/provider" import { SessionCompaction } from "./compaction" -import { Bus } from "../bus" import { SystemPrompt } from "./system" import { Instruction } from "./instruction" import { Plugin } from "../plugin" @@ -48,15 +48,15 @@ import { TaskTool, type TaskPromptOps } from "@/tool/task" import { SessionRunState } from "./run-state" import { RuntimeFlags } from "@/effect/runtime-flags" import { EventV2Bridge } from "@/event-v2-bridge" -import { SessionEvent } from "@opencode-ai/core/session-event" +import { Database } from "@opencode-ai/core/database/database" +import { SessionEvent } from "@opencode-ai/core/session/event" import { ModelV2 } from "@opencode-ai/core/model" import { ProviderV2 } from "@opencode-ai/core/provider" -import { AgentAttachment, FileAttachment, ReferenceAttachment, Source } from "@opencode-ai/core/session-prompt" +import { AgentAttachment, FileAttachment, ReferenceAttachment, Source } from "@opencode-ai/core/session/prompt" import { Reference } from "@/reference/reference" import * as DateTime from "effect/DateTime" -import { eq } from "@/storage/db" -import * as Database from "@/storage/db" -import { SessionTable } from "./session.sql" +import { eq } from "drizzle-orm" +import { SessionTable } from "@opencode-ai/core/session/sql" import { referencePromptMetadata, referenceTextPart } from "./prompt/reference" import { SessionReminders } from "./reminders" import { SessionTools } from "./tools" @@ -65,8 +65,8 @@ import { LLMEvent } from "@opencode-ai/llm" // @ts-ignore globalThis.AI_SDK_LOG_WARNINGS = false -const decodeMessageInfo = Schema.decodeUnknownExit(MessageV2.Info) -const decodeMessagePart = Schema.decodeUnknownExit(MessageV2.Part) +const decodeMessageInfo = Schema.decodeUnknownExit(SessionLegacy.Info) +const decodeMessagePart = Schema.decodeUnknownExit(SessionLegacy.Part) const STRUCTURED_OUTPUT_DESCRIPTION = `Use this tool to return your final response in the requested structured format. @@ -81,7 +81,7 @@ const STRUCTURED_OUTPUT_SYSTEM_PROMPT = `IMPORTANT: The user has requested struc const log = Log.create({ service: "session.prompt" }) const elog = EffectLogger.create({ service: "session.prompt" }) -function isOrphanedInterruptedTool(part: MessageV2.ToolPart) { +function isOrphanedInterruptedTool(part: SessionLegacy.ToolPart) { // cleanup() marks abandoned tool_use blocks this way after retries/aborts. // They are not pending work and must not trigger an assistant-prefill request. return part.state.status === "error" && part.state.metadata?.interrupted === true @@ -89,10 +89,10 @@ function isOrphanedInterruptedTool(part: MessageV2.ToolPart) { export interface Interface { readonly cancel: (sessionID: SessionID) => Effect.Effect - readonly prompt: (input: PromptInput) => Effect.Effect - readonly loop: (input: LoopInput) => Effect.Effect - readonly shell: (input: ShellInput) => Effect.Effect - readonly command: (input: CommandInput) => Effect.Effect + readonly prompt: (input: PromptInput) => Effect.Effect + readonly loop: (input: LoopInput) => Effect.Effect + readonly shell: (input: ShellInput) => Effect.Effect + readonly command: (input: CommandInput) => Effect.Effect readonly resolvePromptParts: (template: string) => Effect.Effect } @@ -101,7 +101,6 @@ export class Service extends Context.Service()("@opencode/Se export const layer = Layer.effect( Service, Effect.gen(function* () { - const bus = yield* Bus.Service const status = yield* SessionStatus.Service const sessions = yield* Session.Service const agents = yield* Agent.Service @@ -129,6 +128,8 @@ export const layer = Layer.effect( const references = yield* Reference.Service const events = yield* EventV2Bridge.Service const flags = yield* RuntimeFlags.Service + const database = yield* Database.Service + const { db } = database const ops = Effect.fn("SessionPrompt.ops")(function* () { return { cancel: (sessionID: SessionID) => cancel(sessionID), @@ -240,14 +241,14 @@ export const layer = Layer.effect( const title = Effect.fn("SessionPrompt.ensureTitle")(function* (input: { session: Session.Info - history: MessageV2.WithParts[] - providerID: ProviderID - modelID: ModelID + history: SessionLegacy.WithParts[] + providerID: ProviderV2.ID + modelID: ProviderV2.ModelID }) { if (input.session.parentID) return if (!Session.isDefaultTitle(input.session.title)) return - const real = (m: MessageV2.WithParts) => + const real = (m: SessionLegacy.WithParts) => m.info.role === "user" && !m.parts.every((p) => "synthetic" in p && p.synthetic) const idx = input.history.findIndex(real) if (idx === -1) return @@ -258,7 +259,7 @@ export const layer = Layer.effect( if (!firstUser || firstUser.info.role !== "user") return const firstInfo = firstUser.info - const subtasks = firstUser.parts.filter((p): p is MessageV2.SubtaskPart => p.type === "subtask") + const subtasks = firstUser.parts.filter((p): p is SessionLegacy.SubtaskPart => p.type === "subtask") const onlySubtasks = subtasks.length > 0 && firstUser.parts.every((p) => p.type === "subtask") const ag = yield* agents.get("title") @@ -301,19 +302,19 @@ export const layer = Layer.effect( }) const handleSubtask = Effect.fn("SessionPrompt.handleSubtask")(function* (input: { - task: MessageV2.SubtaskPart + task: SessionLegacy.SubtaskPart model: Provider.Model - lastUser: MessageV2.User + lastUser: SessionLegacy.User sessionID: SessionID session: Session.Info - msgs: MessageV2.WithParts[] + msgs: SessionLegacy.WithParts[] }) { const { task, model, lastUser, sessionID, session, msgs } = input const ctx = yield* InstanceState.context const promptOps = yield* ops() const { task: taskTool } = yield* registry.named() const taskModel = task.model ? yield* getModel(task.model.providerID, task.model.modelID, sessionID) : model - const assistantMessage: MessageV2.Assistant = yield* sessions.updateMessage({ + const assistantMessage: SessionLegacy.Assistant = yield* sessions.updateMessage({ id: MessageID.ascending(), role: "assistant", parentID: lastUser.id, @@ -328,7 +329,7 @@ export const layer = Layer.effect( providerID: taskModel.providerID, time: { created: Date.now() }, }) - let part: MessageV2.ToolPart = yield* sessions.updatePart({ + let part: SessionLegacy.ToolPart = yield* sessions.updatePart({ id: PartID.ascending(), messageID: assistantMessage.id, sessionID: assistantMessage.sessionID, @@ -363,7 +364,7 @@ export const layer = Layer.effect( const available = (yield* agents.list()).filter((a) => !a.hidden).map((a) => a.name) const hint = available.length ? ` Available agents: ${available.join(", ")}` : "" const error = new NamedError.Unknown({ message: `Agent not found: "${task.agent}".${hint}` }) - yield* bus.publish(Session.Event.Error, { sessionID, error: error.toObject() }) + yield* events.publish(Session.Event.Error, { sessionID, error: error.toObject() }) throw error } @@ -384,7 +385,7 @@ export const layer = Layer.effect( ...part, type: "tool", state: { ...part.state, ...val }, - } satisfies MessageV2.ToolPart) + } satisfies SessionLegacy.ToolPart) }), ask: (req: any) => permission @@ -418,7 +419,7 @@ export const layer = Layer.effect( metadata: part.state.metadata, input: part.state.input, }, - } satisfies MessageV2.ToolPart) + } satisfies SessionLegacy.ToolPart) } }), ), @@ -453,7 +454,7 @@ export const layer = Layer.effect( attachments, time: { ...part.state.time, end: Date.now() }, }, - } satisfies MessageV2.ToolPart) + } satisfies SessionLegacy.ToolPart) } if (!result) { @@ -469,12 +470,12 @@ export const layer = Layer.effect( metadata: part.state.status === "pending" ? undefined : part.state.metadata, input: part.state.input, }, - } satisfies MessageV2.ToolPart) + } satisfies SessionLegacy.ToolPart) } if (!task.command) return - const summaryUserMsg: MessageV2.User = { + const summaryUserMsg: SessionLegacy.User = { id: MessageID.ascending(), sessionID, role: "user", @@ -490,7 +491,7 @@ export const layer = Layer.effect( type: "text", text: "Summarize the task tool output above and continue with your task.", synthetic: true, - } satisfies MessageV2.TextPart) + } satisfies SessionLegacy.TextPart) }) const shellImpl = Effect.fn("SessionPrompt.shellImpl")(function* (input: ShellInput, ready?: Latch.Latch) { @@ -508,11 +509,11 @@ export const layer = Layer.effect( const available = (yield* agents.list()).filter((a) => !a.hidden).map((a) => a.name) const hint = available.length ? ` Available agents: ${available.join(", ")}` : "" const error = new NamedError.Unknown({ message: `Agent not found: "${input.agent}".${hint}` }) - yield* bus.publish(Session.Event.Error, { sessionID: input.sessionID, error: error.toObject() }) + yield* events.publish(Session.Event.Error, { sessionID: input.sessionID, error: error.toObject() }) throw error } const model = input.model ?? agent.model ?? (yield* currentModel(input.sessionID)) - const userMsg: MessageV2.User = { + const userMsg: SessionLegacy.User = { id: input.messageID ?? MessageID.ascending(), sessionID: input.sessionID, time: { created: Date.now() }, @@ -521,7 +522,7 @@ export const layer = Layer.effect( model: { providerID: model.providerID, modelID: model.modelID }, } yield* sessions.updateMessage(userMsg) - const userPart: MessageV2.Part = { + const userPart: SessionLegacy.Part = { type: "text", id: PartID.ascending(), messageID: userMsg.id, @@ -531,7 +532,7 @@ export const layer = Layer.effect( } yield* sessions.updatePart(userPart) - const msg: MessageV2.Assistant = { + const msg: SessionLegacy.Assistant = { id: MessageID.ascending(), sessionID: input.sessionID, parentID: userMsg.id, @@ -547,7 +548,7 @@ export const layer = Layer.effect( } yield* sessions.updateMessage(msg) const started = Date.now() - const part: MessageV2.ToolPart = { + const part: SessionLegacy.ToolPart = { type: "tool", id: PartID.ascending(), messageID: msg.id, @@ -653,8 +654,8 @@ export const layer = Layer.effect( }) const getModel = Effect.fn("SessionPrompt.getModel")(function* ( - providerID: ProviderID, - modelID: ModelID, + providerID: ProviderV2.ID, + modelID: ProviderV2.ModelID, sessionID: SessionID, ) { const exit = yield* provider.getModel(providerID, modelID).pipe(Effect.exit) @@ -662,7 +663,7 @@ export const layer = Layer.effect( const err = Cause.squash(exit.cause) if (Provider.ModelNotFoundError.isInstance(err)) { const hint = err.suggestions?.length ? ` Did you mean: ${err.suggestions.join(", ")}?` : "" - yield* bus.publish(Session.Event.Error, { + yield* events.publish(Session.Event.Error, { sessionID, error: new NamedError.Unknown({ message: `Model not found: ${err.providerID}/${err.modelID}.${hint}`, @@ -673,13 +674,16 @@ export const layer = Layer.effect( }) const currentModel = Effect.fnUntraced(function* (sessionID: SessionID) { - const current = Database.use((db) => - db.select({ model: SessionTable.model }).from(SessionTable).where(eq(SessionTable.id, sessionID)).get(), - ) + const current = yield* db + .select({ model: SessionTable.model }) + .from(SessionTable) + .where(eq(SessionTable.id, sessionID)) + .get() + .pipe(Effect.orDie) if (current?.model) { return { - providerID: ProviderID.make(current.model.providerID), - modelID: ModelID.make(current.model.id), + providerID: ProviderV2.ID.make(current.model.providerID), + modelID: ProviderV2.ModelID.make(current.model.id), ...(current.model.variant && current.model.variant !== "default" ? { variant: current.model.variant } : {}), } } @@ -697,17 +701,16 @@ export const layer = Layer.effect( const available = (yield* agents.list()).filter((a) => !a.hidden).map((a) => a.name) const hint = available.length ? ` Available agents: ${available.join(", ")}` : "" const error = new NamedError.Unknown({ message: `Agent not found: "${agentName}".${hint}` }) - yield* bus.publish(Session.Event.Error, { sessionID: input.sessionID, error: error.toObject() }) + yield* events.publish(Session.Event.Error, { sessionID: input.sessionID, error: error.toObject() }) throw error } - const current = Database.use((db) => - db - .select({ agent: SessionTable.agent, model: SessionTable.model }) - .from(SessionTable) - .where(eq(SessionTable.id, input.sessionID)) - .get(), - ) + const current = yield* db + .select({ agent: SessionTable.agent, model: SessionTable.model }) + .from(SessionTable) + .where(eq(SessionTable.id, input.sessionID)) + .get() + .pipe(Effect.orDie) const model = input.model ?? ag.model ?? (yield* currentModel(input.sessionID)) const same = ag.model && model.providerID === ag.model.providerID && model.modelID === ag.model.modelID const full = @@ -718,7 +721,7 @@ export const layer = Layer.effect( : undefined const variant = input.variant ?? (ag.variant && full?.variants?.[ag.variant] ? ag.variant : undefined) - const info: MessageV2.User = { + const info: SessionLegacy.User = { id: input.messageID ?? MessageID.ascending(), role: "user", sessionID: input.sessionID, @@ -759,8 +762,8 @@ export const layer = Layer.effect( yield* Effect.addFinalizer(() => instruction.clear(info.id)) - type Draft = T extends MessageV2.Part ? Omit & { id?: string } : never - const assign = (part: Draft): MessageV2.Part => ({ + type Draft = T extends SessionLegacy.Part ? Omit & { id?: string } : never + const assign = (part: Draft): SessionLegacy.Part => ({ ...part, id: part.id ? PartID.make(part.id) : PartID.ascending(), }) @@ -789,14 +792,14 @@ export const layer = Layer.effect( }) }) - const resolvePart: (part: PromptInput["parts"][number]) => Effect.Effect[]> = Effect.fn( + const resolvePart: (part: PromptInput["parts"][number]) => Effect.Effect[]> = Effect.fn( "SessionPrompt.resolveUserPart", )(function* (part) { if (part.type === "file") { if (part.source?.type === "resource") { const { clientName, uri } = part.source log.info("mcp resource", { clientName, uri, mime: part.mime }) - const pieces: Draft[] = [ + const pieces: Draft[] = [ { messageID: info.id, sessionID: input.sessionID, @@ -916,7 +919,7 @@ export const layer = Layer.effect( if (end) limit = end - (offset - 1) } const args = { filePath: filepath, offset, limit } - const pieces: Draft[] = [ + const pieces: Draft[] = [ ...(referenceContext ? [{ ...referenceContext, messageID: info.id, sessionID: input.sessionID }] : []), @@ -958,7 +961,7 @@ export const layer = Layer.effect( const error = Cause.squash(exit.cause) log.error("failed to read file", { error }) const message = error instanceof Error ? error.message : String(error) - yield* bus.publish(Session.Event.Error, { + yield* events.publish(Session.Event.Error, { sessionID: input.sessionID, error: new NamedError.Unknown({ message }).toObject(), }) @@ -980,7 +983,7 @@ export const layer = Layer.effect( const error = Cause.squash(exit.cause) log.error("failed to read directory", { error }) const message = error instanceof Error ? error.message : String(error) - yield* bus.publish(Session.Event.Error, { + yield* events.publish(Session.Event.Error, { sessionID: input.sessionID, error: new NamedError.Unknown({ message }).toObject(), }) @@ -1212,7 +1215,7 @@ export const layer = Layer.effect( return { info, parts } }, Effect.scoped) - const prompt: (input: PromptInput) => Effect.Effect = Effect.fn( + const prompt: (input: PromptInput) => Effect.Effect = Effect.fn( "SessionPrompt.prompt", )(function* (input: PromptInput) { const session = yield* sessions.get(input.sessionID).pipe(Effect.orDie) @@ -1241,7 +1244,7 @@ export const layer = Layer.effect( throw new Error("Impossible") }) - const runLoop: (sessionID: SessionID) => Effect.Effect = Effect.fn("SessionPrompt.run")( + const runLoop: (sessionID: SessionID) => Effect.Effect = Effect.fn("SessionPrompt.run")( function* (sessionID: SessionID) { const ctx = yield* InstanceState.context const slog = elog.with({ sessionID }) @@ -1253,7 +1256,9 @@ export const layer = Layer.effect( yield* status.set(sessionID, { type: "busy" }) yield* slog.info("loop", { step }) - let msgs = yield* MessageV2.filterCompactedEffect(sessionID) + let msgs = yield* MessageV2.filterCompactedEffect(sessionID).pipe( + Effect.provideService(Database.Service, database), + ) const { user: lastUser, assistant: lastAssistant, finished: lastFinished, tasks } = MessageV2.latest(msgs) @@ -1277,7 +1282,7 @@ export const layer = Layer.effect( lastUser.id < lastAssistant.id ) { const orphan = lastAssistantMsg?.parts.find( - (part): part is MessageV2.ToolPart => part.type === "tool" && isOrphanedInterruptedTool(part), + (part): part is SessionLegacy.ToolPart => part.type === "tool" && isOrphanedInterruptedTool(part), ) if (orphan) { yield* slog.warn("loop exit with orphaned interrupted tool", { @@ -1333,7 +1338,7 @@ export const layer = Layer.effect( const available = (yield* agents.list()).filter((a) => !a.hidden).map((a) => a.name) const hint = available.length ? ` Available agents: ${available.join(", ")}` : "" const error = new NamedError.Unknown({ message: `Agent not found: "${lastUser.agent}".${hint}` }) - yield* bus.publish(Session.Event.Error, { sessionID, error: error.toObject() }) + yield* events.publish(Session.Event.Error, { sessionID, error: error.toObject() }) throw error } const maxSteps = agent.steps ?? Infinity @@ -1344,7 +1349,7 @@ export const layer = Layer.effect( Effect.provideService(Session.Service, sessions), ) - const msg: MessageV2.Assistant = { + const msg: SessionLegacy.Assistant = { id: MessageID.ascending(), parentID: lastUser.id, role: "assistant", @@ -1464,7 +1469,7 @@ export const layer = Layer.effect( const finished = handle.message.finish && !["tool-calls", "unknown"].includes(handle.message.finish) if (finished && !handle.message.error) { if (format.type === "json_schema") { - handle.message.error = new MessageV2.StructuredOutputError({ + handle.message.error = new SessionLegacy.StructuredOutputError({ message: "Model did not produce structured output", retries: 0, }).toObject() @@ -1497,13 +1502,13 @@ export const layer = Layer.effect( }, ) - const loop: (input: LoopInput) => Effect.Effect = Effect.fn("SessionPrompt.loop")(function* ( + const loop: (input: LoopInput) => Effect.Effect = Effect.fn("SessionPrompt.loop")(function* ( input: LoopInput, ) { return yield* state.ensureRunning(input.sessionID, lastAssistant(input.sessionID), runLoop(input.sessionID)) }) - const shell: (input: ShellInput) => Effect.Effect = Effect.fn( + const shell: (input: ShellInput) => Effect.Effect = Effect.fn( "SessionPrompt.shell", )(function* (input: ShellInput) { const ready = yield* Latch.make() @@ -1517,7 +1522,7 @@ export const layer = Layer.effect( const available = (yield* commands.list()).map((c) => c.name) const hint = available.length ? ` Available commands: ${available.join(", ")}` : "" const error = new NamedError.Unknown({ message: `Command not found: "${input.command}".${hint}` }) - yield* bus.publish(Session.Event.Error, { sessionID: input.sessionID, error: error.toObject() }) + yield* events.publish(Session.Event.Error, { sessionID: input.sessionID, error: error.toObject() }) throw error } const agentName = cmd.agent ?? input.agent @@ -1578,7 +1583,7 @@ export const layer = Layer.effect( const available = (yield* agents.list()).filter((a) => !a.hidden).map((a) => a.name) const hint = available.length ? ` Available agents: ${available.join(", ")}` : "" const error = new NamedError.Unknown({ message: `Agent not found: "${agentName}".${hint}` }) - yield* bus.publish(Session.Event.Error, { sessionID: input.sessionID, error: error.toObject() }) + yield* events.publish(Session.Event.Error, { sessionID: input.sessionID, error: error.toObject() }) throw error } @@ -1618,7 +1623,7 @@ export const layer = Layer.effect( parts, variant: input.variant, }) - yield* bus.publish(Command.Event.Executed, { + yield* events.publish(Command.Event.Executed, { name: input.command, sessionID: input.sessionID, arguments: input.arguments, @@ -1661,21 +1666,21 @@ export const defaultLayer = Layer.suspend(() => Layer.provide(Image.defaultLayer), Layer.provide( Layer.mergeAll( - EventV2Bridge.defaultLayer, Agent.defaultLayer, + Database.defaultLayer, SystemPrompt.defaultLayer, LLM.defaultLayer, Reference.defaultLayer, - Bus.layer, CrossSpawnSpawner.defaultLayer, RuntimeFlags.defaultLayer, + EventV2Bridge.defaultLayer, ), ), ), ) const ModelRef = Schema.Struct({ - providerID: ProviderID, - modelID: ModelID, + providerID: ProviderV2.ID, + modelID: ProviderV2.ModelID, }) export const PromptInput = Schema.Struct({ @@ -1688,15 +1693,15 @@ export const PromptInput = Schema.Struct({ description: "@deprecated tools and permissions have been merged, you can set permissions on the session itself now", }), - format: Schema.optional(MessageV2.Format), + format: Schema.optional(SessionLegacy.Format), system: Schema.optional(Schema.String), variant: Schema.optional(Schema.String), parts: Schema.Array( Schema.Union([ - MessageV2.TextPartInput, - MessageV2.FilePartInput, - MessageV2.AgentPartInput, - MessageV2.SubtaskPartInput, + SessionLegacy.TextPartInput, + SessionLegacy.FilePartInput, + SessionLegacy.AgentPartInput, + SessionLegacy.SubtaskPartInput, ]).annotate({ discriminator: "type" }), ), }) @@ -1735,7 +1740,7 @@ export const CommandInput = Schema.Struct({ mime: Schema.String, filename: Schema.optional(Schema.String), url: Schema.String, - source: Schema.optional(MessageV2.FilePartSource), + source: Schema.optional(SessionLegacy.FilePartSource), }), ]).annotate({ discriminator: "type" }), ), diff --git a/packages/opencode/src/session/prompt/reference.ts b/packages/opencode/src/session/prompt/reference.ts index ae1a465798..de20b7f45f 100644 --- a/packages/opencode/src/session/prompt/reference.ts +++ b/packages/opencode/src/session/prompt/reference.ts @@ -1,4 +1,5 @@ import { Option, Schema } from "effect" +import { SessionLegacy } from "@opencode-ai/core/session/legacy" import { MessageV2 } from "../message-v2" import { Reference } from "@/reference/reference" @@ -33,7 +34,7 @@ export function referenceTextPart(input: { target?: string targetPath?: string problem?: string -}): MessageV2.TextPartInput { +}): SessionLegacy.TextPartInput { const metadata: ReferencePromptMetadata = { name: input.reference.name, kind: input.reference.kind, diff --git a/packages/opencode/src/session/reminders.ts b/packages/opencode/src/session/reminders.ts index a11bd5e67b..206304b393 100644 --- a/packages/opencode/src/session/reminders.ts +++ b/packages/opencode/src/session/reminders.ts @@ -1,4 +1,5 @@ import path from "path" +import { SessionLegacy } from "@opencode-ai/core/session/legacy" import { Effect } from "effect" import { Agent } from "@/agent/agent" import { AppFileSystem } from "@opencode-ai/core/filesystem" @@ -12,7 +13,7 @@ import BUILD_SWITCH from "./prompt/build-switch.txt" import PLAN_MODE from "./prompt/plan-mode.txt" export const apply = Effect.fn("SessionReminders.apply")(function* (input: { - messages: MessageV2.WithParts[] + messages: SessionLegacy.WithParts[] agent: Agent.Info session: Session.Info }) { diff --git a/packages/opencode/src/session/retry.ts b/packages/opencode/src/session/retry.ts index 463bc27a95..bcfb54c475 100644 --- a/packages/opencode/src/session/retry.ts +++ b/packages/opencode/src/session/retry.ts @@ -1,4 +1,5 @@ import type { NamedError } from "@opencode-ai/core/util/error" +import { SessionLegacy } from "@opencode-ai/core/session/legacy" import { Cause, Clock, Duration, Effect, Schedule } from "effect" import { MessageV2 } from "./message-v2" import { iife } from "@/util/iife" @@ -31,7 +32,7 @@ function cap(ms: number) { return Math.min(ms, RETRY_MAX_DELAY) } -export function delay(attempt: number, error?: MessageV2.APIError) { +export function delay(attempt: number, error?: SessionLegacy.APIError) { if (error) { const headers = error.data.responseHeaders if (headers) { @@ -66,8 +67,8 @@ export function delay(attempt: number, error?: MessageV2.APIError) { export function retryable(error: Err, provider: string) { // context overflow errors should not be retried - if (MessageV2.ContextOverflowError.isInstance(error)) return undefined - if (MessageV2.APIError.isInstance(error)) { + if (SessionLegacy.ContextOverflowError.isInstance(error)) return undefined + if (SessionLegacy.APIError.isInstance(error)) { const status = error.data.statusCode // 5xx errors are transient server failures and should always be retried, // even when the provider SDK doesn't explicitly mark them as retryable. @@ -183,7 +184,7 @@ export function policy(opts: { const retry = retryable(error, opts.provider) if (!retry) return Cause.done(meta.attempt) return Effect.gen(function* () { - const wait = delay(meta.attempt, MessageV2.APIError.isInstance(error) ? error : undefined) + const wait = delay(meta.attempt, SessionLegacy.APIError.isInstance(error) ? error : undefined) const now = yield* Clock.currentTimeMillis yield* opts.set({ attempt: meta.attempt, diff --git a/packages/opencode/src/session/revert.ts b/packages/opencode/src/session/revert.ts index 950d533a3d..f33330704d 100644 --- a/packages/opencode/src/session/revert.ts +++ b/packages/opencode/src/session/revert.ts @@ -1,8 +1,8 @@ import { Effect, Layer, Context, Schema } from "effect" -import { Bus } from "../bus" +import { SessionLegacy } from "@opencode-ai/core/session/legacy" +import { EventV2Bridge } from "@/event-v2-bridge" import { Snapshot } from "../snapshot" import { Storage } from "@/storage/storage" -import { SyncEvent } from "../sync" import * as Log from "@opencode-ai/core/util/log" import * as Session from "./session" import { MessageV2 } from "./message-v2" @@ -33,15 +33,14 @@ export const layer = Layer.effect( const sessions = yield* Session.Service const snap = yield* Snapshot.Service const storage = yield* Storage.Service - const bus = yield* Bus.Service + const events = yield* EventV2Bridge.Service const summary = yield* SessionSummary.Service const state = yield* SessionRunState.Service - const sync = yield* SyncEvent.Service const revert = Effect.fn("SessionRevert.revert")(function* (input: RevertInput) { yield* state.assertNotBusy(input.sessionID) const all = yield* sessions.messages({ sessionID: input.sessionID }).pipe(Effect.orDie) - let lastUser: MessageV2.User | undefined + let lastUser: SessionLegacy.User | undefined const session = yield* sessions.get(input.sessionID).pipe(Effect.orDie) let rev: Session.Info["revert"] @@ -77,7 +76,7 @@ export const layer = Layer.effect( const range = all.filter((msg) => msg.info.id >= rev.messageID) const diffs = yield* summary.computeDiff({ messages: range }) yield* storage.write(["session_diff", input.sessionID], diffs).pipe(Effect.ignore) - yield* bus.publish(Session.Event.Diff, { sessionID: input.sessionID, diff: diffs }) + yield* events.publish(Session.Event.Diff, { sessionID: input.sessionID, diff: diffs }) yield* sessions.setRevert({ sessionID: input.sessionID, revert: rev, @@ -105,8 +104,8 @@ export const layer = Layer.effect( const sessionID = session.id const msgs = yield* sessions.messages({ sessionID }).pipe(Effect.orDie) const messageID = session.revert.messageID - const remove = [] as MessageV2.WithParts[] - let target: MessageV2.WithParts | undefined + const remove = [] as SessionLegacy.WithParts[] + let target: SessionLegacy.WithParts | undefined for (const msg of msgs) { if (msg.info.id < messageID) continue if (msg.info.id > messageID) { @@ -120,10 +119,7 @@ export const layer = Layer.effect( remove.push(msg) } for (const msg of remove) { - yield* sync.run(MessageV2.Event.Removed, { - sessionID, - messageID: msg.info.id, - }) + yield* sessions.removeMessage({ sessionID, messageID: msg.info.id }) } if (session.revert.partID && target) { const partID = session.revert.partID @@ -132,11 +128,7 @@ export const layer = Layer.effect( const removeParts = target.parts.slice(idx) target.parts = target.parts.slice(0, idx) for (const part of removeParts) { - yield* sync.run(MessageV2.Event.PartRemoved, { - sessionID, - messageID: target.info.id, - partID: part.id, - }) + yield* sessions.removePart({ sessionID, messageID: target.info.id, partID: part.id }) } } } @@ -153,9 +145,8 @@ export const defaultLayer = Layer.suspend(() => Layer.provide(Session.defaultLayer), Layer.provide(Snapshot.defaultLayer), Layer.provide(Storage.defaultLayer), - Layer.provide(Bus.layer), + Layer.provide(EventV2Bridge.defaultLayer), Layer.provide(SessionSummary.defaultLayer), - Layer.provide(SyncEvent.defaultLayer), ), ) diff --git a/packages/opencode/src/session/run-state.ts b/packages/opencode/src/session/run-state.ts index 8f0051dfba..1b92dce682 100644 --- a/packages/opencode/src/session/run-state.ts +++ b/packages/opencode/src/session/run-state.ts @@ -1,4 +1,5 @@ import { InstanceState } from "@/effect/instance-state" +import { SessionLegacy } from "@opencode-ai/core/session/legacy" import { Runner } from "@/effect/runner" import { BackgroundJob } from "@/background/job" import { Effect, Latch, Layer, Scope, Context } from "effect" @@ -12,15 +13,15 @@ export interface Interface { readonly cancel: (sessionID: SessionID) => Effect.Effect readonly ensureRunning: ( sessionID: SessionID, - onInterrupt: Effect.Effect, - work: Effect.Effect, - ) => Effect.Effect + onInterrupt: Effect.Effect, + work: Effect.Effect, + ) => Effect.Effect readonly startShell: ( sessionID: SessionID, - onInterrupt: Effect.Effect, - work: Effect.Effect, + onInterrupt: Effect.Effect, + work: Effect.Effect, ready?: Latch.Latch, - ) => Effect.Effect + ) => Effect.Effect } export class Service extends Context.Service()("@opencode/SessionRunState") {} @@ -34,7 +35,7 @@ export const layer = Layer.effect( const state = yield* InstanceState.make( Effect.fn("SessionRunState.state")(function* () { const scope = yield* Scope.Scope - const runners = new Map>() + const runners = new Map>() yield* Effect.addFinalizer( Effect.fnUntraced(function* () { yield* Effect.forEach(runners.values(), (runner) => runner.cancel, { @@ -50,12 +51,12 @@ export const layer = Layer.effect( const runner = Effect.fn("SessionRunState.runner")(function* ( sessionID: SessionID, - onInterrupt: Effect.Effect, + onInterrupt: Effect.Effect, ) { const data = yield* InstanceState.get(state) const existing = data.runners.get(sessionID) if (existing) return existing - const next = Runner.make(data.scope, { + const next = Runner.make(data.scope, { onIdle: Effect.gen(function* () { data.runners.delete(sessionID) yield* status.set(sessionID, { type: "idle" }) @@ -86,16 +87,16 @@ export const layer = Layer.effect( const ensureRunning = Effect.fn("SessionRunState.ensureRunning")(function* ( sessionID: SessionID, - onInterrupt: Effect.Effect, - work: Effect.Effect, + onInterrupt: Effect.Effect, + work: Effect.Effect, ) { return yield* (yield* runner(sessionID, onInterrupt)).ensureRunning(work) }) const startShell = Effect.fn("SessionRunState.startShell")(function* ( sessionID: SessionID, - onInterrupt: Effect.Effect, - work: Effect.Effect, + onInterrupt: Effect.Effect, + work: Effect.Effect, ready?: Latch.Latch, ) { return yield* (yield* runner(sessionID, onInterrupt)) diff --git a/packages/opencode/src/session/schema.ts b/packages/opencode/src/session/schema.ts index f1622b6958..4a49d110c8 100644 --- a/packages/opencode/src/session/schema.ts +++ b/packages/opencode/src/session/schema.ts @@ -1,10 +1,10 @@ import { Schema } from "effect" import { Identifier } from "@/id/id" -import { Session as CoreSession } from "@opencode-ai/core/session" +import { SessionV2 } from "@opencode-ai/core/session" import { withStatics } from "@opencode-ai/core/schema" -export const SessionID = CoreSession.ID +export const SessionID = SessionV2.ID export type SessionID = Schema.Schema.Type export const MessageID = Schema.String.check(Schema.isStartsWith("msg")).pipe( diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index e84da3340c..b055c488b8 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -1,14 +1,17 @@ import { Slug } from "@opencode-ai/core/util/slug" +import { SessionLegacy } from "@opencode-ai/core/session/legacy" import { serviceUse } from "@opencode-ai/core/effect/service-use" import path from "path" import { BackgroundJob } from "@/background/job" -import { BusEvent } from "@/bus/bus-event" -import { Bus } from "@/bus" import { Decimal } from "decimal.js" import type { ProviderMetadata, Usage } from "@opencode-ai/llm" import { InstallationVersion } from "@opencode-ai/core/installation/version" +import { Database } from "@opencode-ai/core/database/database" +import { makeRuntime } from "@opencode-ai/core/effect/runtime" +import { EventV2Bridge } from "@/event-v2-bridge" +import { EventV2 } from "@opencode-ai/core/event" +import { SessionV2 } from "@opencode-ai/core/session" -import { Database } from "@/storage/db" import { NotFoundError } from "@/storage/storage" import { eq } from "drizzle-orm" import { and } from "drizzle-orm" @@ -19,29 +22,29 @@ import { like } from "drizzle-orm" import { inArray } from "drizzle-orm" import { lt } from "drizzle-orm" import { or } from "drizzle-orm" -import { SyncEvent } from "../sync" import type { SQL } from "drizzle-orm" -import { PartTable, SessionTable } from "./session.sql" -import { ProjectTable } from "../project/project.sql" +import { PartTable, SessionTable } from "@opencode-ai/core/session/sql" +import { ProjectTable } from "@opencode-ai/core/project/sql" import { Storage } from "@/storage/storage" import * as Log from "@opencode-ai/core/util/log" import { MessageV2 } from "./message-v2" import type { InstanceContext } from "../project/instance-context" import { InstanceState } from "@/effect/instance-state" import { Snapshot } from "@/snapshot" -import { ProjectID } from "../project/schema" -import { WorkspaceID } from "../control-plane/schema" +import { ProjectV2 } from "@opencode-ai/core/project" +import { WorkspaceV2 } from "@opencode-ai/core/workspace" import { SessionID, MessageID, PartID } from "./schema" -import { ModelID, ProviderID } from "@/provider/schema" import type { Provider } from "@/provider/provider" import { Permission } from "@/permission" import { Global } from "@opencode-ai/core/global" import { Effect, Layer, Option, Context, Schema, Types } from "effect" -import { NonNegativeInt, optionalOmitUndefined } from "@opencode-ai/core/schema" +import { AbsolutePath, NonNegativeInt, optionalOmitUndefined } from "@opencode-ai/core/schema" import { RuntimeFlags } from "@/effect/runtime-flags" +import { ProviderV2 } from "@opencode-ai/core/provider" const log = Log.create({ service: "session" }) +const runtime = makeRuntime(Database.Service, Database.defaultLayer) const parentTitlePrefix = "New session - " const childTitlePrefix = "Child session - " @@ -82,8 +85,8 @@ export function fromRow(row: SessionRow): Info { agent: row.agent ?? undefined, model: row.model ? { - id: ModelID.make(row.model.id), - providerID: ProviderID.make(row.model.providerID), + id: ProviderV2.ModelID.make(row.model.id), + providerID: ProviderV2.ID.make(row.model.providerID), variant: row.model.variant, } : undefined, @@ -112,6 +115,13 @@ export function fromRow(row: SessionRow): Info { } } +function eventLocation(info: Pick) { + return { + directory: AbsolutePath.make(info.directory), + workspaceID: info.workspaceID, + } +} + export function toRow(info: Info) { return { id: info.id, @@ -202,8 +212,8 @@ const Revert = Schema.Struct({ }) const Model = Schema.Struct({ - id: ModelID, - providerID: ProviderID, + id: ProviderV2.ModelID, + providerID: ProviderV2.ID, variant: optionalOmitUndefined(Schema.String), }) @@ -212,8 +222,8 @@ export const Metadata = Schema.Record(Schema.String, Schema.Any) export const Info = Schema.Struct({ id: SessionID, slug: Schema.String, - projectID: ProjectID, - workspaceID: optionalOmitUndefined(WorkspaceID), + projectID: ProjectV2.ID, + workspaceID: optionalOmitUndefined(WorkspaceV2.ID), directory: Schema.String, path: optionalOmitUndefined(Schema.String), parentID: optionalOmitUndefined(SessionID), @@ -233,7 +243,7 @@ export const Info = Schema.Struct({ export type Info = Types.DeepMutable> export const ProjectInfo = Schema.Struct({ - id: ProjectID, + id: ProjectV2.ID, name: optionalOmitUndefined(Schema.String), worktree: Schema.String, }).annotate({ identifier: "ProjectSummary" }) @@ -253,7 +263,7 @@ export const CreateInput = Schema.optional( model: Schema.optional(Model), metadata: Schema.optional(Metadata), permission: Schema.optional(Permission.Ruleset), - workspaceID: Schema.optional(WorkspaceID), + workspaceID: Schema.optional(WorkspaceV2.ID), }), ) export type CreateInput = Types.DeepMutable> @@ -291,13 +301,23 @@ export type ListInput = { directory?: string scope?: "project" path?: string - workspaceID?: WorkspaceID + workspaceID?: WorkspaceV2.ID roots?: boolean start?: number search?: string limit?: number } +export type GlobalListInput = { + directory?: string + roots?: boolean + start?: number + cursor?: number + search?: string + limit?: number + archived?: boolean +} + const CreatedEventSchema = Schema.Struct({ sessionID: SessionID, info: Info, @@ -317,8 +337,8 @@ const UpdatedTime = Schema.Struct({ const UpdatedInfo = Schema.Struct({ id: Schema.optional(Schema.NullOr(SessionID)), slug: Schema.optional(Schema.NullOr(Schema.String)), - projectID: Schema.optional(Schema.NullOr(ProjectID)), - workspaceID: Schema.optional(Schema.NullOr(WorkspaceID)), + projectID: Schema.optional(Schema.NullOr(ProjectV2.ID)), + workspaceID: Schema.optional(Schema.NullOr(WorkspaceV2.ID)), directory: Schema.optional(Schema.NullOr(Schema.String)), path: Schema.optional(Schema.NullOr(Schema.String)), parentID: Schema.optional(Schema.NullOr(SessionID)), @@ -342,41 +362,25 @@ const UpdatedEventSchema = Schema.Struct({ }) export const Event = { - Created: SyncEvent.define({ - type: "session.created", - version: 1, - aggregate: "sessionID", - schema: CreatedEventSchema, - }), - Updated: SyncEvent.define({ - type: "session.updated", - version: 1, - aggregate: "sessionID", - schema: UpdatedEventSchema, - busSchema: CreatedEventSchema, - }), - Deleted: SyncEvent.define({ - type: "session.deleted", - version: 1, - aggregate: "sessionID", - schema: CreatedEventSchema, - }), - Diff: BusEvent.define( - "session.diff", - Schema.Struct({ + Created: SessionLegacy.Event.Created, + Updated: SessionLegacy.Event.Updated, + Deleted: SessionLegacy.Event.Deleted, + Diff: EventV2.define({ + type: "session.diff", + schema: { sessionID: SessionID, diff: Schema.Array(Snapshot.FileDiff), - }), - ), - Error: BusEvent.define( - "session.error", - Schema.Struct({ + }, + }), + Error: EventV2.define({ + type: "session.error", + schema: { sessionID: Schema.optional(SessionID), - // Reuses MessageV2.Assistant.fields.error (already Schema.optional) so - // the derived zod keeps the same discriminated-union shape on the bus. - error: MessageV2.Assistant.fields.error, - }), - ), + // Reuses SessionLegacy.Assistant.fields.error (already Schema.optional) so + // the derived schema keeps the same discriminated-union shape on the event stream. + error: SessionLegacy.Assistant.fields.error, + }, + }), } export function plan(input: { slug: string; time: { created: number } }, instance: InstanceContext) { @@ -461,6 +465,7 @@ export type NotFound = NotFoundError export interface Interface { readonly list: (input?: ListInput) => Effect.Effect + readonly listGlobal: (input?: GlobalListInput) => Effect.Effect readonly create: (input?: { parentID?: SessionID title?: string @@ -468,7 +473,7 @@ export interface Interface { model?: Schema.Schema.Type metadata?: typeof Metadata.Type permission?: Permission.Ruleset - workspaceID?: WorkspaceID + workspaceID?: WorkspaceV2.ID }) => Effect.Effect readonly fork: (input: { sessionID: SessionID; messageID?: MessageID }) => Effect.Effect readonly touch: (sessionID: SessionID) => Effect.Effect @@ -484,19 +489,24 @@ export interface Interface { }) => Effect.Effect readonly clearRevert: (sessionID: SessionID) => Effect.Effect readonly setSummary: (input: { sessionID: SessionID; summary: Info["summary"] }) => Effect.Effect + readonly setShare: (input: { sessionID: SessionID; share: Info["share"] }) => Effect.Effect + readonly setWorkspace: (input: { sessionID: SessionID; workspaceID: Info["workspaceID"] }) => Effect.Effect readonly diff: (sessionID: SessionID) => Effect.Effect - readonly messages: (input: { sessionID: SessionID; limit?: number }) => Effect.Effect + readonly messages: (input: { + sessionID: SessionID + limit?: number + }) => Effect.Effect readonly children: (parentID: SessionID) => Effect.Effect readonly remove: (sessionID: SessionID) => Effect.Effect - readonly updateMessage: (msg: T) => Effect.Effect + readonly updateMessage: (msg: T) => Effect.Effect readonly removeMessage: (input: { sessionID: SessionID; messageID: MessageID }) => Effect.Effect readonly removePart: (input: { sessionID: SessionID; messageID: MessageID; partID: PartID }) => Effect.Effect readonly getPart: (input: { sessionID: SessionID messageID: MessageID partID: PartID - }) => Effect.Effect - readonly updatePart: (part: T) => Effect.Effect + }) => Effect.Effect + readonly updatePart: (part: T) => Effect.Effect readonly updatePartDelta: (input: { sessionID: SessionID messageID: MessageID @@ -507,39 +517,61 @@ export interface Interface { /** Finds the first message matching the predicate, searching newest-first. */ readonly findMessage: ( sessionID: SessionID, - predicate: (msg: MessageV2.WithParts) => boolean, - ) => Effect.Effect, NotFound> + predicate: (msg: SessionLegacy.WithParts) => boolean, + ) => Effect.Effect, NotFound> } export class Service extends Context.Service()("@opencode/Session") {} export const use = serviceUse(Service) -export type Patch = Types.DeepMutable["data"]["info"]> - -const db = (fn: (d: Parameters[0] extends (trx: infer D) => any ? D : never) => T) => - Effect.sync(() => Database.use(fn)) +export type Patch = Omit, "time" | "share" | "summary" | "revert" | "permission"> & { + time?: Partial + share?: Partial> | null + summary?: Info["summary"] | null + revert?: Info["revert"] | null + permission?: Info["permission"] | null +} export const layer: Layer.Layer< Service, never, - BackgroundJob.Service | Bus.Service | Storage.Service | SyncEvent.Service | RuntimeFlags.Service + | BackgroundJob.Service + | Storage.Service + | RuntimeFlags.Service + | Database.Service + | EventV2Bridge.Service > = Layer.effect( Service, Effect.gen(function* () { + const { db } = yield* Database.Service + const database = yield* Database.Service const background = yield* BackgroundJob.Service - const bus = yield* Bus.Service + const events = yield* EventV2Bridge.Service const storage = yield* Storage.Service - const sync = yield* SyncEvent.Service const flags = yield* RuntimeFlags.Service + const locationForSession = Effect.fnUntraced(function* (sessionID: SessionID) { + const row = yield* db + .select({ directory: SessionTable.directory, workspaceID: SessionTable.workspace_id }) + .from(SessionTable) + .where(eq(SessionTable.id, sessionID)) + .get() + .pipe(Effect.orDie) + if (!row) return + return { + directory: AbsolutePath.make(row.directory), + workspaceID: row.workspaceID ?? undefined, + } + }) + const createNext = Effect.fn("Session.createNext")(function* (input: { id?: SessionID title?: string agent?: string model?: Schema.Schema.Type parentID?: SessionID - workspaceID?: WorkspaceID + workspaceID?: WorkspaceV2.ID directory: string path?: string metadata?: typeof Metadata.Type @@ -569,41 +601,78 @@ export const layer: Layer.Layer< } log.info("created", result) - yield* sync.run(Event.Created, { sessionID: result.id, info: result }) - - if (!flags.experimentalWorkspaces) { - // This only exist for backwards compatibility. We should not be - // manually publishing this event; it is a sync event now - yield* bus.publish(Event.Updated, { - sessionID: result.id, - info: result, - }) - } + yield* events.publish( + SessionLegacy.Event.Created, + { sessionID: result.id, info: result }, + { location: eventLocation(result) }, + ) return result }) const get = Effect.fn("Session.get")(function* (id: SessionID) { - const row = yield* db((d) => d.select().from(SessionTable).where(eq(SessionTable.id, id)).get()) + const row = yield* db.select().from(SessionTable).where(eq(SessionTable.id, id)).get().pipe(Effect.orDie) if (!row) return yield* Effect.fail(new NotFoundError({ message: `Session not found: ${id}` })) return fromRow(row) }) const list = Effect.fn("Session.list")(function* (input?: ListInput) { const ctx = yield* InstanceState.context - return Array.from( - listByProject({ projectID: ctx.project.id, experimentalWorkspaces: flags.experimentalWorkspaces, ...input }), - ) + return yield* listByProject(db, { + projectID: ctx.project.id, + experimentalWorkspaces: flags.experimentalWorkspaces, + ...input, + }) + }) + + const listGlobal = Effect.fn("Session.listGlobal")(function* (input?: GlobalListInput) { + const conditions: SQL[] = [] + if (input?.directory) conditions.push(eq(SessionTable.directory, input.directory)) + if (input?.roots) conditions.push(isNull(SessionTable.parent_id)) + if (input?.start) conditions.push(gte(SessionTable.time_updated, input.start)) + if (input?.cursor) conditions.push(lt(SessionTable.time_updated, input.cursor)) + if (input?.search) conditions.push(like(SessionTable.title, `%${input.search}%`)) + if (!input?.archived) conditions.push(isNull(SessionTable.time_archived)) + + const query = + conditions.length > 0 + ? db + .select() + .from(SessionTable) + .where(and(...conditions)) + : db.select().from(SessionTable) + const rows = yield* query + .orderBy(desc(SessionTable.time_updated), desc(SessionTable.id)) + .limit(input?.limit ?? 100) + .all() + .pipe(Effect.orDie) + const ids = [...new Set(rows.map((row) => row.project_id))] + const projects = new Map() + if (ids.length > 0) { + const items = yield* db + .select({ id: ProjectTable.id, name: ProjectTable.name, worktree: ProjectTable.worktree }) + .from(ProjectTable) + .where(inArray(ProjectTable.id, ids)) + .all() + .pipe(Effect.orDie) + for (const item of items) { + projects.set(item.id, { + id: item.id, + name: item.name ?? undefined, + worktree: item.worktree, + }) + } + } + return rows.map((row) => ({ ...fromRow(row), project: projects.get(row.project_id) ?? null })) }) const children = Effect.fn("Session.children")(function* (parentID: SessionID) { - const rows = yield* db((d) => - d - .select() - .from(SessionTable) - .where(and(eq(SessionTable.parent_id, parentID))) - .all(), - ) + const rows = yield* db + .select() + .from(SessionTable) + .where(and(eq(SessionTable.parent_id, parentID))) + .all() + .pipe(Effect.orDie) return rows.map(fromRow) }) @@ -623,50 +692,59 @@ export const layer: Layer.Layer< yield* remove(child.id) } - yield* sync.run(Event.Deleted, { sessionID, info: session }, { publish: hasInstance }) - yield* sync.remove(sessionID) + yield* events.publish( + SessionLegacy.Event.Deleted, + { sessionID, info: session }, + { location: eventLocation(session) }, + ) + yield* events.remove(sessionID) } catch (e) { log.error(e) } }) - const updateMessage = (msg: T): Effect.Effect => + const updateMessage = (msg: T): Effect.Effect => Effect.gen(function* () { - yield* sync.run(MessageV2.Event.Updated, { sessionID: msg.sessionID, info: msg }) + const location = yield* locationForSession(msg.sessionID) + yield* events.publish(SessionLegacy.Event.MessageUpdated, { sessionID: msg.sessionID, info: msg }, { location }) return msg }).pipe(Effect.withSpan("Session.updateMessage")) - const updatePart = (part: T): Effect.Effect => + const updatePart = (part: T): Effect.Effect => Effect.gen(function* () { - yield* sync.run(MessageV2.Event.PartUpdated, { - sessionID: part.sessionID, - part: structuredClone(part), - time: Date.now(), - }) + const location = yield* locationForSession(part.sessionID) + yield* events.publish( + SessionLegacy.Event.PartUpdated, + { + sessionID: part.sessionID, + part: structuredClone(part), + time: Date.now(), + }, + { location }, + ) return part }).pipe(Effect.withSpan("Session.updatePart")) const getPart: Interface["getPart"] = Effect.fn("Session.getPart")(function* (input) { - const row = Database.use((db) => - db - .select() - .from(PartTable) - .where( - and( - eq(PartTable.session_id, input.sessionID), - eq(PartTable.message_id, input.messageID), - eq(PartTable.id, input.partID), - ), - ) - .get(), - ) + const row = yield* db + .select() + .from(PartTable) + .where( + and( + eq(PartTable.session_id, input.sessionID), + eq(PartTable.message_id, input.messageID), + eq(PartTable.id, input.partID), + ), + ) + .get() + .pipe(Effect.orDie) if (!row) return return { ...row.data, id: row.id, sessionID: row.session_id, messageID: row.message_id, - } as MessageV2.Part + } as SessionLegacy.Part }) const create = Effect.fn("Session.create")(function* (input?: { @@ -676,7 +754,7 @@ export const layer: Layer.Layer< model?: Schema.Schema.Type metadata?: typeof Metadata.Type permission?: Permission.Ruleset - workspaceID?: WorkspaceID + workspaceID?: WorkspaceV2.ID }) { const ctx = yield* InstanceState.context const workspace = yield* InstanceState.workspaceID @@ -721,7 +799,7 @@ export const layer: Layer.Layer< }) for (const part of msg.parts) { - const p: MessageV2.Part = { + const p: SessionLegacy.Part = { ...part, id: PartID.ascending(), messageID: cloned.id, @@ -736,29 +814,44 @@ export const layer: Layer.Layer< return session }) - const patch = (sessionID: SessionID, info: Patch) => sync.run(Event.Updated, { sessionID, info }) + const patch = (sessionID: SessionID, info: Patch) => + Effect.gen(function* () { + const current = yield* get(sessionID) + const next = { + ...current, + ...info, + time: info.time ? { ...current.time, ...info.time } : current.time, + share: info.share === null ? undefined : info.share ? { ...current.share, ...info.share } : current.share, + summary: info.summary === null ? undefined : (info.summary ?? current.summary), + revert: info.revert === null ? undefined : (info.revert ?? current.revert), + permission: info.permission === null ? undefined : (info.permission ?? current.permission), + } as Info + yield* events.publish(SessionLegacy.Event.Updated, { sessionID, info: next }, { location: eventLocation(next) }) + }) const touch = Effect.fn("Session.touch")(function* (sessionID: SessionID) { - yield* patch(sessionID, { time: { updated: Date.now() } }) + yield* patch(sessionID, { time: { updated: Date.now() } }).pipe(Effect.orDie) }) const setTitle = Effect.fn("Session.setTitle")(function* (input: { sessionID: SessionID; title: string }) { - yield* patch(input.sessionID, { title: input.title }) + yield* patch(input.sessionID, { title: input.title }).pipe(Effect.orDie) }) const setArchived = Effect.fn("Session.setArchived")(function* (input: { sessionID: SessionID; time?: number }) { - yield* patch(input.sessionID, { time: { archived: input.time } }) + yield* patch(input.sessionID, { time: { archived: input.time } }).pipe(Effect.orDie) }) const setMetadata = Effect.fn("Session.setMetadata")(function* (input: typeof SetMetadataInput.Type) { - yield* patch(input.sessionID, { metadata: input.metadata, time: { updated: Date.now() } }) + yield* patch(input.sessionID, { metadata: input.metadata, time: { updated: Date.now() } }).pipe(Effect.orDie) }) const setPermission = Effect.fn("Session.setPermission")(function* (input: { sessionID: SessionID permission: Permission.Ruleset }) { - yield* patch(input.sessionID, { permission: [...input.permission], time: { updated: Date.now() } }) + yield* patch(input.sessionID, { permission: [...input.permission], time: { updated: Date.now() } }).pipe( + Effect.orDie, + ) }) const setRevert = Effect.fn("Session.setRevert")(function* (input: { @@ -766,18 +859,35 @@ export const layer: Layer.Layer< revert: Info["revert"] summary: Info["summary"] }) { - yield* patch(input.sessionID, { summary: input.summary, time: { updated: Date.now() }, revert: input.revert }) + yield* patch(input.sessionID, { + summary: input.summary, + time: { updated: Date.now() }, + revert: input.revert, + }).pipe(Effect.orDie) }) const clearRevert = Effect.fn("Session.clearRevert")(function* (sessionID: SessionID) { - yield* patch(sessionID, { time: { updated: Date.now() }, revert: null }) + yield* patch(sessionID, { time: { updated: Date.now() }, revert: null }).pipe(Effect.orDie) }) const setSummary = Effect.fn("Session.setSummary")(function* (input: { sessionID: SessionID summary: Info["summary"] }) { - yield* patch(input.sessionID, { time: { updated: Date.now() }, summary: input.summary }) + yield* patch(input.sessionID, { time: { updated: Date.now() }, summary: input.summary }).pipe(Effect.orDie) + }) + + const setShare = Effect.fn("Session.setShare")(function* (input: { sessionID: SessionID; share: Info["share"] }) { + yield* patch(input.sessionID, { share: input.share ?? null, time: { updated: Date.now() } }).pipe(Effect.orDie) + }) + + const setWorkspace = Effect.fn("Session.setWorkspace")(function* (input: { + sessionID: SessionID + workspaceID: Info["workspaceID"] + }) { + yield* patch(input.sessionID, { workspaceID: input.workspaceID, time: { updated: Date.now() } }).pipe( + Effect.orDie, + ) }) const diff = Effect.fn("Session.diff")(function* (sessionID: SessionID) { @@ -788,14 +898,18 @@ export const layer: Layer.Layer< const messages: Interface["messages"] = Effect.fn("Session.messages")(function* (input) { if (input.limit) { - return (yield* MessageV2.page({ sessionID: input.sessionID, limit: input.limit })).items + return (yield* MessageV2.page({ sessionID: input.sessionID, limit: input.limit }).pipe( + Effect.provideService(Database.Service, database), + )).items } const size = 50 - const result = [] as MessageV2.WithParts[] + const result = [] as SessionLegacy.WithParts[] let before: string | undefined while (true) { - const page = yield* MessageV2.page({ sessionID: input.sessionID, limit: size, before }) + const page = yield* MessageV2.page({ sessionID: input.sessionID, limit: size, before }).pipe( + Effect.provideService(Database.Service, database), + ) if (page.items.length === 0) break for (let i = page.items.length - 1; i >= 0; i--) { const item = page.items[i] @@ -811,10 +925,15 @@ export const layer: Layer.Layer< sessionID: SessionID messageID: MessageID }) { - yield* sync.run(MessageV2.Event.Removed, { - sessionID: input.sessionID, - messageID: input.messageID, - }) + const location = yield* locationForSession(input.sessionID) + yield* events.publish( + SessionLegacy.Event.MessageRemoved, + { + sessionID: input.sessionID, + messageID: input.messageID, + }, + { location }, + ) return input.messageID }) @@ -823,11 +942,16 @@ export const layer: Layer.Layer< messageID: MessageID partID: PartID }) { - yield* sync.run(MessageV2.Event.PartRemoved, { - sessionID: input.sessionID, - messageID: input.messageID, - partID: input.partID, - }) + const location = yield* locationForSession(input.sessionID) + yield* events.publish( + SessionLegacy.Event.PartRemoved, + { + sessionID: input.sessionID, + messageID: input.messageID, + partID: input.partID, + }, + { location }, + ) return input.partID }) @@ -838,7 +962,7 @@ export const layer: Layer.Layer< field: string delta: string }) { - yield* bus.publish(MessageV2.Event.PartDelta, input) + yield* events.publish(MessageV2.Event.PartDelta, input) }) /** Finds the first message matching the predicate, searching newest-first. */ @@ -846,7 +970,9 @@ export const layer: Layer.Layer< const size = 50 let before: string | undefined while (true) { - const page = yield* MessageV2.page({ sessionID, limit: size, before }) + const page = yield* MessageV2.page({ sessionID, limit: size, before }).pipe( + Effect.provideService(Database.Service, database), + ) if (page.items.length === 0) break for (let i = page.items.length - 1; i >= 0; i--) { const item = page.items[i] @@ -855,11 +981,12 @@ export const layer: Layer.Layer< if (!page.more || !page.cursor) break before = page.cursor } - return Option.none() + return Option.none() }) return Service.of({ list, + listGlobal, create, fork, touch, @@ -871,6 +998,8 @@ export const layer: Layer.Layer< setRevert, clearRevert, setSummary, + setShare, + setWorkspace, diff, messages, children, @@ -888,9 +1017,10 @@ export const layer: Layer.Layer< export const defaultLayer = layer.pipe( Layer.provide(BackgroundJob.defaultLayer), - Layer.provide(Bus.layer), Layer.provide(Storage.defaultLayer), - Layer.provide(SyncEvent.defaultLayer), + Layer.provide(Database.defaultLayer), + Layer.provide(EventV2Bridge.defaultLayer), + Layer.provide(SessionV2.defaultLayer), Layer.provide(RuntimeFlags.defaultLayer), ) @@ -911,9 +1041,10 @@ const cancelBackgroundJobs = Effect.fn("Session.cancelBackgroundJobs")(function* ) }) -function* listByProject( +function listByProject( + db: Database.Interface["db"], input: ListInput & { - projectID: ProjectID + projectID: ProjectV2.ID experimentalWorkspaces: boolean }, ) { @@ -949,18 +1080,17 @@ function* listByProject( const limit = input.limit ?? 100 - const rows = Database.use((db) => - db - .select() - .from(SessionTable) - .where(and(...conditions)) - .orderBy(desc(SessionTable.time_updated)) - .limit(limit) - .all(), - ) - for (const row of rows) { - yield fromRow(row) - } + return db + .select() + .from(SessionTable) + .where(and(...conditions)) + .orderBy(desc(SessionTable.time_updated)) + .limit(limit) + .all() + .pipe( + Effect.orDie, + Effect.map((rows) => rows.map(fromRow)), + ) } export function* listGlobal(input?: { @@ -995,7 +1125,7 @@ export function* listGlobal(input?: { const limit = input?.limit ?? 100 - const rows = Database.use((db) => { + const rows = runtime.runSync(({ db }) => { const query = conditions.length > 0 ? db @@ -1003,19 +1133,20 @@ export function* listGlobal(input?: { .from(SessionTable) .where(and(...conditions)) : db.select().from(SessionTable) - return query.orderBy(desc(SessionTable.time_updated), desc(SessionTable.id)).limit(limit).all() + return query.orderBy(desc(SessionTable.time_updated), desc(SessionTable.id)).limit(limit).all().pipe(Effect.orDie) }) const ids = [...new Set(rows.map((row) => row.project_id))] const projects = new Map() if (ids.length > 0) { - const items = Database.use((db) => + const items = runtime.runSync(({ db }) => db .select({ id: ProjectTable.id, name: ProjectTable.name, worktree: ProjectTable.worktree }) .from(ProjectTable) .where(inArray(ProjectTable.id, ids)) - .all(), + .all() + .pipe(Effect.orDie), ) for (const item of items) { projects.set(item.id, { diff --git a/packages/opencode/src/session/status.ts b/packages/opencode/src/session/status.ts index 089559e2cd..a7a6c5f87e 100644 --- a/packages/opencode/src/session/status.ts +++ b/packages/opencode/src/session/status.ts @@ -1,9 +1,9 @@ -import { BusEvent } from "@/bus/bus-event" -import { Bus } from "@/bus" import { InstanceState } from "@/effect/instance-state" import { SessionID } from "./schema" import { NonNegativeInt } from "@opencode-ai/core/schema" import { Effect, Layer, Context, Schema } from "effect" +import { EventV2Bridge } from "@/event-v2-bridge" +import { EventV2 } from "@opencode-ai/core/event" export const Info = Schema.Union([ Schema.Struct({ @@ -32,20 +32,20 @@ export const Info = Schema.Union([ export type Info = Schema.Schema.Type export const Event = { - Status: BusEvent.define( - "session.status", - Schema.Struct({ + Status: EventV2.define({ + type: "session.status", + schema: { sessionID: SessionID, status: Info, - }), - ), + }, + }), // deprecated - Idle: BusEvent.define( - "session.idle", - Schema.Struct({ + Idle: EventV2.define({ + type: "session.idle", + schema: { sessionID: SessionID, - }), - ), + }, + }), } export interface Interface { @@ -59,7 +59,7 @@ export class Service extends Context.Service()("@opencode/Se export const layer = Layer.effect( Service, Effect.gen(function* () { - const bus = yield* Bus.Service + const events = yield* EventV2Bridge.Service const state = yield* InstanceState.make( Effect.fn("SessionStatus.state")(() => Effect.succeed(new Map())), @@ -76,9 +76,9 @@ export const layer = Layer.effect( const set = Effect.fn("SessionStatus.set")(function* (sessionID: SessionID, status: Info) { const data = yield* InstanceState.get(state) - yield* bus.publish(Event.Status, { sessionID, status }) + yield* events.publish(Event.Status, { sessionID, status }) if (status.type === "idle") { - yield* bus.publish(Event.Idle, { sessionID }) + yield* events.publish(Event.Idle, { sessionID }) data.delete(sessionID) return } @@ -89,6 +89,6 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer.pipe(Layer.provide(Bus.layer)) +export const defaultLayer = layer.pipe(Layer.provide(EventV2Bridge.defaultLayer)) export * as SessionStatus from "./status" diff --git a/packages/opencode/src/session/summary.ts b/packages/opencode/src/session/summary.ts index aa4b8719bc..0b0d46526b 100644 --- a/packages/opencode/src/session/summary.ts +++ b/packages/opencode/src/session/summary.ts @@ -1,5 +1,6 @@ import { Effect, Layer, Context, Schema } from "effect" -import { Bus } from "@/bus" +import { SessionLegacy } from "@opencode-ai/core/session/legacy" +import { EventV2Bridge } from "@/event-v2-bridge" import { Snapshot } from "@/snapshot" import { Storage } from "@/storage/storage" import * as Session from "./session" @@ -65,7 +66,7 @@ function unquoteGitPath(input: string) { export interface Interface { readonly summarize: (input: { sessionID: SessionID; messageID: MessageID }) => Effect.Effect readonly diff: (input: { sessionID: SessionID; messageID?: MessageID }) => Effect.Effect - readonly computeDiff: (input: { messages: MessageV2.WithParts[] }) => Effect.Effect + readonly computeDiff: (input: { messages: SessionLegacy.WithParts[] }) => Effect.Effect } export class Service extends Context.Service()("@opencode/SessionSummary") {} @@ -76,9 +77,9 @@ export const layer = Layer.effect( const sessions = yield* Session.Service const snapshot = yield* Snapshot.Service const storage = yield* Storage.Service - const bus = yield* Bus.Service + const events = yield* EventV2Bridge.Service - const computeDiff = Effect.fn("SessionSummary.computeDiff")(function* (input: { messages: MessageV2.WithParts[] }) { + const computeDiff = Effect.fn("SessionSummary.computeDiff")(function* (input: { messages: SessionLegacy.WithParts[] }) { let from: string | undefined let to: string | undefined for (const item of input.messages) { @@ -115,7 +116,7 @@ export const layer = Layer.effect( }, }) yield* storage.write(["session_diff", input.sessionID], diffs).pipe(Effect.ignore) - yield* bus.publish(Session.Event.Diff, { sessionID: input.sessionID, diff: diffs }) + yield* events.publish(Session.Event.Diff, { sessionID: input.sessionID, diff: diffs }) const messages = all.filter( (m) => m.info.id === input.messageID || (m.info.role === "assistant" && m.info.parentID === input.messageID), @@ -151,7 +152,7 @@ export const defaultLayer = Layer.suspend(() => Layer.provide(Session.defaultLayer), Layer.provide(Snapshot.defaultLayer), Layer.provide(Storage.defaultLayer), - Layer.provide(Bus.layer), + Layer.provide(EventV2Bridge.defaultLayer), ), ) diff --git a/packages/opencode/src/session/todo.ts b/packages/opencode/src/session/todo.ts index 005b3b7c4e..37598f9d56 100644 --- a/packages/opencode/src/session/todo.ts +++ b/packages/opencode/src/session/todo.ts @@ -1,11 +1,11 @@ -import { BusEvent } from "@/bus/bus-event" -import { Bus } from "@/bus" import { SessionID } from "./schema" import { Effect, Layer, Context, Schema } from "effect" -import { Database } from "@/storage/db" +import { Database } from "@opencode-ai/core/database/database" import { eq } from "drizzle-orm" import { asc } from "drizzle-orm" -import { TodoTable } from "./session.sql" +import { TodoTable } from "@opencode-ai/core/session/sql" +import { EventV2Bridge } from "@/event-v2-bridge" +import { EventV2 } from "@opencode-ai/core/event" export const Info = Schema.Struct({ content: Schema.String.annotate({ description: "Brief description of the task" }), @@ -17,13 +17,13 @@ export const Info = Schema.Struct({ export type Info = Schema.Schema.Type export const Event = { - Updated: BusEvent.define( - "todo.updated", - Schema.Struct({ + Updated: EventV2.define({ + type: "todo.updated", + schema: { sessionID: SessionID, todos: Schema.Array(Info), - }), - ), + }, + }), } export interface Interface { @@ -36,35 +36,41 @@ export class Service extends Context.Service()("@opencode/Se export const layer = Layer.effect( Service, Effect.gen(function* () { - const bus = yield* Bus.Service + const events = yield* EventV2Bridge.Service + const { db } = yield* Database.Service const update = Effect.fn("Todo.update")(function* (input: { sessionID: SessionID; todos: Info[] }) { - yield* Effect.sync(() => - Database.transaction((db) => { - db.delete(TodoTable).where(eq(TodoTable.session_id, input.sessionID)).run() - if (input.todos.length === 0) return - db.insert(TodoTable) - .values( - input.todos.map((todo, position) => ({ - session_id: input.sessionID, - content: todo.content, - status: todo.status, - priority: todo.priority, - position, - })), - ) - .run() - }), - ) - yield* bus.publish(Event.Updated, input) + yield* db + .transaction((tx) => + Effect.gen(function* () { + yield* tx.delete(TodoTable).where(eq(TodoTable.session_id, input.sessionID)).run() + if (input.todos.length === 0) return + yield* tx + .insert(TodoTable) + .values( + input.todos.map((todo, position) => ({ + session_id: input.sessionID, + content: todo.content, + status: todo.status, + priority: todo.priority, + position, + })), + ) + .run() + }), + ) + .pipe(Effect.orDie) + yield* events.publish(Event.Updated, input) }) const get = Effect.fn("Todo.get")(function* (sessionID: SessionID) { - const rows = yield* Effect.sync(() => - Database.use((db) => - db.select().from(TodoTable).where(eq(TodoTable.session_id, sessionID)).orderBy(asc(TodoTable.position)).all(), - ), - ) + const rows = yield* db + .select() + .from(TodoTable) + .where(eq(TodoTable.session_id, sessionID)) + .orderBy(asc(TodoTable.position)) + .all() + .pipe(Effect.orDie) return rows.map((row) => ({ content: row.content, status: row.status, @@ -76,6 +82,6 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer.pipe(Layer.provide(Bus.layer)) +export const defaultLayer = layer.pipe(Layer.provide(EventV2Bridge.defaultLayer), Layer.provide(Database.defaultLayer)) export * as Todo from "./todo" diff --git a/packages/opencode/src/session/tools.ts b/packages/opencode/src/session/tools.ts index f45df9d0fa..20ffb60e13 100644 --- a/packages/opencode/src/session/tools.ts +++ b/packages/opencode/src/session/tools.ts @@ -1,4 +1,5 @@ import { Agent } from "@/agent/agent" +import { SessionLegacy } from "@opencode-ai/core/session/legacy" import { Provider } from "@/provider/provider" import { ProviderTransform } from "@/provider/transform" import { MCP } from "@/mcp" @@ -7,7 +8,7 @@ import { Tool } from "@/tool/tool" import { ToolJsonSchema } from "@/tool/json-schema" import { ToolRegistry } from "@/tool/registry" import { Truncate } from "@/tool/truncate" -import { ModelID } from "@/provider/schema" + import { Plugin } from "@/plugin" import type { TaskPromptOps } from "@/tool/task" import { type Tool as AITool, tool, jsonSchema, type ToolExecutionOptions, asSchema } from "ai" @@ -18,6 +19,7 @@ import { SessionProcessor } from "./processor" import { PartID } from "./schema" import * as Log from "@opencode-ai/core/util/log" import { EffectBridge } from "@/effect/bridge" +import { ProviderV2 } from "@opencode-ai/core/provider" const log = Log.create({ service: "session.tools" }) @@ -27,7 +29,7 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { session: Session.Info processor: Pick bypassAgentCheck: boolean - messages: MessageV2.WithParts[] + messages: SessionLegacy.WithParts[] promptOps: TaskPromptOps }) { using _ = log.time("resolveTools") @@ -73,7 +75,7 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { }) for (const item of yield* registry.tools({ - modelID: ModelID.make(input.model.api.id), + modelID: ProviderV2.ModelID.make(input.model.api.id), providerID: input.model.providerID, agent: input.agent, })) { @@ -151,7 +153,7 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { ) const textParts: string[] = [] - const attachments: Omit[] = [] + const attachments: Omit[] = [] for (const contentItem of result.content) { if (contentItem.type === "text") textParts.push(contentItem.text) else if (contentItem.type === "image") { diff --git a/packages/opencode/src/share/session.ts b/packages/opencode/src/share/session.ts index a13b6c9deb..b27bc728a5 100644 --- a/packages/opencode/src/share/session.ts +++ b/packages/opencode/src/share/session.ts @@ -1,6 +1,5 @@ import { Session } from "@/session/session" import { SessionID } from "@/session/schema" -import { SyncEvent } from "@/sync" import { Effect, Layer, Scope, Context } from "effect" import { Config } from "@/config/config" import { RuntimeFlags } from "@/effect/runtime-flags" @@ -21,20 +20,19 @@ export const layer = Layer.effect( const session = yield* Session.Service const shareNext = yield* ShareNext.Service const scope = yield* Scope.Scope - const sync = yield* SyncEvent.Service const flags = yield* RuntimeFlags.Service const share = Effect.fn("SessionShare.share")(function* (sessionID: SessionID) { const conf = yield* cfg.get() if (conf.share === "disabled") throw new Error("Sharing is disabled in configuration") const result = yield* shareNext.create(sessionID) - yield* sync.run(Session.Event.Updated, { sessionID, info: { share: { url: result.url } } }) + yield* session.setShare({ sessionID, share: { url: result.url } }) return result }) const unshare = Effect.fn("SessionShare.unshare")(function* (sessionID: SessionID) { yield* shareNext.remove(sessionID) - yield* sync.run(Session.Event.Updated, { sessionID, info: { share: { url: null } } }) + yield* session.setShare({ sessionID, share: undefined }) }) const create = Effect.fn("SessionShare.create")(function* (input?: Session.CreateInput) { @@ -54,7 +52,6 @@ export const defaultLayer = layer.pipe( Layer.provide(ShareNext.defaultLayer), Layer.provide(Session.defaultLayer), Layer.provide(Config.defaultLayer), - Layer.provide(SyncEvent.defaultLayer), Layer.provide(RuntimeFlags.defaultLayer), ) diff --git a/packages/opencode/src/share/share-next.ts b/packages/opencode/src/share/share-next.ts index ab2d9d151d..bf0ae3b844 100644 --- a/packages/opencode/src/share/share-next.ts +++ b/packages/opencode/src/share/share-next.ts @@ -3,18 +3,20 @@ import { serviceUse } from "@opencode-ai/core/effect/service-use" import { Effect, Exit, Layer, Option, Schema, Scope, Context, Stream } from "effect" import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" import { Account } from "@/account/account" -import { Bus } from "@/bus" +import { EventV2Bridge } from "@/event-v2-bridge" import { InstanceState } from "@/effect/instance-state" import { Provider } from "@/provider/provider" -import { ModelID, ProviderID } from "@/provider/schema" + import { Session } from "@/session/session" import { MessageV2 } from "@/session/message-v2" import type { SessionID } from "@/session/schema" -import { Database } from "@/storage/db" +import { Database } from "@opencode-ai/core/database/database" import { eq } from "drizzle-orm" import { Config } from "@/config/config" import * as Log from "@opencode-ai/core/util/log" -import { SessionShareTable } from "./share.sql" +import { SessionShareTable } from "@opencode-ai/core/share/sql" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { EventV2 } from "@opencode-ai/core/event" const log = Log.create({ service: "share-next" }) const disabled = process.env["OPENCODE_DISABLE_SHARE"] === "true" || process.env["OPENCODE_DISABLE_SHARE"] === "1" @@ -79,9 +81,6 @@ export class Service extends Context.Service()("@opencode/Sh export const use = serviceUse(Service) -const db = (fn: (d: Parameters[0] extends (trx: infer D) => any ? D : never) => T) => - Effect.sync(() => Database.use(fn)) - function api(resource: string): Api { return { create: `/api/${resource}`, @@ -113,14 +112,15 @@ export const layer = Layer.effect( Service, Effect.gen(function* () { const account = yield* Account.Service - const bus = yield* Bus.Service + const events = yield* EventV2Bridge.Service const cfg = yield* Config.Service + const { db } = yield* Database.Service const http = yield* HttpClient.HttpClient const httpOk = HttpClient.filterStatusOk(http) const provider = yield* Provider.Service const session = yield* Session.Service - function sync(sessionID: SessionID, data: Data[]): Effect.Effect { + function sync(sessionID: SessionID, data: Data[]) { return Effect.gen(function* () { if (disabled) return const share = yield* getCached(sessionID) @@ -166,49 +166,39 @@ export const layer = Layer.effect( if (disabled) return cache - const watch = ( + const watch = ( def: D, - fn: (evt: { properties: any }) => Effect.Effect, + fn: (data: EventV2.Data) => Effect.Effect, ) => - bus.subscribe(def as never).pipe( - Effect.flatMap((stream) => - stream.pipe( - Stream.runForEach((evt) => - fn(evt).pipe( - Effect.catchCause((cause) => - Effect.sync(() => { - log.error("share subscriber failed", { type: def.type, cause }) - }), - ), - ), - ), - Effect.forkScoped, - ), - ), - ) + events.listen((event) => { + if (event.type !== def.type || event.location?.directory !== _ctx.directory) return Effect.void + return fn(event.data as EventV2.Data).pipe( + Effect.catchCause((cause) => Effect.sync(() => log.error("share subscriber failed", { type: def.type, cause }))), + ) + }) - yield* watch(Session.Event.Updated, (evt) => + yield* watch(Session.Event.Updated, (data) => Effect.gen(function* () { - const info = evt.properties.info - yield* sync(info.id, [{ type: "session", data: info }]) + const info = data.info + yield* sync(info.id, [{ type: "session", data: structuredClone(info) as SDK.Session }]) }), ) - yield* watch(MessageV2.Event.Updated, (evt) => + yield* watch(MessageV2.Event.Updated, (data) => Effect.gen(function* () { - const info = evt.properties.info - yield* sync(info.sessionID, [{ type: "message", data: info }]) + const info = data.info + yield* sync(info.sessionID, [{ type: "message", data: structuredClone(info) as SDK.Message }]) if (info.role !== "user") return const model = yield* provider.getModel(info.model.providerID, info.model.modelID) yield* sync(info.sessionID, [{ type: "model", data: [model] }]) }), ) - yield* watch(MessageV2.Event.PartUpdated, (evt) => - sync(evt.properties.part.sessionID, [{ type: "part", data: evt.properties.part }]), + yield* watch(MessageV2.Event.PartUpdated, (data) => + sync(data.part.sessionID, [{ type: "part", data: structuredClone(data.part) as SDK.Part }]), ) - yield* watch(Session.Event.Diff, (evt) => - sync(evt.properties.sessionID, [{ type: "session_diff", data: evt.properties.diff }]), + yield* watch(Session.Event.Diff, (data) => + sync(data.sessionID, [{ type: "session_diff", data: structuredClone(data.diff) as SDK.SnapshotFileDiff[] }]), ) - yield* watch(Session.Event.Deleted, (evt) => remove(evt.properties.sessionID)) + yield* watch(Session.Event.Deleted, (data) => remove(data.sessionID)) return cache }), @@ -233,9 +223,12 @@ export const layer = Layer.effect( }) const get = Effect.fnUntraced(function* (sessionID: SessionID) { - const row = yield* db((db) => - db.select().from(SessionShareTable).where(eq(SessionShareTable.session_id, sessionID)).get(), - ) + const row = yield* db + .select() + .from(SessionShareTable) + .where(eq(SessionShareTable.session_id, sessionID)) + .get() + .pipe(Effect.orDie) if (!row) return return { id: row.id, secret: row.secret, url: row.url } satisfies Share }) @@ -289,7 +282,7 @@ export const layer = Layer.effect( .map((item) => [`${item.providerID}/${item.modelID}`, item] as const), ).values(), ), - (item) => provider.getModel(ProviderID.make(item.providerID), ModelID.make(item.modelID)), + (item) => provider.getModel(ProviderV2.ID.make(item.providerID), ProviderV2.ModelID.make(item.modelID)), { concurrency: 8 }, ) @@ -321,16 +314,15 @@ export const layer = Layer.effect( Effect.flatMap((r) => httpOk.execute(r)), Effect.flatMap(HttpClientResponse.schemaBodyJson(ShareSchema)), ) - yield* db((db) => - db - .insert(SessionShareTable) - .values({ session_id: sessionID, id: result.id, secret: result.secret, url: result.url }) - .onConflictDoUpdate({ - target: SessionShareTable.session_id, - set: { id: result.id, secret: result.secret, url: result.url }, - }) - .run(), - ) + yield* db + .insert(SessionShareTable) + .values({ session_id: sessionID, id: result.id, secret: result.secret, url: result.url }) + .onConflictDoUpdate({ + target: SessionShareTable.session_id, + set: { id: result.id, secret: result.secret, url: result.url }, + }) + .run() + .pipe(Effect.orDie) const s = yield* InstanceState.get(state) s.shared.set(sessionID, result) yield* full(sessionID).pipe( @@ -362,7 +354,7 @@ export const layer = Layer.effect( Effect.flatMap((r) => httpOk.execute(r)), ) - yield* db((db) => db.delete(SessionShareTable).where(eq(SessionShareTable.session_id, sessionID)).run()) + yield* db.delete(SessionShareTable).where(eq(SessionShareTable.session_id, sessionID)).run().pipe(Effect.orDie) s.shared.delete(sessionID) s.queue.delete(sessionID) }) @@ -372,9 +364,10 @@ export const layer = Layer.effect( ) export const defaultLayer = layer.pipe( - Layer.provide(Bus.layer), + Layer.provide(EventV2Bridge.defaultLayer), Layer.provide(Account.defaultLayer), Layer.provide(Config.defaultLayer), + Layer.provide(Database.defaultLayer), Layer.provide(FetchHttpClient.layer), Layer.provide(Provider.defaultLayer), Layer.provide(Session.defaultLayer), diff --git a/packages/opencode/src/skill/index.ts b/packages/opencode/src/skill/index.ts index c1c6d0d6f2..1d21774eeb 100644 --- a/packages/opencode/src/skill/index.ts +++ b/packages/opencode/src/skill/index.ts @@ -3,7 +3,7 @@ import { pathToFileURL } from "url" import { Effect, Layer, Context, Schema } from "effect" import { NamedError } from "@opencode-ai/core/util/error" import type { Agent } from "@/agent/agent" -import { Bus } from "@/bus" +import { EventV2Bridge } from "@/event-v2-bridge" import { InstanceState } from "@/effect/instance-state" import { Global } from "@opencode-ai/core/global" import { Permission } from "@/permission" @@ -101,7 +101,7 @@ export interface Interface { readonly available: (agent?: Agent.Info) => Effect.Effect } -const add = Effect.fnUntraced(function* (state: State, match: string, bus: Bus.Interface) { +const add = Effect.fnUntraced(function* (state: State, match: string, events: EventV2Bridge.Service["Service"]) { const md = yield* Effect.tryPromise({ try: () => ConfigMarkdown.parse(match), catch: (err) => err, @@ -112,7 +112,7 @@ const add = Effect.fnUntraced(function* (state: State, match: string, bus: Bus.I ? err.data.message : `Failed to parse skill ${match}` const { Session } = yield* Effect.promise(() => import("@/session/session")) - yield* bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() }) + yield* events.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() }) log.error("failed to load skill", { skill: match, err }) return undefined }), @@ -232,8 +232,8 @@ const discoverSkills = Effect.fnUntraced(function* ( } }) -const loadSkills = Effect.fnUntraced(function* (state: State, discovered: DiscoveryState, bus: Bus.Interface) { - yield* Effect.forEach(discovered.matches, (match) => add(state, match, bus), { +const loadSkills = Effect.fnUntraced(function* (state: State, discovered: DiscoveryState, events: EventV2Bridge.Service["Service"]) { + yield* Effect.forEach(discovered.matches, (match) => add(state, match, events), { concurrency: "unbounded", discard: true, }) @@ -248,7 +248,7 @@ export const layer = Layer.effect( Effect.gen(function* () { const discovery = yield* Discovery.Service const config = yield* Config.Service - const bus = yield* Bus.Service + const events = yield* EventV2Bridge.Service const fsys = yield* AppFileSystem.Service const global = yield* Global.Service const flags = yield* RuntimeFlags.Service @@ -277,7 +277,7 @@ export const layer = Layer.effect( location: "", content: CUSTOMIZE_OPENCODE_SKILL_BODY, } - yield* loadSkills(s, yield* InstanceState.get(discovered), bus) + yield* loadSkills(s, yield* InstanceState.get(discovered), events) return s }), ) @@ -317,7 +317,7 @@ export const layer = Layer.effect( export const defaultLayer = layer.pipe( Layer.provide(Discovery.defaultLayer), Layer.provide(Config.defaultLayer), - Layer.provide(Bus.layer), + Layer.provide(EventV2Bridge.defaultLayer), Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Global.layer), Layer.provide(RuntimeFlags.defaultLayer), diff --git a/packages/opencode/src/storage/db.bun.ts b/packages/opencode/src/storage/db.bun.ts deleted file mode 100644 index fa6190925a..0000000000 --- a/packages/opencode/src/storage/db.bun.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Database } from "bun:sqlite" -import { drizzle } from "drizzle-orm/bun-sqlite" - -export function init(path: string) { - const sqlite = new Database(path, { create: true }) - const db = drizzle({ client: sqlite }) - return db -} diff --git a/packages/opencode/src/storage/db.node.ts b/packages/opencode/src/storage/db.node.ts deleted file mode 100644 index 0dba8dcef3..0000000000 --- a/packages/opencode/src/storage/db.node.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { DatabaseSync } from "node:sqlite" -import { drizzle } from "drizzle-orm/node-sqlite" - -export function init(path: string) { - const sqlite = new DatabaseSync(path) - const db = drizzle({ client: sqlite }) - return db -} diff --git a/packages/opencode/src/storage/db.ts b/packages/opencode/src/storage/db.ts deleted file mode 100644 index 06f1f84a9a..0000000000 --- a/packages/opencode/src/storage/db.ts +++ /dev/null @@ -1,200 +0,0 @@ -import { type SQLiteBunDatabase } from "drizzle-orm/bun-sqlite" -import { migrate } from "drizzle-orm/bun-sqlite/migrator" -import { type SQLiteTransaction } from "drizzle-orm/sqlite-core" -export * from "drizzle-orm" -import { RuntimeFlags } from "@/effect/runtime-flags" -import { LocalContext } from "@/util/local-context" -import { Global } from "@opencode-ai/core/global" -import * as Log from "@opencode-ai/core/util/log" -import { NamedError } from "@opencode-ai/core/util/error" -import path from "path" -import { readFileSync, readdirSync, existsSync } from "fs" -import { Flag } from "@opencode-ai/core/flag/flag" -import { InstallationChannel } from "@opencode-ai/core/installation/version" -import { EffectBridge } from "@/effect/bridge" -import { init } from "#db" -import { Effect, Schema } from "effect" - -declare const OPENCODE_MIGRATIONS: { sql: string; timestamp: number; name: string }[] | undefined - -export const NotFoundError = NamedError.create("NotFoundError", { - message: Schema.String, -}) - -const log = Log.create({ service: "db" }) - -type DatabaseFlags = Pick - -const readRuntimeFlags = () => - Effect.runSync(RuntimeFlags.Service.useSync((flags) => flags).pipe(Effect.provide(RuntimeFlags.defaultLayer))) - -export function getChannelPath(flags: Pick = readRuntimeFlags()) { - if (["latest", "beta", "prod"].includes(InstallationChannel) || flags.disableChannelDb) - return path.join(Global.Path.data, "opencode.db") - const safe = InstallationChannel.replace(/[^a-zA-Z0-9._-]/g, "-") - return path.join(Global.Path.data, `opencode-${safe}.db`) -} - -export const getPath = (flags?: Pick) => { - if (Flag.OPENCODE_DB) { - if (Flag.OPENCODE_DB === ":memory:" || path.isAbsolute(Flag.OPENCODE_DB)) return Flag.OPENCODE_DB - return path.join(Global.Path.data, Flag.OPENCODE_DB) - } - return getChannelPath(flags) -} - -export type Transaction = SQLiteTransaction<"sync", void> - -type Client = ReturnType - -type Journal = { sql: string; timestamp: number; name: string }[] - -// Drizzle's migrate overloads trigger expensive variance checks here; narrow to the journal overload we actually use. -const migrateFromJournal = migrate as unknown as (db: SQLiteBunDatabase, entries: Journal) => void - -function applyMigrations(db: SQLiteBunDatabase, entries: Journal) { - migrateFromJournal(db, entries) -} - -function time(tag: string) { - const match = /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})/.exec(tag) - if (!match) return 0 - return Date.UTC( - Number(match[1]), - Number(match[2]) - 1, - Number(match[3]), - Number(match[4]), - Number(match[5]), - Number(match[6]), - ) -} - -function migrations(dir: string): Journal { - const dirs = readdirSync(dir, { withFileTypes: true }) - .filter((entry) => entry.isDirectory()) - .map((entry) => entry.name) - - const sql = dirs - .map((name) => { - const file = path.join(dir, name, "migration.sql") - if (!existsSync(file)) return - return { - sql: readFileSync(file, "utf-8"), - timestamp: time(name), - name, - } - }) - .filter(Boolean) as Journal - - return sql.sort((a, b) => a.timestamp - b.timestamp) -} - -let client: Client | undefined -let loaded = false - -export const Client = Object.assign( - (flags: DatabaseFlags = readRuntimeFlags()): Client => { - if (loaded) return client as Client - - const dbPath = getPath(flags) - log.info("opening database", { path: dbPath }) - - const db = init(dbPath) - - db.run("PRAGMA journal_mode = WAL") - db.run("PRAGMA synchronous = NORMAL") - db.run("PRAGMA busy_timeout = 5000") - db.run("PRAGMA cache_size = -64000") - db.run("PRAGMA foreign_keys = ON") - db.run("PRAGMA wal_checkpoint(PASSIVE)") - - // Apply schema migrations - const entries = - typeof OPENCODE_MIGRATIONS !== "undefined" - ? OPENCODE_MIGRATIONS - : migrations(path.join(import.meta.dirname, "../../migration")) - if (entries.length > 0) { - log.info("applying migrations", { - count: entries.length, - mode: typeof OPENCODE_MIGRATIONS !== "undefined" ? "bundled" : "dev", - }) - if (flags.skipMigrations) { - for (const item of entries) { - item.sql = "select 1;" - } - } - applyMigrations(db, entries) - } - - client = db - loaded = true - return db - }, - { - reset: () => { - loaded = false - client = undefined - }, - loaded: () => loaded, - }, -) - -export function close() { - if (!Client.loaded()) return - Client().$client.close() - Client.reset() -} - -export type TxOrDb = Transaction | Client - -const ctx = LocalContext.create<{ - tx: TxOrDb - effects: (() => void | Promise)[] -}>("database") - -export function use(callback: (trx: TxOrDb) => T): T { - try { - return callback(ctx.use().tx) - } catch (err) { - if (err instanceof LocalContext.NotFound) { - const effects: (() => void | Promise)[] = [] - const result = ctx.provide({ effects, tx: Client() }, () => callback(Client())) - for (const effect of effects) effect() - return result - } - throw err - } -} - -export function effect(fn: () => any | Promise) { - const bound = EffectBridge.bind(fn) - try { - ctx.use().effects.push(bound) - } catch { - bound() - } -} - -type NotPromise = T extends Promise ? never : T - -export function transaction( - callback: (tx: TxOrDb) => NotPromise, - options?: { - behavior?: "deferred" | "immediate" | "exclusive" - }, -): NotPromise { - try { - return callback(ctx.use().tx) - } catch (err) { - if (err instanceof LocalContext.NotFound) { - const effects: (() => void | Promise)[] = [] - const txCallback = EffectBridge.bind((tx: TxOrDb) => ctx.provide({ tx, effects }, () => callback(tx))) - const result = Client().transaction(txCallback, { behavior: options?.behavior }) - for (const effect of effects) effect() - return result as NotPromise - } - throw err - } -} - -export * as Database from "./db" diff --git a/packages/opencode/src/storage/json-migration.ts b/packages/opencode/src/storage/json-migration.ts index 3930e591a4..00a10e6d92 100644 --- a/packages/opencode/src/storage/json-migration.ts +++ b/packages/opencode/src/storage/json-migration.ts @@ -2,9 +2,9 @@ import type { SQLiteBunDatabase } from "drizzle-orm/bun-sqlite" import type { NodeSQLiteDatabase } from "drizzle-orm/node-sqlite" import { Global } from "@opencode-ai/core/global" import * as Log from "@opencode-ai/core/util/log" -import { ProjectTable } from "../project/project.sql" -import { SessionTable, MessageTable, PartTable, TodoTable, PermissionTable } from "../session/session.sql" -import { SessionShareTable } from "../share/share.sql" +import { ProjectTable } from "@opencode-ai/core/project/sql" +import { SessionTable, MessageTable, PartTable, TodoTable, PermissionTable } from "@opencode-ai/core/session/sql" +import { SessionShareTable } from "@opencode-ai/core/share/sql" import path from "path" import { existsSync } from "fs" import { Filesystem } from "@/util/filesystem" diff --git a/packages/opencode/src/storage/schema.ts b/packages/opencode/src/storage/schema.ts index 0c12cee622..01d47fcb5a 100644 --- a/packages/opencode/src/storage/schema.ts +++ b/packages/opencode/src/storage/schema.ts @@ -1,5 +1,5 @@ -export { AccountTable, AccountStateTable, ControlAccountTable } from "../account/account.sql" -export { ProjectTable } from "../project/project.sql" -export { SessionTable, MessageTable, PartTable, TodoTable, PermissionTable } from "../session/session.sql" -export { SessionShareTable } from "../share/share.sql" -export { WorkspaceTable } from "../control-plane/workspace.sql" +export { AccountTable, AccountStateTable, ControlAccountTable } from "@opencode-ai/core/account/sql" +export { ProjectTable } from "@opencode-ai/core/project/sql" +export { SessionTable, MessageTable, PartTable, TodoTable, PermissionTable } from "@opencode-ai/core/session/sql" +export { SessionShareTable } from "@opencode-ai/core/share/sql" +export { WorkspaceTable } from "@opencode-ai/core/control-plane/workspace.sql" diff --git a/packages/opencode/src/sync/index.ts b/packages/opencode/src/sync/index.ts deleted file mode 100644 index 8573636615..0000000000 --- a/packages/opencode/src/sync/index.ts +++ /dev/null @@ -1,411 +0,0 @@ -// Legacy sync event system. It should stay unaware of core EventV2 execution; -// the only temporary V2 coupling here is exposing versioned core event schemas -// in effectPayloads() so existing HTTP/SDK schema generation remains stable. -// Remove that registry read when event schemas are generated from core directly. -import { Database } from "@/storage/db" -import { eq } from "drizzle-orm" -import { GlobalBus } from "@/bus/global" -import { Bus as ProjectBus } from "@/bus" -import { BusEvent } from "@/bus/bus-event" -import { EventSequenceTable, EventTable } from "./event.sql" -import { EventID } from "./schema" -import { Context, Effect, Layer, Schema as EffectSchema } from "effect" -import type { DeepMutable } from "@opencode-ai/core/schema" -import { EventV2 } from "@opencode-ai/core/event" -import { serviceUse } from "@opencode-ai/core/effect/service-use" -import { InstanceState } from "@/effect/instance-state" -import { RuntimeFlags } from "@/effect/runtime-flags" -import { EffectBridge } from "@/effect/bridge" - -// Keep `Event["data"]` mutable because projectors mutate the persisted shape -// when writing to the database. Bus payloads (`Properties`) stay readonly — -// subscribers only read. - -export type Definition< - Type extends string = string, - Schema extends EffectSchema.Top = EffectSchema.Top, - BusSchema extends EffectSchema.Top = Schema, -> = { - type: Type - version: number - aggregate: string - schema: Schema - // Bus event payload schema. Defaults to `schema` unless `busSchema` was - // passed at definition time (see `session.updated`, whose projector - // expands the persisted data to a `{ sessionID, info }` bus payload). - properties: BusSchema -} - -export type Event = { - id: string - seq: number - aggregateID: string - data: DeepMutable> -} - -export type Properties = EffectSchema.Schema.Type - -export type SerializedEvent = Event & { type: string } - -type ProjectorFunc = (db: Database.TxOrDb, data: unknown, event: Event) => void -type ConvertEvent = (type: string, data: Event["data"]) => unknown | Promise - -export interface Interface { - readonly run: ( - def: Def, - data: Event["data"], - options?: { publish?: boolean }, - ) => Effect.Effect - readonly replay: (event: SerializedEvent, options?: { publish: boolean; ownerID?: string }) => Effect.Effect - readonly replayAll: ( - events: SerializedEvent[], - options?: { publish: boolean; ownerID?: string }, - ) => Effect.Effect - readonly remove: (aggregateID: string) => Effect.Effect - readonly claim: (aggregateID: string, ownerID: string) => Effect.Effect -} - -export class Service extends Context.Service()("@opencode/SyncEvent") {} - -export const layer = Layer.effect(Service)( - Effect.gen(function* () { - const flags = yield* RuntimeFlags.Service - const bus = yield* ProjectBus.Service - - const replay: Interface["replay"] = Effect.fn("SyncEvent.replay")(function* (event, options) { - const def = registry.get(event.type) - if (!def) { - throw new Error(`Unknown event type: ${event.type}`) - } - - const row = Database.use((db) => - db - .select({ seq: EventSequenceTable.seq, ownerID: EventSequenceTable.owner_id }) - .from(EventSequenceTable) - .where(eq(EventSequenceTable.aggregate_id, event.aggregateID)) - .get(), - ) - - const latest = row?.seq ?? -1 - if (event.seq <= latest) return - - if (row?.ownerID && row.ownerID !== options?.ownerID) { - return - } - - const expected = latest + 1 - if (event.seq !== expected) { - throw new Error( - `Sequence mismatch for aggregate "${event.aggregateID}": expected ${expected}, got ${event.seq}`, - ) - } - - const publish = !!options?.publish - // Bridge captures handler-fiber refs (InstanceRef/WorkspaceRef) and the - // full Effect context, so the forked publish + GlobalBus emit run with - // the right state without a per-call attachWith. - const bridge = yield* EffectBridge.make() - process(def, event, { - bus, - bridge, - publish, - ownerID: options?.ownerID, - experimentalWorkspaces: flags.experimentalWorkspaces, - }) - }) - - const replayAll: Interface["replayAll"] = Effect.fn("SyncEvent.replayAll")(function* (events, options) { - const source = events[0]?.aggregateID - if (!source) return undefined - if (events.some((item) => item.aggregateID !== source)) { - throw new Error("Replay events must belong to the same session") - } - const start = events[0].seq - for (const [i, item] of events.entries()) { - const seq = start + i - if (item.seq !== seq) { - throw new Error(`Replay sequence mismatch at index ${i}: expected ${seq}, got ${item.seq}`) - } - } - for (const item of events) { - yield* replay(item, options) - } - return source - }) - - const run: Interface["run"] = Effect.fn("SyncEvent.run")(function* (def, data, options) { - const agg = (data as Record)[def.aggregate] - // This should never happen: we've enforced it via typescript in - // the definition - if (agg == null) { - throw new Error(`SyncEvent.run: "${def.aggregate}" required but not found: ${JSON.stringify(data)}`) - } - - if (def.version !== versions.get(def.type)) { - throw new Error(`SyncEvent.run: running old versions of events is not allowed: ${def.type}`) - } - - const { publish = true } = options || {} - const bridge = yield* EffectBridge.make() - - // Note that this is an "immediate" transaction which is critical. - // We need to make sure we can safely read and write with nothing - // else changing the data from under us - Database.transaction( - (tx) => { - const id = EventID.ascending() - const row = tx - .select({ seq: EventSequenceTable.seq }) - .from(EventSequenceTable) - .where(eq(EventSequenceTable.aggregate_id, agg)) - .get() - const seq = row?.seq != null ? row.seq + 1 : 0 - - const event = { id, seq, aggregateID: agg, data } - process(def, event, { bus, bridge, publish, experimentalWorkspaces: flags.experimentalWorkspaces }) - }, - { - behavior: "immediate", - }, - ) - }) - - const remove: Interface["remove"] = Effect.fn("SyncEvent.remove")(function* (aggregateID) { - Database.transaction((tx) => { - tx.delete(EventSequenceTable).where(eq(EventSequenceTable.aggregate_id, aggregateID)).run() - tx.delete(EventTable).where(eq(EventTable.aggregate_id, aggregateID)).run() - }) - }) - - const claim: Interface["claim"] = Effect.fn("SyncEvent.claim")((aggregateID, ownerID) => - Effect.sync(() => - Database.use((db) => - db - .update(EventSequenceTable) - .set({ owner_id: ownerID }) - .where(eq(EventSequenceTable.aggregate_id, aggregateID)) - .run(), - ), - ), - ) - - return Service.of({ - run, - replay, - replayAll, - remove, - claim, - }) - }), -) - -export const defaultLayer = layer.pipe(Layer.provide([ProjectBus.defaultLayer, RuntimeFlags.defaultLayer])) - -export const use = serviceUse(Service) - -export const registry = new Map() -let projectors: Map | undefined -const versions = new Map() -let frozen = false -let convertEvent: ConvertEvent - -export function reset() { - frozen = false - projectors = undefined - convertEvent = (_, data) => data -} - -export function init(input: { projectors: Array<[Definition, ProjectorFunc]>; convertEvent?: ConvertEvent }) { - projectors = new Map(input.projectors.map(([def, func]) => [versionedType(def.type, def.version), func])) - for (let entry of EventV2.registry.values()) { - if (!entry.version || !entry.aggregate) continue - register({ - type: entry.type, - version: entry.version, - aggregate: entry.aggregate, - properties: entry.data, - schema: entry.data, - }) - } - - // Install all the latest event defs to the bus. We only ever emit - // latest versions from code, and keep around old versions for - // replaying. Replaying does not go through the bus, and it - // simplifies the bus to only use unversioned latest events - for (let [type, version] of versions.entries()) { - let def = registry.get(versionedType(type, version))! - BusEvent.define(def.type, def.properties) - } - - // Freeze the system so it clearly errors if events are defined - // after `init` which would cause bugs - frozen = true - convertEvent = input.convertEvent ?? ((_, data) => data) -} - -export function versionedType(type: A): A -export function versionedType(type: A, version: B): `${A}/${B}` -export function versionedType(type: string, version?: number) { - return version ? `${type}.${version}` : type -} - -export function define< - Type extends string, - Agg extends string, - Schema extends EffectSchema.Top, - BusSchema extends EffectSchema.Top = Schema, ->(input: { - type: Type - version: number - aggregate: Agg - schema: Schema - busSchema?: BusSchema -}): Definition { - if (frozen) { - throw new Error("Error defining sync event: sync system has been frozen") - } - - const def = { - type: input.type, - version: input.version, - aggregate: input.aggregate, - schema: input.schema, - properties: (input.busSchema ?? input.schema) as BusSchema, - } - - register(def) - - return def -} - -export function project( - def: Def, - func: (db: Database.TxOrDb, data: Event["data"], event: Event) => void, -): [Definition, ProjectorFunc] { - return [def, func as ProjectorFunc] -} - -function register(def: Definition) { - versions.set(def.type, Math.max(def.version, versions.get(def.type) || 0)) - registry.set(versionedType(def.type, def.version), def) -} - -function process( - def: Def, - event: Event, - options: { - bus: ProjectBus.Interface - bridge: EffectBridge.Shape - publish: boolean - ownerID?: string - experimentalWorkspaces: boolean - }, -) { - if (projectors == null) { - throw new Error("No projectors available. Call `SyncEvent.init` to install projectors") - } - - const projector = projectors.get(versionedType(def.type, def.version)) - if (!projector) { - if (!def.type.includes("next")) throw new Error(`Projector not found for event: ${def.type}`) - return - } - - Database.transaction((tx) => { - projector(tx, event.data, event) - - if (options.experimentalWorkspaces) { - tx.insert(EventSequenceTable) - .values({ - aggregate_id: event.aggregateID, - seq: event.seq, - owner_id: options?.ownerID, - }) - .onConflictDoUpdate({ - target: EventSequenceTable.aggregate_id, - set: { seq: event.seq }, - }) - .run() - tx.insert(EventTable) - .values({ - id: event.id, - seq: event.seq, - aggregate_id: event.aggregateID, - type: versionedType(def.type, def.version), - data: event.data as Record, - }) - .run() - } - - Database.effect(() => { - if (!options.publish) return - const result = convertEvent(def.type, event.data) - // The bridge was built inside the caller's fiber so it already carries - // InstanceRef/WorkspaceRef and the full Effect context. Both the bus - // publish and the GlobalBus emit run inside the forked Effect so they - // share the same instance/workspace lookup. - const publish = (data: unknown) => - options.bridge.fork( - Effect.gen(function* () { - yield* options.bus.publish(def, data as Properties, { id: event.id }) - const instance = yield* InstanceState.context - const workspace = yield* InstanceState.workspaceID - GlobalBus.emit("event", { - directory: instance.directory, - project: instance.project.id, - workspace, - payload: { - type: "sync", - syncEvent: { - type: versionedType(def.type, def.version), - ...event, - }, - }, - }) - }), - ) - if (result instanceof Promise) { - void result.then(publish) - } else { - publish(result) - } - }) - }) -} - -export function effectPayloads() { - return [ - ...registry - .entries() - .map(([type, def]) => - EffectSchema.Struct({ - type: EffectSchema.Literal("sync"), - name: EffectSchema.Literal(type), - id: EffectSchema.String, - seq: EffectSchema.Finite, - aggregateID: EffectSchema.Literal(def.aggregate), - data: def.schema, - }).annotate({ identifier: `SyncEvent.${type}` }), - ) - .toArray(), - ...EventV2.registry - .values() - .filter( - (definition) => - definition.version !== undefined && !registry.has(versionedType(definition.type, definition.version)), - ) - .map((definition) => - EffectSchema.Struct({ - type: EffectSchema.Literal("sync"), - name: EffectSchema.Literal(versionedType(definition.type, definition.version!)), - id: EffectSchema.String, - seq: EffectSchema.Finite, - aggregateID: EffectSchema.Literal(definition.aggregate!), - data: definition.data, - }).annotate({ identifier: `SyncEvent.${definition.type}` }), - ) - .toArray(), - ] -} - -export * as SyncEvent from "." diff --git a/packages/opencode/src/tool/apply_patch.ts b/packages/opencode/src/tool/apply_patch.ts index 84e84cc396..356d09f65c 100644 --- a/packages/opencode/src/tool/apply_patch.ts +++ b/packages/opencode/src/tool/apply_patch.ts @@ -1,7 +1,7 @@ import * as path from "path" import { Effect, Schema } from "effect" import * as Tool from "./tool" -import { Bus } from "../bus" +import { EventV2Bridge } from "@/event-v2-bridge" import { FileWatcher } from "../file/watcher" import { InstanceState } from "@/effect/instance-state" import { Patch } from "../patch" @@ -25,7 +25,7 @@ export const ApplyPatchTool = Tool.define( const lsp = yield* LSP.Service const afs = yield* AppFileSystem.Service const format = yield* Format.Service - const bus = yield* Bus.Service + const events = yield* EventV2Bridge.Service const run = Effect.fn("ApplyPatchTool.execute")(function* ( params: Schema.Schema.Type, @@ -253,13 +253,13 @@ export const ApplyPatchTool = Tool.define( if (yield* format.file(edited)) { yield* Bom.syncFile(afs, edited, change.bom) } - yield* bus.publish(File.Event.Edited, { file: edited }) + yield* events.publish(File.Event.Edited, { file: edited }) } } // Publish file change events for (const update of updates) { - yield* bus.publish(FileWatcher.Event.Updated, update) + yield* events.publish(FileWatcher.Event.Updated, update) } // Notify LSP of file changes and collect diagnostics diff --git a/packages/opencode/src/tool/edit.ts b/packages/opencode/src/tool/edit.ts index ea3aac3480..79df2fa1b0 100644 --- a/packages/opencode/src/tool/edit.ts +++ b/packages/opencode/src/tool/edit.ts @@ -11,7 +11,7 @@ import { createTwoFilesPatch, diffLines } from "diff" import DESCRIPTION from "./edit.txt" import { File } from "../file" import { FileWatcher } from "../file/watcher" -import { Bus } from "../bus" +import { EventV2Bridge } from "@/event-v2-bridge" import { Format } from "../format" import { InstanceState } from "@/effect/instance-state" import { Snapshot } from "@/snapshot" @@ -61,7 +61,7 @@ export const EditTool = Tool.define( const lsp = yield* LSP.Service const afs = yield* AppFileSystem.Service const format = yield* Format.Service - const bus = yield* Bus.Service + const events = yield* EventV2Bridge.Service return { description: DESCRIPTION, @@ -108,8 +108,8 @@ export const EditTool = Tool.define( if (yield* format.file(filePath)) { contentNew = yield* Bom.syncFile(afs, filePath, desiredBom) } - yield* bus.publish(File.Event.Edited, { file: filePath }) - yield* bus.publish(FileWatcher.Event.Updated, { + yield* events.publish(File.Event.Edited, { file: filePath }) + yield* events.publish(FileWatcher.Event.Updated, { file: filePath, event: existed ? "change" : "add", }) @@ -152,8 +152,8 @@ export const EditTool = Tool.define( if (yield* format.file(filePath)) { contentNew = yield* Bom.syncFile(afs, filePath, desiredBom) } - yield* bus.publish(File.Event.Edited, { file: filePath }) - yield* bus.publish(FileWatcher.Event.Updated, { + yield* events.publish(File.Event.Edited, { file: filePath }) + yield* events.publish(FileWatcher.Event.Updated, { file: filePath, event: "change", }) diff --git a/packages/opencode/src/tool/plan.ts b/packages/opencode/src/tool/plan.ts index af206f66a5..01ca4a69c9 100644 --- a/packages/opencode/src/tool/plan.ts +++ b/packages/opencode/src/tool/plan.ts @@ -1,4 +1,5 @@ import path from "path" +import { SessionLegacy } from "@opencode-ai/core/session/legacy" import { Effect, Schema } from "effect" import * as Tool from "./tool" import { Question } from "../question" @@ -49,7 +50,7 @@ export const PlanExitTool = Tool.define( const model = lastUser?.info.role === "user" && lastUser.info.model ? lastUser.info.model : yield* provider.defaultModel() - const msg: MessageV2.User = { + const msg: SessionLegacy.User = { id: MessageID.ascending(), sessionID: ctx.sessionID, role: "user", @@ -65,7 +66,7 @@ export const PlanExitTool = Tool.define( type: "text", text: `The plan at ${plan} has been approved, you can now edit files. Execute the plan`, synthetic: true, - } satisfies MessageV2.TextPart) + } satisfies SessionLegacy.TextPart) return { title: "Switching to build agent", diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index d7f7de778e..aae98a8c7c 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -7,6 +7,7 @@ import { GlobTool } from "./glob" import { GrepTool } from "./grep" import { ReadTool } from "./read" import { TaskTool } from "./task" +import { Database } from "@opencode-ai/core/database/database" import { TodoWriteTool } from "./todo" import { WebFetchTool } from "./webfetch" import { WriteTool } from "./write" @@ -20,7 +21,7 @@ import { Schema } from "effect" import z from "zod" import { Plugin } from "../plugin" import { Provider } from "@/provider/provider" -import { ProviderID, type ModelID } from "../provider/schema" + import { WebSearchTool } from "./websearch" import { RepoCloneTool } from "./repo_clone" import { RepoOverviewTool } from "./repo_overview" @@ -45,7 +46,7 @@ import { Todo } from "../session/todo" import { LSP } from "@/lsp/lsp" import { Instruction } from "../session/instruction" import { AppFileSystem } from "@opencode-ai/core/filesystem" -import { Bus } from "../bus" +import { EventV2Bridge } from "@/event-v2-bridge" import { Agent } from "../agent/agent" import { Git } from "@/git" import { Skill } from "../skill" @@ -53,11 +54,12 @@ import { Permission } from "@/permission" import { Reference } from "@/reference/reference" import { BackgroundJob } from "@/background/job" import { RuntimeFlags } from "@/effect/runtime-flags" +import { ProviderV2 } from "@opencode-ai/core/provider" const log = Log.create({ service: "tool.registry" }) -export function webSearchEnabled(providerID: ProviderID, flags = { exa: false, parallel: false }) { - return providerID === ProviderID.opencode || flags.exa || flags.parallel +export function webSearchEnabled(providerID: ProviderV2.ID, flags = { exa: false, parallel: false }) { + return providerID === ProviderV2.ID.opencode || flags.exa || flags.parallel } type TaskDef = Tool.InferDef @@ -74,7 +76,7 @@ export interface Interface { readonly ids: () => Effect.Effect readonly all: () => Effect.Effect readonly named: () => Effect.Effect<{ task: TaskDef; read: ReadDef }> - readonly tools: (model: { providerID: ProviderID; modelID: ModelID; agent: Agent.Info }) => Effect.Effect + readonly tools: (model: { providerID: ProviderV2.ID; modelID: ProviderV2.ModelID; agent: Agent.Info }) => Effect.Effect } export class Service extends Context.Service()("@opencode/ToolRegistry") {} @@ -97,13 +99,14 @@ export const layer: Layer.Layer< | LSP.Service | Instruction.Service | AppFileSystem.Service - | Bus.Service + | EventV2Bridge.Service | HttpClient.HttpClient | ChildProcessSpawner | Ripgrep.Service | Format.Service | Truncate.Service | RuntimeFlags.Service + | Database.Service > = Layer.effect( Service, Effect.gen(function* () { @@ -386,14 +389,14 @@ export const defaultLayer = Layer.suspend(() => Layer.provide(LSP.defaultLayer), Layer.provide(Instruction.defaultLayer), Layer.provide(AppFileSystem.defaultLayer), - Layer.provide(Bus.layer), + Layer.provide(EventV2Bridge.defaultLayer), Layer.provide(FetchHttpClient.layer), Layer.provide(Format.defaultLayer), Layer.provide(CrossSpawnSpawner.defaultLayer), Layer.provide(Ripgrep.defaultLayer), Layer.provide(Truncate.defaultLayer), ) - .pipe(Layer.provide(RuntimeFlags.defaultLayer)), + .pipe(Layer.provide(Database.defaultLayer), Layer.provide(RuntimeFlags.defaultLayer)), ) function isZodType(value: unknown): value is z.ZodType { diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index a9a29debbc..bf52030d9c 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -1,6 +1,7 @@ import * as Tool from "./tool" import DESCRIPTION from "./task.txt" import { ToolJsonSchema } from "./json-schema" +import { SessionLegacy } from "@opencode-ai/core/session/legacy" import { BackgroundJob } from "@/background/job" import { Session } from "@/session/session" import { SessionID, MessageID } from "../session/schema" @@ -12,11 +13,12 @@ import { Config } from "@/config/config" import { Cause, Effect, Exit, Schema, Scope } from "effect" import { EffectBridge } from "@/effect/bridge" import { RuntimeFlags } from "@/effect/runtime-flags" +import { Database } from "@opencode-ai/core/database/database" export interface TaskPromptOps { cancel(sessionID: SessionID): Effect.Effect resolvePromptParts(template: string): Effect.Effect - prompt(input: SessionPrompt.PromptInput): Effect.Effect + prompt(input: SessionPrompt.PromptInput): Effect.Effect } const id = "task" @@ -102,6 +104,7 @@ export const TaskTool = Tool.define( const sessions = yield* Session.Service const scope = yield* Scope.Scope const flags = yield* RuntimeFlags.Service + const database = yield* Database.Service const run = Effect.fn("TaskTool.execute")(function* ( params: Schema.Schema.Type, @@ -158,7 +161,10 @@ export const TaskTool = Tool.define( ], })) - const msg = yield* MessageV2.get({ sessionID: ctx.sessionID, messageID: ctx.messageID }).pipe(Effect.orDie) + const msg = yield* MessageV2.get({ sessionID: ctx.sessionID, messageID: ctx.messageID }).pipe( + Effect.provideService(Database.Service, database), + Effect.orDie, + ) if (msg.info.role !== "assistant") return yield* Effect.fail(new Error("Not an assistant message")) const model = next.model ?? { diff --git a/packages/opencode/src/tool/tool.ts b/packages/opencode/src/tool/tool.ts index f072773fad..4edbec94cc 100644 --- a/packages/opencode/src/tool/tool.ts +++ b/packages/opencode/src/tool/tool.ts @@ -1,4 +1,5 @@ import { Effect, Schema } from "effect" +import { SessionLegacy } from "@opencode-ai/core/session/legacy" import type { JSONSchema7 } from "@ai-sdk/provider" import type { MessageV2 } from "../session/message-v2" import type { Permission } from "../permission" @@ -38,7 +39,7 @@ export type Context = { abort: AbortSignal callID?: string extra?: { [key: string]: unknown } - messages: MessageV2.WithParts[] + messages: SessionLegacy.WithParts[] metadata(input: { title?: string; metadata?: M }): Effect.Effect ask(input: Omit): Effect.Effect } @@ -47,7 +48,7 @@ export interface ExecuteResult { title: string metadata: M output: string - attachments?: Omit[] + attachments?: Omit[] } export interface Def< diff --git a/packages/opencode/src/tool/write.ts b/packages/opencode/src/tool/write.ts index c2be73ab1c..40de52279a 100644 --- a/packages/opencode/src/tool/write.ts +++ b/packages/opencode/src/tool/write.ts @@ -5,7 +5,7 @@ import * as Tool from "./tool" import { LSP } from "@/lsp/lsp" import { createTwoFilesPatch } from "diff" import DESCRIPTION from "./write.txt" -import { Bus } from "../bus" +import { EventV2Bridge } from "@/event-v2-bridge" import { File } from "../file" import { FileWatcher } from "../file/watcher" import { Format } from "../format" @@ -29,7 +29,7 @@ export const WriteTool = Tool.define( Effect.gen(function* () { const lsp = yield* LSP.Service const fs = yield* AppFileSystem.Service - const bus = yield* Bus.Service + const events = yield* EventV2Bridge.Service const format = yield* Format.Service return { @@ -65,8 +65,8 @@ export const WriteTool = Tool.define( if (yield* format.file(filepath)) { yield* Bom.syncFile(fs, filepath, desiredBom) } - yield* bus.publish(File.Event.Edited, { file: filepath }) - yield* bus.publish(FileWatcher.Event.Updated, { + yield* events.publish(File.Event.Edited, { file: filepath }) + yield* events.publish(FileWatcher.Event.Updated, { file: filepath, event: exists ? "change" : "add", }) diff --git a/packages/opencode/src/v2/provider-parity-checklist.md b/packages/opencode/src/v2/provider-parity-checklist.md deleted file mode 100644 index e3a599d8ec..0000000000 --- a/packages/opencode/src/v2/provider-parity-checklist.md +++ /dev/null @@ -1,95 +0,0 @@ -# Unported Provider Logic Checklist - -This tracks legacy provider behavior from `packages/opencode/src/provider/provider.ts` that still needs to be ported into the v2 provider plugins under `packages/opencode/src/v2/plugin/provider/`. Keep entries checked only when v2 has equivalent behavior or when the item is intentionally skipped. - -## Provider Setup - -- [x] Cloudflare AI Gateway custom SDK construction with `createAiGateway` / `createUnified`. -- [x] Google Vertex authenticated `fetch` injection. -- [x] Amazon Bedrock AWS credential chain setup. -- [x] Amazon Bedrock bearer token setup. -- [x] SAP AI Core service key setup. - -## Provider Options - -- [x] Azure resource name resolution. -- [x] Azure missing-resource error. -- [x] Azure Cognitive Services baseURL resolution. -- [x] Cloudflare Workers AI account ID validation. -- [x] Cloudflare Workers AI account ID vars. -- [x] Cloudflare AI Gateway account ID validation. -- [x] Cloudflare AI Gateway gateway ID validation. -- [x] Cloudflare AI Gateway token validation. -- [x] Amazon Bedrock region precedence. -- [x] Amazon Bedrock profile precedence. -- [x] Amazon Bedrock endpoint precedence. -- [x] Google Vertex project resolution. -- [x] Google Vertex location resolution. -- [x] GitLab instance URL resolution. -- [x] GitLab token resolution. -- [x] GitLab AI gateway headers. -- [x] GitLab feature flags. -- [x] Opencode unauthenticated paid-model filtering. -- [x] Opencode public API key fallback. - -## Request Behavior - -- [x] Request timeout handling. -- [x] Chunk timeout handling. -- [x] SSE timeout wrapping. -- [x] OpenAI response item ID stripping. -- [x] Azure response item ID stripping. -- [x] OpenAI-compatible `includeUsage` defaulting. - -## Dynamic Models - -- [ ] GitLab workflow model discovery. - -## Model Filtering - -- [ ] Experimental alpha model filtering. -- [ ] Deprecated model filtering. -- [ ] Config whitelist filtering. -- [ ] Config blacklist filtering. -- [ ] `gpt-5-chat-latest` filtering. -- [ ] OpenRouter `openai/gpt-5-chat` filtering. - -## Default Models - -- [x] Configured default model selection. Replaced by explicit `Catalog.model.setDefault`. -- [SKIP] Recent-history default model selection — not porting to server-side v2 catalog. -- [x] Default model fallback sorting. Uses newest available model, not legacy hard-coded priority. - -## Small Models - -- [SKIP] Configured `small_model` selection — not porting config-driven selection to server-side v2 catalog. -- [x] Provider-specific small model priority. Replaced by cheapest output cost selection. -- [x] Opencode small model priority. Replaced by cheapest output cost selection. -- [x] GitHub Copilot small model priority. Replaced by cheapest output cost selection. -- [x] Amazon Bedrock region-aware small model selection. Replaced by cheapest output cost selection. - -## URL And Env Vars - -- [SKIP] BaseURL `${VAR}` interpolation — not porting generic URL templating; provider plugins should construct concrete URLs. -- [x] Azure `AZURE_RESOURCE_NAME` vars. Handled by Azure provider plugins. -- [x] Google Vertex vars. Handled by Google Vertex provider plugins. -- [x] Cloudflare Workers AI vars. Handled by Cloudflare Workers AI provider plugin. - -## Auth - -- [ ] Auth-derived provider API keys. -- [ ] OpenAI OAuth/API auth distinction. -- [ ] GitLab OAuth token selection. -- [ ] GitLab API token selection. -- [ ] Azure auth metadata resource name. -- [ ] Cloudflare auth metadata account ID. -- [ ] Cloudflare auth metadata gateway ID. - -## Config And Plugin Parity - -- [ ] Legacy plugin auth loader behavior. -- [ ] Config provider merge behavior. -- [ ] Config model merge behavior. -- [ ] Variant generation from model metadata. -- [ ] Config variant merge behavior. -- [ ] Config variant disable behavior. diff --git a/packages/opencode/src/v2/session.ts b/packages/opencode/src/v2/session.ts deleted file mode 100644 index 551f030ffc..0000000000 --- a/packages/opencode/src/v2/session.ts +++ /dev/null @@ -1,372 +0,0 @@ -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 "@opencode-ai/core/session-message" -import type { Prompt } from "@opencode-ai/core/session-prompt" -import { ProjectID } from "@/project/schema" -import { SessionEvent } from "@opencode-ai/core/session-event" -import { V2Schema } from "@opencode-ai/core/v2-schema" -import { optionalOmitUndefined } from "@opencode-ai/core/schema" -import { EventV2 } from "@opencode-ai/core/event" -import { EventV2Bridge } from "@/event-v2-bridge" -import { ModelV2 } from "@opencode-ai/core/model" -import { ProviderV2 } from "@opencode-ai/core/provider" - -export const Delivery = Schema.Literals(["immediate", "deferred"]).annotate({ - identifier: "Session.Delivery", -}) -export type Delivery = Schema.Schema.Type - -export const DefaultDelivery = "immediate" satisfies Delivery - -export class Info extends Schema.Class("Session.Info")({ - id: SessionID, - parentID: optionalOmitUndefined(SessionID), - projectID: ProjectID, - workspaceID: optionalOmitUndefined(WorkspaceID), - path: optionalOmitUndefined(Schema.String), - agent: optionalOmitUndefined(Schema.String), - model: ModelV2.Ref.pipe(optionalOmitUndefined), - cost: Schema.Finite, - tokens: Schema.Struct({ - input: Schema.Finite, - output: Schema.Finite, - reasoning: Schema.Finite, - cache: Schema.Struct({ - read: Schema.Finite, - write: Schema.Finite, - }), - }), - time: Schema.Struct({ - created: V2Schema.DateTimeUtcFromMillis, - updated: V2Schema.DateTimeUtcFromMillis, - archived: optionalOmitUndefined(V2Schema.DateTimeUtcFromMillis), - }), - 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 class NotFoundError extends Schema.TaggedErrorClass()("Session.NotFoundError", { - sessionID: SessionID, -}) {} - -export class OperationUnavailableError extends Schema.TaggedErrorClass()( - "Session.OperationUnavailableError", - { - operation: Schema.Literals(["prompt", "compact", "wait"]), - }, -) {} - -export class MessageDecodeError extends Schema.TaggedErrorClass()("Session.MessageDecodeError", { - sessionID: SessionID, - messageID: SessionMessage.ID, -}) {} - -export interface Interface { - readonly create: (input?: { - agent?: string - model?: ModelV2.Ref - parentID?: SessionID - workspaceID?: WorkspaceID - }) => Effect.Effect - readonly get: (sessionID: SessionID) => 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 subagent: (input: { - id?: EventV2.ID - parentID: SessionID - prompt: Prompt - agent: string - model?: ModelV2.Ref - }) => Effect.Effect - readonly switchAgent: (input: { sessionID: SessionID; agent: string }) => Effect.Effect - readonly switchModel: (input: { sessionID: SessionID; model: ModelV2.Ref }) => Effect.Effect - readonly compact: (sessionID: SessionID) => Effect.Effect - readonly wait: (sessionID: SessionID) => Effect.Effect -} - -export class Service extends Context.Service()("@opencode/v2/Session") {} - -export const layer = Layer.effect( - Service, - Effect.gen(function* () { - const events = yield* EventV2Bridge.Service - const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Message) - - const decode = (row: typeof SessionMessageTable.$inferSelect) => - decodeMessage({ ...row.data, id: row.id, type: row.type }).pipe( - Effect.mapError( - () => - new MessageDecodeError({ - sessionID: SessionID.make(row.session_id), - messageID: SessionMessage.ID.make(row.id), - }), - ), - ) - - function fromRow(row: typeof SessionTable.$inferSelect): Info { - return new Info({ - 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: ModelV2.ID.make(row.model.id), - providerID: ProviderV2.ID.make(row.model.providerID), - variant: row.model.variant ? ModelV2.VariantID.make(row.model.variant) : undefined, - } - : undefined, - cost: row.cost, - tokens: { - input: row.tokens_input, - output: row.tokens_output, - reasoning: row.tokens_reasoning, - cache: { - read: row.tokens_cache_read, - write: row.tokens_cache_write, - }, - }, - time: { - created: DateTime.makeUnsafe(row.time_created), - updated: DateTime.makeUnsafe(row.time_updated), - archived: row.time_archived ? DateTime.makeUnsafe(row.time_archived) : undefined, - }, - }) - } - - const result = Service.of({ - create: Effect.fn("V2Session.create")(function* (_input) { - return {} as any - }), - get: Effect.fn("V2Session.get")(function* (sessionID) { - const row = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, sessionID)).get()) - if (!row) return yield* new NotFoundError({ sessionID }) - return fromRow(row) - }), - list: Effect.fn("V2Session.list")(function* (input) { - const direction = input.cursor?.direction ?? "next" - let order = input.order ?? "desc" - // This is a load bearing sort, desktop relies on this - const sortColumn = SessionTable.time_updated - // 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(sortColumn, input.start)) - if (input.search) conditions.push(like(SessionTable.title, `%${input.search}%`)) - if (input.cursor) { - conditions.push( - order === "asc" - ? or( - gt(sortColumn, input.cursor.time), - and(eq(sortColumn, input.cursor.time), gt(SessionTable.id, input.cursor.id)), - )! - : or( - lt(sortColumn, input.cursor.time), - and(eq(sortColumn, 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(sortColumn) : desc(sortColumn), - order === "asc" ? asc(SessionTable.id) : desc(SessionTable.id), - ) - - 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) { - yield* result.get(input.sessionID) - 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 yield* Effect.forEach(rows, (row) => decode(row)) - }), - context: Effect.fn("V2Session.context")(function* (sessionID) { - yield* result.get(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 yield* Effect.forEach(rows, (row) => decode(row)) - }), - prompt: Effect.fn("V2Session.prompt")(function* (input) { - yield* result.get(input.sessionID) - return yield* new OperationUnavailableError({ operation: "prompt" }) - }), - shell: Effect.fn("V2Session.shell")(function* (_input) {}), - skill: Effect.fn("V2Session.skill")(function* (_input) {}), - switchAgent: Effect.fn("V2Session.switchAgent")(function* (input) { - yield* events.publish(SessionEvent.AgentSwitched, { - sessionID: input.sessionID, - timestamp: DateTime.makeUnsafe(Date.now()), - agent: input.agent, - }) - }), - switchModel: Effect.fn("V2Session.switchModel")(function* (input) { - yield* events.publish(SessionEvent.ModelSwitched, { - sessionID: input.sessionID, - timestamp: DateTime.makeUnsafe(Date.now()), - model: input.model, - }) - }), - subagent: Effect.fn("V2Session.subagent")(function* (input) { - const parent = yield* result.get(input.parentID) - const child = yield* result.create({ - agent: input.agent, - model: input.model, - parentID: input.parentID, - workspaceID: parent.workspaceID, - }) - yield* result.prompt({ - prompt: input.prompt, - sessionID: child.id, - }) - yield* Effect.gen(function* () { - yield* result.wait(child.id) - const messages = yield* result.messages({ sessionID: child.id, order: "desc" }) - const assistant = messages.find((msg) => msg.type === "assistant") - if (!assistant) return - const text = assistant.content.findLast((part) => part.type === "text") - if (!text) return - }).pipe(Effect.forkChild()) - }), - compact: Effect.fn("V2Session.compact")(function* (sessionID) { - yield* result.get(sessionID) - return yield* new OperationUnavailableError({ operation: "compact" }) - }), - wait: Effect.fn("V2Session.wait")(function* (sessionID) { - yield* result.get(sessionID) - return yield* new OperationUnavailableError({ operation: "wait" }) - }), - }) - - return result - }), -) - -export const defaultLayer = layer.pipe(Layer.provide(EventV2Bridge.defaultLayer)) - -export * as SessionV2 from "./session" diff --git a/packages/opencode/src/worktree/index.ts b/packages/opencode/src/worktree/index.ts index d9743e563b..27041925dc 100644 --- a/packages/opencode/src/worktree/index.ts +++ b/packages/opencode/src/worktree/index.ts @@ -2,14 +2,14 @@ import { Global } from "@opencode-ai/core/global" import { InstanceLayer } from "@/project/instance-layer" import { InstanceStore } from "@/project/instance-store" import { Project } from "@/project/project" -import { Database } from "@/storage/db" +import { Database } from "@opencode-ai/core/database/database" import { eq } from "drizzle-orm" -import { ProjectTable } from "../project/project.sql" -import type { ProjectID } from "../project/schema" +import { ProjectTable } from "@opencode-ai/core/project/sql" +import type { ProjectV2 } from "@opencode-ai/core/project" import * as Log from "@opencode-ai/core/util/log" import { Slug } from "@opencode-ai/core/util/slug" import { errorMessage } from "../util/error" -import { BusEvent } from "@/bus/bus-event" +import { EventV2 } from "@opencode-ai/core/event" import { GlobalBus } from "@/bus/global" import { Git } from "@/git" import { Effect, Layer, Path, Schema, Scope, Context } from "effect" @@ -22,19 +22,19 @@ import { InstanceState } from "@/effect/instance-state" const log = Log.create({ service: "worktree" }) export const Event = { - Ready: BusEvent.define( - "worktree.ready", - Schema.Struct({ + Ready: EventV2.define({ + type: "worktree.ready", + schema: { name: Schema.String, branch: Schema.optional(Schema.String), - }), - ), - Failed: BusEvent.define( - "worktree.failed", - Schema.Struct({ + }, + }), + Failed: EventV2.define({ + type: "worktree.failed", + schema: { message: Schema.String, - }), - ), + }, + }), } export const Info = Schema.Struct({ @@ -149,7 +149,7 @@ type GitResult = { code: number; text: string; stderr: string } export const layer: Layer.Layer< Service, never, - AppFileSystem.Service | Path.Path | AppProcess.Service | Git.Service | Project.Service | InstanceStore.Service + AppFileSystem.Service | Path.Path | AppProcess.Service | Git.Service | Project.Service | InstanceStore.Service | Database.Service > = Layer.effect( Service, Effect.gen(function* () { @@ -157,6 +157,7 @@ export const layer: Layer.Layer< const fs = yield* AppFileSystem.Service const pathSvc = yield* Path.Path const appProcess = yield* AppProcess.Service + const { db } = yield* Database.Service const gitSvc = yield* Git.Service const project = yield* Project.Service const store = yield* InstanceStore.Service @@ -394,6 +395,9 @@ export const layer: Layer.Layer< const directory = yield* canonical(input.directory) + // Preserve the loaded path casing for the store cache; `directory` is lowercased on Windows. + if (directory !== (yield* canonical(ctx.worktree))) yield* store.disposeDirectory(input.directory) + const list = yield* git(["worktree", "list", "--porcelain"], { cwd: ctx.worktree }) if (list.code !== 0) { return yield* new RemoveFailedError({ message: list.stderr || list.text || "Failed to read git worktrees" }) @@ -411,6 +415,8 @@ export const layer: Layer.Layer< return true } + // Git may return the original casing when a caller supplied a normalized Windows path. + yield* store.disposeDirectory(entry.path) yield* stopFsmonitor(entry.path) const removed = yield* git(["worktree", "remove", "--force", entry.path], { cwd: ctx.worktree }) if (removed.code !== 0) { @@ -476,11 +482,9 @@ export const layer: Layer.Layer< const runStartScripts = Effect.fnUntraced(function* ( directory: string, - input: { projectID: ProjectID; extra?: string }, + input: { projectID: ProjectV2.ID; extra?: string }, ) { - const row = yield* Effect.sync(() => - Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, input.projectID)).get()), - ) + const row = yield* db.select().from(ProjectTable).where(eq(ProjectTable.id, input.projectID)).get().pipe(Effect.orDie) const project = row ? Project.fromRow(row) : undefined const startup = project?.commands?.start?.trim() ?? "" const ok = yield* runStartScript(directory, startup, "project") @@ -611,6 +615,7 @@ export const appLayer = layer.pipe( Layer.provide(Git.defaultLayer), Layer.provide(AppProcess.defaultLayer), Layer.provide(Project.defaultLayer), + Layer.provide(Database.defaultLayer), Layer.provide(AppFileSystem.defaultLayer), Layer.provide(NodePath.layer), ) diff --git a/packages/opencode/test/account/repo.test.ts b/packages/opencode/test/account/repo.test.ts index 1376651543..42851fc19d 100644 --- a/packages/opencode/test/account/repo.test.ts +++ b/packages/opencode/test/account/repo.test.ts @@ -1,20 +1,21 @@ import { expect } from "bun:test" import { Effect, Layer, Option } from "effect" +import { sql } from "drizzle-orm" import { AccountRepo } from "../../src/account/repo" import { AccessToken, AccountID, OrgID, RefreshToken } from "../../src/account/schema" -import { Database } from "@/storage/db" +import { Database } from "@opencode-ai/core/database/database" import { testEffect } from "../lib/effect" const truncate = Layer.effectDiscard( - Effect.sync(() => { - const db = Database.Client() - db.run(/*sql*/ `DELETE FROM account_state`) - db.run(/*sql*/ `DELETE FROM account`) + Effect.gen(function* () { + const { db } = yield* Database.Service + yield* db.run(sql`DELETE FROM account_state`) + yield* db.run(sql`DELETE FROM account`) }), -) +).pipe(Layer.provide(Database.defaultLayer)) -const it = testEffect(Layer.merge(AccountRepo.layer, truncate)) +const it = testEffect(Layer.merge(AccountRepo.defaultLayer, truncate)) it.live("list returns empty when no accounts exist", () => Effect.gen(function* () { diff --git a/packages/opencode/test/account/service.test.ts b/packages/opencode/test/account/service.test.ts index ffe5d78a1f..04d425e2c4 100644 --- a/packages/opencode/test/account/service.test.ts +++ b/packages/opencode/test/account/service.test.ts @@ -1,5 +1,6 @@ import { expect } from "bun:test" import { Duration, Effect, Layer, Option, Schema } from "effect" +import { sql } from "drizzle-orm" import { HttpClient, HttpClientError, HttpClientResponse } from "effect/unstable/http" import { AccountRepo } from "../../src/account/repo" @@ -15,18 +16,18 @@ import { RefreshToken, UserCode, } from "../../src/account/schema" -import { Database } from "@/storage/db" +import { Database } from "@opencode-ai/core/database/database" import { testEffect } from "../lib/effect" const truncate = Layer.effectDiscard( - Effect.sync(() => { - const db = Database.Client() - db.run(/*sql*/ `DELETE FROM account_state`) - db.run(/*sql*/ `DELETE FROM account`) + Effect.gen(function* () { + const { db } = yield* Database.Service + yield* db.run(sql`DELETE FROM account_state`) + yield* db.run(sql`DELETE FROM account`) }), -) +).pipe(Layer.provide(Database.defaultLayer)) -const it = testEffect(Layer.merge(AccountRepo.layer, truncate)) +const it = testEffect(Layer.merge(AccountRepo.defaultLayer, truncate)) const insideEagerRefreshWindow = Duration.toMillis(Duration.minutes(1)) const outsideEagerRefreshWindow = Duration.toMillis(Duration.minutes(10)) diff --git a/packages/opencode/test/acp/directory.test.ts b/packages/opencode/test/acp/directory.test.ts index aa5fc12df2..5cc48d78ff 100644 --- a/packages/opencode/test/acp/directory.test.ts +++ b/packages/opencode/test/acp/directory.test.ts @@ -1,7 +1,7 @@ import { describe, expect } from "bun:test" import { Directory } from "@/acp/directory" import { Command } from "@/command" -import { ModelID, ProviderID } from "@/provider/schema" +import { ProviderV2 } from "@opencode-ai/core/provider" import { Provider } from "@/provider/provider" import { Effect, Layer } from "effect" import { it } from "../lib/effect" @@ -13,8 +13,8 @@ const command = (name: string): Command.Info => ({ hints: [], }) -const model = (providerID: ProviderID, id: string, variants?: Directory.ModelVariants): Provider.Model => ({ - id: ModelID.make(id), +const model = (providerID: ProviderV2.ID, id: string, variants?: Directory.ModelVariants): Provider.Model => ({ + id: ProviderV2.ModelID.make(id), providerID, api: { id, @@ -49,8 +49,8 @@ const model = (providerID: ProviderID, id: string, variants?: Directory.ModelVar }) const snapshot = (directory: string) => { - const providerID = ProviderID.make(`provider-${directory}`) - const modelID = ModelID.make(`model-${directory}`) + const providerID = ProviderV2.ID.make(`provider-${directory}`) + const modelID = ProviderV2.ModelID.make(`model-${directory}`) const providers = { [providerID]: { id: providerID, @@ -63,10 +63,10 @@ const snapshot = (directory: string) => { low: { reasoningEffort: "low" }, high: { reasoningEffort: "high" }, }), - [ModelID.make(`plain-${directory}`)]: model(providerID, `plain-${directory}`), + [ProviderV2.ModelID.make(`plain-${directory}`)]: model(providerID, `plain-${directory}`), }, }, - } satisfies Record + } satisfies Record return Directory.build({ directory, @@ -148,7 +148,7 @@ describe("ACP directory snapshot", () => { low: { reasoningEffort: "low" }, high: { reasoningEffort: "high" }, }) - expect(directory.variants(alpha, { ...model, modelID: ModelID.make("missing") })).toBeUndefined() + expect(directory.variants(alpha, { ...model, modelID: ProviderV2.ModelID.make("missing") })).toBeUndefined() }).pipe(Effect.provide(fakeLayer([]))), ) diff --git a/packages/opencode/test/acp/service-session.test.ts b/packages/opencode/test/acp/service-session.test.ts index 7a09fd1cc9..878d331c44 100644 --- a/packages/opencode/test/acp/service-session.test.ts +++ b/packages/opencode/test/acp/service-session.test.ts @@ -11,18 +11,18 @@ import type { SetSessionConfigOptionResponse, } from "@agentclientprotocol/sdk" import type { OpencodeClient } from "@opencode-ai/sdk/v2" +import { ProviderV2 } from "@opencode-ai/core/provider" import { Effect, ManagedRuntime } from "effect" import * as ACPService from "@/acp/service" import * as ACPError from "@/acp/error" import { ACPSession } from "@/acp/session" import { UsageService } from "@/acp/usage" -import { ModelID, ProviderID } from "@/provider/schema" import type { Provider } from "@/provider/provider" -const providerID = ProviderID.make("test") -const modelID = ModelID.make("test-model") -const configuredModelID = ModelID.make("configured-model") -const secondModelID = ModelID.make("second-model") +const providerID = ProviderV2.ID.make("test") +const modelID = ProviderV2.ModelID.make("test-model") +const configuredModelID = ProviderV2.ModelID.make("configured-model") +const secondModelID = ProviderV2.ModelID.make("second-model") const provider: Provider.Info = { id: providerID, diff --git a/packages/opencode/test/acp/session.test.ts b/packages/opencode/test/acp/session.test.ts index c033880375..a7801218e2 100644 --- a/packages/opencode/test/acp/session.test.ts +++ b/packages/opencode/test/acp/session.test.ts @@ -1,16 +1,16 @@ import { describe, expect } from "bun:test" import type { McpServer } from "@agentclientprotocol/sdk" import { Effect } from "effect" +import { ProviderV2 } from "@opencode-ai/core/provider" import * as ACPError from "@/acp/error" import * as ACPSession from "@/acp/session" -import { ModelID, ProviderID } from "@/provider/schema" import { testEffect } from "../lib/effect" const sessionTest = testEffect(ACPSession.defaultLayer) const model = (providerID: string, modelID: string): ACPSession.SelectedModel => ({ - providerID: ProviderID.make(providerID), - modelID: ModelID.make(modelID), + providerID: ProviderV2.ID.make(providerID), + modelID: ProviderV2.ModelID.make(modelID), }) const mcpServer: McpServer = { diff --git a/packages/opencode/test/acp/usage.test.ts b/packages/opencode/test/acp/usage.test.ts index 343a270138..0366f2321e 100644 --- a/packages/opencode/test/acp/usage.test.ts +++ b/packages/opencode/test/acp/usage.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test" import type { SessionNotification } from "@agentclientprotocol/sdk" +import { ProviderV2 } from "@opencode-ai/core/provider" import { UsageService } from "@/acp/usage" -import { ModelID, ProviderID } from "@/provider/schema" import { Provider } from "@/provider/provider" import { Effect, Layer } from "effect" import { it } from "../lib/effect" @@ -41,7 +41,7 @@ const assistantWithoutProvider = (): UsageService.SessionMessage => ({ }, }) -const model = (providerID: ProviderID, modelID: ModelID, context: number): Provider.Model => ({ +const model = (providerID: ProviderV2.ID, modelID: ProviderV2.ModelID, context: number): Provider.Model => ({ id: modelID, providerID, api: { @@ -75,9 +75,9 @@ const model = (providerID: ProviderID, modelID: ModelID, context: number): Provi release_date: "2026-01-01", }) -const providers = (context = 128_000): Record => { - const providerID = ProviderID.make("anthropic") - const modelID = ModelID.make("claude-sonnet") +const providers = (context = 128_000): Record => { + const providerID = ProviderV2.ID.make("anthropic") + const modelID = ProviderV2.ModelID.make("claude-sonnet") return { [providerID]: { id: providerID, @@ -94,7 +94,7 @@ const providers = (context = 128_000): Record => { const fakeLayer = (input: { readonly messages?: Effect.Effect - readonly providers?: (directory: string) => Effect.Effect, unknown> + readonly providers?: (directory: string) => Effect.Effect, unknown> }) => UsageService.layer.pipe( Layer.provide( @@ -178,13 +178,13 @@ describe("acp usage", () => { const usage = yield* UsageService.Service const first = yield* usage.contextLimit({ directory: "/workspace", - providerID: ProviderID.make("anthropic"), - modelID: ModelID.make("claude-sonnet"), + providerID: ProviderV2.ID.make("anthropic"), + modelID: ProviderV2.ModelID.make("claude-sonnet"), }) const second = yield* usage.contextLimit({ directory: "/workspace", - providerID: ProviderID.make("anthropic"), - modelID: ModelID.make("claude-sonnet"), + providerID: ProviderV2.ID.make("anthropic"), + modelID: ProviderV2.ModelID.make("claude-sonnet"), }) expect(first).toBe(200_000) diff --git a/packages/opencode/test/agent/plugin-agent-regression.test.ts b/packages/opencode/test/agent/plugin-agent-regression.test.ts index d79e01c788..60604e8111 100644 --- a/packages/opencode/test/agent/plugin-agent-regression.test.ts +++ b/packages/opencode/test/agent/plugin-agent-regression.test.ts @@ -5,7 +5,7 @@ import { FetchHttpClient } from "effect/unstable/http" import path from "path" import { pathToFileURL } from "url" import { Agent } from "../../src/agent/agent" -import { Bus } from "../../src/bus" +import { EventV2Bridge } from "../../src/event-v2-bridge" import { Config } from "../../src/config/config" import { Env } from "../../src/env" import { RuntimeFlags } from "../../src/effect/runtime-flags" @@ -33,7 +33,7 @@ const configLayer = Config.layer.pipe( Layer.provide(FetchHttpClient.layer), ) const pluginLayer = Plugin.layer.pipe( - Layer.provide(Bus.layer), + Layer.provide(EventV2Bridge.defaultLayer), Layer.provide(configLayer), Layer.provide(RuntimeFlags.layer({ disableDefaultPlugins: true })), ) diff --git a/packages/opencode/test/auth/auth.test.ts b/packages/opencode/test/auth/auth.test.ts index 55e950aab6..58ce6ea718 100644 --- a/packages/opencode/test/auth/auth.test.ts +++ b/packages/opencode/test/auth/auth.test.ts @@ -2,7 +2,6 @@ import { describe, expect } from "bun:test" import { Effect, Layer } from "effect" import { Auth } from "../../src/auth" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" -import { provideTmpdirInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" const node = CrossSpawnSpawner.defaultLayer @@ -10,77 +9,69 @@ const node = CrossSpawnSpawner.defaultLayer const it = testEffect(Layer.mergeAll(Auth.defaultLayer, node)) describe("Auth", () => { - it.live("set normalizes trailing slashes in keys", () => - provideTmpdirInstance(() => - Effect.gen(function* () { - const auth = yield* Auth.Service - yield* auth.set("https://example.com/", { - type: "wellknown", - key: "TOKEN", - token: "abc", - }) - const data = yield* auth.all() - expect(data["https://example.com"]).toBeDefined() - expect(data["https://example.com/"]).toBeUndefined() - }), - ), + it.instance("set normalizes trailing slashes in keys", () => + Effect.gen(function* () { + const auth = yield* Auth.Service + yield* auth.set("https://example.com/", { + type: "wellknown", + key: "TOKEN", + token: "abc", + }) + const data = yield* auth.all() + expect(data["https://example.com"]).toBeDefined() + expect(data["https://example.com/"]).toBeUndefined() + }), ) - it.live("set cleans up pre-existing trailing-slash entry", () => - provideTmpdirInstance(() => - Effect.gen(function* () { - const auth = yield* Auth.Service - yield* auth.set("https://example.com/", { - type: "wellknown", - key: "TOKEN", - token: "old", - }) - yield* auth.set("https://example.com", { - type: "wellknown", - key: "TOKEN", - token: "new", - }) - const data = yield* auth.all() - const keys = Object.keys(data).filter((key) => key.includes("example.com")) - expect(keys).toEqual(["https://example.com"]) - const entry = data["https://example.com"]! - expect(entry.type).toBe("wellknown") - if (entry.type === "wellknown") expect(entry.token).toBe("new") - }), - ), + it.instance("set cleans up pre-existing trailing-slash entry", () => + Effect.gen(function* () { + const auth = yield* Auth.Service + yield* auth.set("https://example.com/", { + type: "wellknown", + key: "TOKEN", + token: "old", + }) + yield* auth.set("https://example.com", { + type: "wellknown", + key: "TOKEN", + token: "new", + }) + const data = yield* auth.all() + const keys = Object.keys(data).filter((key) => key.includes("example.com")) + expect(keys).toEqual(["https://example.com"]) + const entry = data["https://example.com"]! + expect(entry.type).toBe("wellknown") + if (entry.type === "wellknown") expect(entry.token).toBe("new") + }), ) - it.live("remove deletes both trailing-slash and normalized keys", () => - provideTmpdirInstance(() => - Effect.gen(function* () { - const auth = yield* Auth.Service - yield* auth.set("https://example.com", { - type: "wellknown", - key: "TOKEN", - token: "abc", - }) - yield* auth.remove("https://example.com/") - const data = yield* auth.all() - expect(data["https://example.com"]).toBeUndefined() - expect(data["https://example.com/"]).toBeUndefined() - }), - ), + it.instance("remove deletes both trailing-slash and normalized keys", () => + Effect.gen(function* () { + const auth = yield* Auth.Service + yield* auth.set("https://example.com", { + type: "wellknown", + key: "TOKEN", + token: "abc", + }) + yield* auth.remove("https://example.com/") + const data = yield* auth.all() + expect(data["https://example.com"]).toBeUndefined() + expect(data["https://example.com/"]).toBeUndefined() + }), ) - it.live("set and remove are no-ops on keys without trailing slashes", () => - provideTmpdirInstance(() => - Effect.gen(function* () { - const auth = yield* Auth.Service - yield* auth.set("anthropic", { - type: "api", - key: "sk-test", - }) - const data = yield* auth.all() - expect(data["anthropic"]).toBeDefined() - yield* auth.remove("anthropic") - const after = yield* auth.all() - expect(after["anthropic"]).toBeUndefined() - }), - ), + it.instance("set and remove are no-ops on keys without trailing slashes", () => + Effect.gen(function* () { + const auth = yield* Auth.Service + yield* auth.set("anthropic", { + type: "api", + key: "sk-test", + }) + const data = yield* auth.all() + expect(data["anthropic"]).toBeDefined() + yield* auth.remove("anthropic") + const after = yield* auth.all() + expect(after["anthropic"]).toBeUndefined() + }), ) }) diff --git a/packages/opencode/test/bus/bus-effect.test.ts b/packages/opencode/test/bus/bus-effect.test.ts deleted file mode 100644 index dfe653dd10..0000000000 --- a/packages/opencode/test/bus/bus-effect.test.ts +++ /dev/null @@ -1,288 +0,0 @@ -import { describe, expect } from "bun:test" -import { Deferred, Effect, Fiber, Latch, Layer, Schema, Stream } from "effect" -import { Bus } from "../../src/bus" -import { BusEvent } from "../../src/bus/bus-event" -import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" -import { disposeAllInstances, provideInstance, tmpdirScoped } from "../fixture/fixture" -import { testEffect } from "../lib/effect" - -const TestEvent = { - Ping: BusEvent.define("test.effect.ping", Schema.Struct({ value: Schema.Number })), - Pong: BusEvent.define("test.effect.pong", Schema.Struct({ message: Schema.String })), - Warmup: BusEvent.define("test.effect.warmup", Schema.Struct({})), -} - -const node = CrossSpawnSpawner.defaultLayer - -const live = Layer.mergeAll(Bus.layer, node) - -const it = testEffect(live) - -// Publishes warmup events until the latch opens, proving the forked subscriber -// fiber has actually wired up its PubSub subscription. -const awaitSubscriberReady = Effect.fn("test.awaitSubscriberReady")(function* ( - ready: Latch.Latch, - warmup: Effect.Effect, -) { - const pump = yield* Effect.forkScoped( - Effect.gen(function* () { - while (true) { - yield* warmup - yield* Effect.sleep("5 millis") - } - }), - ) - yield* ready.await.pipe(Effect.timeout("2 seconds")) - yield* Fiber.interrupt(pump) -}) - -describe("Bus (Effect-native)", () => { - it.instance("publish + subscribe stream delivers events", () => - Effect.gen(function* () { - const bus = yield* Bus.Service - const received: number[] = [] - const done = yield* Deferred.make() - const ready = yield* Latch.make() - - yield* Stream.runForEach(yield* bus.subscribe(TestEvent.Ping), (evt) => - Effect.gen(function* () { - if (evt.properties.value < 0) { - yield* ready.open - return - } - received.push(evt.properties.value) - if (received.length === 2) Deferred.doneUnsafe(done, Effect.void) - }), - ).pipe(Effect.forkScoped) - - yield* awaitSubscriberReady(ready, bus.publish(TestEvent.Ping, { value: -1 })) - yield* bus.publish(TestEvent.Ping, { value: 1 }) - yield* bus.publish(TestEvent.Ping, { value: 2 }) - yield* Deferred.await(done) - - expect(received).toEqual([1, 2]) - }), - ) - - it.instance("subscribe filters by event type", () => - Effect.gen(function* () { - const bus = yield* Bus.Service - const pings: number[] = [] - const done = yield* Deferred.make() - const ready = yield* Latch.make() - - yield* Stream.runForEach(yield* bus.subscribe(TestEvent.Ping), (evt) => - Effect.gen(function* () { - if (evt.properties.value < 0) { - yield* ready.open - return - } - pings.push(evt.properties.value) - Deferred.doneUnsafe(done, Effect.void) - }), - ).pipe(Effect.forkScoped) - - yield* awaitSubscriberReady(ready, bus.publish(TestEvent.Ping, { value: -1 })) - yield* bus.publish(TestEvent.Pong, { message: "ignored" }) - yield* bus.publish(TestEvent.Ping, { value: 42 }) - yield* Deferred.await(done) - - expect(pings).toEqual([42]) - }), - ) - - it.instance("subscribeAll receives all types", () => - Effect.gen(function* () { - const bus = yield* Bus.Service - const types: string[] = [] - const done = yield* Deferred.make() - const ready = yield* Latch.make() - - yield* Stream.runForEach(yield* bus.subscribeAll(), (evt) => - Effect.gen(function* () { - if (evt.type === TestEvent.Warmup.type) { - yield* ready.open - return - } - types.push(evt.type) - if (types.length === 2) Deferred.doneUnsafe(done, Effect.void) - }), - ).pipe(Effect.forkScoped) - - yield* awaitSubscriberReady(ready, bus.publish(TestEvent.Warmup, {})) - 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") - }), - ) - - 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() - const readyA = yield* Latch.make() - const readyB = yield* Latch.make() - - yield* Stream.runForEach(yield* bus.subscribe(TestEvent.Ping), (evt) => - Effect.gen(function* () { - if (evt.properties.value < 0) { - yield* readyA.open - return - } - a.push(evt.properties.value) - Deferred.doneUnsafe(doneA, Effect.void) - }), - ).pipe(Effect.forkScoped) - - yield* Stream.runForEach(yield* bus.subscribe(TestEvent.Ping), (evt) => - Effect.gen(function* () { - if (evt.properties.value < 0) { - yield* readyB.open - return - } - b.push(evt.properties.value) - Deferred.doneUnsafe(doneB, Effect.void) - }), - ).pipe(Effect.forkScoped) - - yield* awaitSubscriberReady(readyA, bus.publish(TestEvent.Ping, { value: -1 })) - yield* awaitSubscriberReady(readyB, bus.publish(TestEvent.Ping, { value: -1 })) - yield* bus.publish(TestEvent.Ping, { value: 99 }) - yield* Deferred.await(doneA) - yield* Deferred.await(doneB) - - expect(a).toEqual([99]) - expect(b).toEqual([99]) - }), - ) - - // RACE 1: eager subscription means publishing immediately after yield* - // bus.subscribe is delivered. Regression for the old lazy `Stream.unwrap` - // shape where PubSub.subscribe ran on first pull and missed any publish - // in the hand-off window. - it.instance("eager subscribe: publish after yield* is delivered without consumer-activation race", () => - Effect.gen(function* () { - const bus = yield* Bus.Service - const stream = yield* bus.subscribe(TestEvent.Ping) - - // Hand-off window: subscription is alive (we yielded). Publish goes - // straight into the subscription queue, even with no consumer running. - yield* bus.publish(TestEvent.Ping, { value: 99 }) - - const collected = yield* stream.pipe( - Stream.take(1), - Stream.runCollect, - Effect.timeout("400 millis"), - Effect.option, - ) - - expect(collected._tag).toBe("Some") - if (collected._tag === "Some") { - const arr = Array.from(collected.value) - expect(arr[0].properties.value).toBe(99) - } - }), - ) - - // RACE 2: same property for subscribeAll. - it.instance("eager subscribeAll: publish after yield* is delivered", () => - Effect.gen(function* () { - const bus = yield* Bus.Service - const stream = yield* bus.subscribeAll() - - yield* bus.publish(TestEvent.Ping, { value: 42 }) - - const collected = yield* stream.pipe( - Stream.take(1), - Stream.runCollect, - Effect.timeout("400 millis"), - Effect.option, - ) - - expect(collected._tag).toBe("Some") - if (collected._tag === "Some") { - const arr = Array.from(collected.value) - expect(arr[0].type).toBe(TestEvent.Ping.type) - } - }), - ) - - // RACE 3: the /event-handler shape exactly. With eager subscription, the - // bus subscription is alive before Stream.concat ever starts. Publishes - // during the prefix consumption window are queued and delivered. - it.instance("eager subscribe: Stream.concat(initial, subscribe) delivers publish during prefix", () => - Effect.gen(function* () { - const bus = yield* Bus.Service - const sawInitial = yield* Deferred.make() - const sawPublish = yield* Deferred.make() - - type Frame = { marker?: "initial"; value?: number } - const subscriptionStream = yield* bus.subscribe(TestEvent.Ping) - const handlerStream: Stream.Stream = Stream.make({ marker: "initial" } as Frame).pipe( - Stream.concat(subscriptionStream.pipe(Stream.map((evt): Frame => ({ value: evt.properties.value })))), - ) - - yield* Stream.runForEach(handlerStream, (frame) => - Effect.gen(function* () { - if (frame.marker === "initial") { - Deferred.doneUnsafe(sawInitial, Effect.void) - return - } - if (frame.value !== undefined) Deferred.doneUnsafe(sawPublish, Effect.succeed(frame.value)) - }), - ).pipe(Effect.forkScoped) - - yield* Deferred.await(sawInitial).pipe(Effect.timeout("1 second")) - - yield* bus.publish(TestEvent.Ping, { value: 7 }) - - const got = yield* Deferred.await(sawPublish).pipe(Effect.timeout("1 second"), Effect.option) - expect(got._tag).toBe("Some") - if (got._tag === "Some") expect(got.value).toBe(7) - }), - ) - - it.live("subscribeAll stream sees InstanceDisposed on disposal", () => - Effect.gen(function* () { - const dir = yield* tmpdirScoped() - const types: string[] = [] - const seen = yield* Deferred.make() - const disposed = yield* Deferred.make() - const ready = yield* Latch.make() - - // Set up subscriber inside the instance - yield* Effect.gen(function* () { - const bus = yield* Bus.Service - - yield* Stream.runForEach(yield* bus.subscribeAll(), (evt) => - Effect.gen(function* () { - if (evt.type === TestEvent.Warmup.type) { - yield* ready.open - return - } - types.push(evt.type) - if (evt.type === TestEvent.Ping.type) Deferred.doneUnsafe(seen, Effect.void) - if (evt.type === Bus.InstanceDisposed.type) Deferred.doneUnsafe(disposed, Effect.void) - }), - ).pipe(Effect.forkScoped) - - yield* awaitSubscriberReady(ready, bus.publish(TestEvent.Warmup, {})) - yield* bus.publish(TestEvent.Ping, { value: 1 }) - yield* Deferred.await(seen) - }).pipe(provideInstance(dir)) - - // Dispose from OUTSIDE the instance scope - yield* Effect.promise(disposeAllInstances) - yield* Deferred.await(disposed).pipe(Effect.timeout("2 seconds")) - - expect(types).toContain("test.effect.ping") - expect(types).toContain(Bus.InstanceDisposed.type) - }), - ) -}) diff --git a/packages/opencode/test/bus/bus-integration.test.ts b/packages/opencode/test/bus/bus-integration.test.ts deleted file mode 100644 index 645a94fb3b..0000000000 --- a/packages/opencode/test/bus/bus-integration.test.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" -import { afterEach, describe, expect } from "bun:test" -import { Deferred, Effect, Layer, Schema } from "effect" -import { Bus } from "../../src/bus" -import { BusEvent } from "../../src/bus/bus-event" -import { disposeAllInstances, provideInstance, tmpdirScoped } from "../fixture/fixture" -import { testEffect } from "../lib/effect" - -const TestEvent = BusEvent.define("test.integration", Schema.Struct({ value: Schema.Number })) -const it = testEffect(Layer.mergeAll(Bus.layer, CrossSpawnSpawner.defaultLayer)) - -describe("Bus integration: acquireRelease subscriber pattern", () => { - afterEach(() => disposeAllInstances()) - - it.instance("subscriber via callback facade receives events and cleans up on unsub", () => - Effect.gen(function* () { - const bus = yield* Bus.Service - const received: number[] = [] - const receivedTwo = yield* Deferred.make() - - const unsub = yield* bus.subscribeCallback(TestEvent, (evt) => { - received.push(evt.properties.value) - if (received.length === 2) Deferred.doneUnsafe(receivedTwo, Effect.void) - }) - yield* bus.publish(TestEvent, { value: 1 }) - yield* bus.publish(TestEvent, { value: 2 }) - yield* Deferred.await(receivedTwo).pipe(Effect.timeout("2 seconds")) - - expect(received).toEqual([1, 2]) - - yield* Effect.sync(unsub) - yield* bus.publish(TestEvent, { value: 3 }) - yield* Effect.sleep("10 millis") - - expect(received).toEqual([1, 2]) - }), - ) - - it.instance("subscribeAll receives events from multiple types", () => - Effect.gen(function* () { - const bus = yield* Bus.Service - const received: Array<{ type: string; value?: number }> = [] - const OtherEvent = BusEvent.define("test.other", Schema.Struct({ value: Schema.Number })) - const receivedTwo = yield* Deferred.make() - - yield* bus.subscribeAllCallback((evt) => { - received.push({ type: evt.type, value: evt.properties.value }) - if (received.length === 2) Deferred.doneUnsafe(receivedTwo, Effect.void) - }) - yield* bus.publish(TestEvent, { value: 10 }) - yield* bus.publish(OtherEvent, { value: 20 }) - yield* Deferred.await(receivedTwo).pipe(Effect.timeout("2 seconds")) - - expect(received).toEqual([ - { type: "test.integration", value: 10 }, - { type: "test.other", value: 20 }, - ]) - }), - ) - - it.live("subscriber cleanup on instance disposal interrupts the stream", () => - Effect.gen(function* () { - const dir = yield* tmpdirScoped() - const received: number[] = [] - const seen = yield* Deferred.make() - const disposed = yield* Deferred.make() - - yield* Effect.gen(function* () { - const bus = yield* Bus.Service - yield* bus.subscribeAllCallback((evt) => { - if (evt.type === Bus.InstanceDisposed.type) { - Deferred.doneUnsafe(disposed, Effect.void) - return - } - received.push(evt.properties.value) - Deferred.doneUnsafe(seen, Effect.void) - }) - yield* bus.publish(TestEvent, { value: 1 }) - yield* Deferred.await(seen).pipe(Effect.timeout("2 seconds")) - }).pipe(provideInstance(dir)) - - yield* Effect.promise(() => disposeAllInstances()) - yield* Deferred.await(disposed).pipe(Effect.timeout("2 seconds")) - - expect(received).toEqual([1]) - }), - ) -}) diff --git a/packages/opencode/test/bus/bus.test.ts b/packages/opencode/test/bus/bus.test.ts deleted file mode 100644 index 0844986162..0000000000 --- a/packages/opencode/test/bus/bus.test.ts +++ /dev/null @@ -1,240 +0,0 @@ -import { afterEach, describe, expect } from "bun:test" -import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" -import { Deferred, Effect, Layer, Schema } from "effect" -import { Bus } from "../../src/bus" -import { BusEvent } from "../../src/bus/bus-event" -import { disposeAllInstances, provideInstance, tmpdirScoped } from "../fixture/fixture" -import { testEffect } from "../lib/effect" - -const TestEvent = { - Ping: BusEvent.define("test.ping", Schema.Struct({ value: Schema.Number })), - Pong: BusEvent.define("test.pong", Schema.Struct({ message: Schema.String })), -} - -const it = testEffect(Layer.mergeAll(Bus.layer, CrossSpawnSpawner.defaultLayer)) - -describe("Bus", () => { - afterEach(() => disposeAllInstances()) - - describe("publish + subscribe", () => { - it.instance("subscriber is live immediately after subscribe returns", () => - Effect.gen(function* () { - const bus = yield* Bus.Service - const received: number[] = [] - const done = yield* Deferred.make() - - yield* bus.subscribeCallback(TestEvent.Ping, (evt) => { - received.push(evt.properties.value) - Deferred.doneUnsafe(done, Effect.void) - }) - yield* bus.publish(TestEvent.Ping, { value: 42 }) - yield* Deferred.await(done).pipe(Effect.timeout("2 seconds")) - - expect(received).toEqual([42]) - }), - ) - - it.instance("subscriber receives matching events", () => - Effect.gen(function* () { - const bus = yield* Bus.Service - const received: number[] = [] - const done = yield* Deferred.make() - - yield* bus.subscribeCallback(TestEvent.Ping, (evt) => { - received.push(evt.properties.value) - if (received.length === 2) Deferred.doneUnsafe(done, Effect.void) - }) - yield* bus.publish(TestEvent.Ping, { value: 42 }) - yield* bus.publish(TestEvent.Ping, { value: 99 }) - yield* Deferred.await(done).pipe(Effect.timeout("2 seconds")) - - expect(received).toEqual([42, 99]) - }), - ) - - it.instance("subscriber does not receive events of other types", () => - Effect.gen(function* () { - const bus = yield* Bus.Service - const pings: number[] = [] - const done = yield* Deferred.make() - - yield* bus.subscribeCallback(TestEvent.Ping, (evt) => { - pings.push(evt.properties.value) - Deferred.doneUnsafe(done, Effect.void) - }) - yield* bus.publish(TestEvent.Pong, { message: "hello" }) - yield* bus.publish(TestEvent.Ping, { value: 1 }) - yield* Deferred.await(done).pipe(Effect.timeout("2 seconds")) - - expect(pings).toEqual([1]) - }), - ) - - it.instance("publish with no subscribers does not throw", () => - Effect.gen(function* () { - const bus = yield* Bus.Service - yield* bus.publish(TestEvent.Ping, { value: 1 }) - }), - ) - }) - - describe("unsubscribe", () => { - it.instance("unsubscribe stops delivery", () => - Effect.gen(function* () { - const bus = yield* Bus.Service - const received: number[] = [] - const first = yield* Deferred.make() - - const unsub = yield* bus.subscribeCallback(TestEvent.Ping, (evt) => { - received.push(evt.properties.value) - if (evt.properties.value === 1) Deferred.doneUnsafe(first, Effect.void) - }) - yield* bus.publish(TestEvent.Ping, { value: 1 }) - yield* Deferred.await(first).pipe(Effect.timeout("2 seconds")) - yield* Effect.sync(unsub) - yield* bus.publish(TestEvent.Ping, { value: 2 }) - yield* Effect.sleep("10 millis") - - expect(received).toEqual([1]) - }), - ) - }) - - describe("subscribeAll", () => { - it.instance("subscribeAll is live immediately after subscribe returns", () => - Effect.gen(function* () { - const bus = yield* Bus.Service - const received: string[] = [] - const done = yield* Deferred.make() - - yield* bus.subscribeAllCallback((evt) => { - received.push(evt.type) - Deferred.doneUnsafe(done, Effect.void) - }) - yield* bus.publish(TestEvent.Ping, { value: 1 }) - yield* Deferred.await(done).pipe(Effect.timeout("2 seconds")) - - expect(received).toEqual(["test.ping"]) - }), - ) - - it.instance("receives all event types", () => - Effect.gen(function* () { - const bus = yield* Bus.Service - const received: string[] = [] - const done = yield* Deferred.make() - - yield* bus.subscribeAllCallback((evt) => { - received.push(evt.type) - if (received.length === 2) Deferred.doneUnsafe(done, Effect.void) - }) - yield* bus.publish(TestEvent.Ping, { value: 1 }) - yield* bus.publish(TestEvent.Pong, { message: "hi" }) - yield* Deferred.await(done).pipe(Effect.timeout("2 seconds")) - - expect(received).toContain("test.ping") - expect(received).toContain("test.pong") - }), - ) - }) - - describe("multiple subscribers", () => { - it.instance("all subscribers for same event type are called", () => - 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* bus.subscribeCallback(TestEvent.Ping, (evt) => { - a.push(evt.properties.value) - Deferred.doneUnsafe(doneA, Effect.void) - }) - yield* bus.subscribeCallback(TestEvent.Ping, (evt) => { - b.push(evt.properties.value) - Deferred.doneUnsafe(doneB, Effect.void) - }) - yield* bus.publish(TestEvent.Ping, { value: 7 }) - yield* Deferred.await(doneA).pipe(Effect.timeout("2 seconds")) - yield* Deferred.await(doneB).pipe(Effect.timeout("2 seconds")) - - expect(a).toEqual([7]) - expect(b).toEqual([7]) - }), - ) - }) - - describe("instance isolation", () => { - it.live("events in one directory do not reach subscribers in another", () => - Effect.gen(function* () { - const tmpA = yield* tmpdirScoped() - const tmpB = yield* tmpdirScoped() - const receivedA: number[] = [] - const receivedB: number[] = [] - const doneA = yield* Deferred.make() - const doneB = yield* Deferred.make() - - yield* Effect.gen(function* () { - const bus = yield* Bus.Service - yield* bus.subscribeCallback(TestEvent.Ping, (evt) => { - receivedA.push(evt.properties.value) - Deferred.doneUnsafe(doneA, Effect.void) - }) - }).pipe(provideInstance(tmpA)) - - yield* Effect.gen(function* () { - const bus = yield* Bus.Service - yield* bus.subscribeCallback(TestEvent.Ping, (evt) => { - receivedB.push(evt.properties.value) - Deferred.doneUnsafe(doneB, Effect.void) - }) - }).pipe(provideInstance(tmpB)) - - yield* Effect.gen(function* () { - const bus = yield* Bus.Service - yield* bus.publish(TestEvent.Ping, { value: 1 }) - }).pipe(provideInstance(tmpA)) - - yield* Effect.gen(function* () { - const bus = yield* Bus.Service - yield* bus.publish(TestEvent.Ping, { value: 2 }) - }).pipe(provideInstance(tmpB)) - - yield* Deferred.await(doneA).pipe(Effect.timeout("2 seconds")) - yield* Deferred.await(doneB).pipe(Effect.timeout("2 seconds")) - - expect(receivedA).toEqual([1]) - expect(receivedB).toEqual([2]) - }), - ) - }) - - describe("instance disposal", () => { - it.live("InstanceDisposed is delivered to wildcard subscribers before stream ends", () => - Effect.gen(function* () { - const tmp = yield* tmpdirScoped() - const received: string[] = [] - const seen = yield* Deferred.make() - const disposed = yield* Deferred.make() - - yield* Effect.gen(function* () { - const bus = yield* Bus.Service - yield* bus.subscribeAllCallback((evt) => { - received.push(evt.type) - if (evt.type === TestEvent.Ping.type) Deferred.doneUnsafe(seen, Effect.void) - if (evt.type === Bus.InstanceDisposed.type) Deferred.doneUnsafe(disposed, Effect.void) - }) - yield* bus.publish(TestEvent.Ping, { value: 1 }) - yield* Deferred.await(seen).pipe(Effect.timeout("2 seconds")) - }).pipe(provideInstance(tmp)) - - yield* Effect.promise(disposeAllInstances) - yield* Deferred.await(disposed).pipe(Effect.timeout("2 seconds")) - - expect(received).toContain("test.ping") - expect(received).toContain(Bus.InstanceDisposed.type) - }), - ) - }) -}) diff --git a/packages/opencode/test/cli/github-action.test.ts b/packages/opencode/test/cli/github-action.test.ts index 263f3a45f3..35f8e44d79 100644 --- a/packages/opencode/test/cli/github-action.test.ts +++ b/packages/opencode/test/cli/github-action.test.ts @@ -1,10 +1,11 @@ import { test, expect, describe } from "bun:test" +import { SessionLegacy } from "@opencode-ai/core/session/legacy" import { extractResponseText, formatPromptTooLargeError } from "../../src/cli/cmd/github" import type { MessageV2 } from "../../src/session/message-v2" import { SessionID, MessageID, PartID } from "../../src/session/schema" // Helper to create minimal valid parts -function createTextPart(text: string): MessageV2.Part { +function createTextPart(text: string): SessionLegacy.Part { return { id: PartID.ascending(), sessionID: SessionID.make("ses_test"), @@ -14,7 +15,7 @@ function createTextPart(text: string): MessageV2.Part { } } -function createReasoningPart(text: string): MessageV2.Part { +function createReasoningPart(text: string): SessionLegacy.Part { return { id: PartID.ascending(), sessionID: SessionID.make("ses_test"), @@ -25,7 +26,7 @@ function createReasoningPart(text: string): MessageV2.Part { } } -function createToolPart(tool: string, title: string, status: "completed" | "running" = "completed"): MessageV2.Part { +function createToolPart(tool: string, title: string, status: "completed" | "running" = "completed"): SessionLegacy.Part { if (status === "completed") { return { id: PartID.ascending(), @@ -59,7 +60,7 @@ function createToolPart(tool: string, title: string, status: "completed" | "runn } } -function createStepStartPart(): MessageV2.Part { +function createStepStartPart(): SessionLegacy.Part { return { id: PartID.ascending(), sessionID: SessionID.make("ses_test"), @@ -68,7 +69,7 @@ function createStepStartPart(): MessageV2.Part { } } -function createStepFinishPart(): MessageV2.Part { +function createStepFinishPart(): SessionLegacy.Part { return { id: PartID.ascending(), sessionID: SessionID.make("ses_test"), diff --git a/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap b/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap index 22ad59aaa2..14882e264b 100644 --- a/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap +++ b/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap @@ -398,7 +398,6 @@ database tools Commands: opencode db [query] open an interactive sqlite3 shell or run a query [default] opencode db path print the database path - opencode db migrate migrate JSON data to SQLite (merges with existing data) Positionals: query SQL query to execute [string] diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index 4d5aaf6fe1..85cb78a329 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -31,7 +31,7 @@ import fs from "fs/promises" import os from "os" import { pathToFileURL } from "url" import { Global } from "@opencode-ai/core/global" -import { ProjectID } from "../../src/project/schema" +import { ProjectV2 } from "@opencode-ai/core/project" import { Filesystem } from "@/util/filesystem" import { ConfigPlugin } from "@/config/plugin" import { AccountTest } from "../fake/account" @@ -275,7 +275,7 @@ async function check(map: (dir: string) => string) { const cfg = await load(ctx) expect(cfg.snapshot).toBe(true) expect(ctx.directory).toBe(Filesystem.resolve(tmp.path)) - expect(ctx.project.id).not.toBe(ProjectID.global) + expect(ctx.project.id).not.toBe(ProjectV2.ID.global) }, }) } finally { @@ -1500,15 +1500,18 @@ test("remote well-known config can use FetchHttpClient layer", async () => { ).pipe( Effect.scoped, Effect.provide( - Config.layer.pipe( - Layer.provide(testFlock), - Layer.provide(AppFileSystem.defaultLayer), - Layer.provide(Env.defaultLayer), - Layer.provide(wellKnownAuth(server.url.origin)), - Layer.provide(AccountTest.empty), - Layer.provideMerge(infra), - Layer.provide(NpmTest.noop), - Layer.provide(FetchHttpClient.layer), + Layer.mergeAll( + Config.layer.pipe( + Layer.provide(testFlock), + Layer.provide(AppFileSystem.defaultLayer), + Layer.provide(Env.defaultLayer), + Layer.provide(wellKnownAuth(server.url.origin)), + Layer.provide(AccountTest.empty), + Layer.provideMerge(infra), + Layer.provide(NpmTest.noop), + Layer.provide(FetchHttpClient.layer), + ), + testInstanceStoreLayer, ), ), Effect.runPromise, diff --git a/packages/opencode/test/control-plane/adapters.test.ts b/packages/opencode/test/control-plane/adapters.test.ts index 762bb5d57e..fbeb7eeb2e 100644 --- a/packages/opencode/test/control-plane/adapters.test.ts +++ b/packages/opencode/test/control-plane/adapters.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test" import { getAdapter, registerAdapter } from "../../src/control-plane/adapters" -import { ProjectID } from "../../src/project/schema" +import { ProjectV2 } from "@opencode-ai/core/project" import type { WorkspaceInfo } from "../../src/control-plane/types" function info(projectID: WorkspaceInfo["projectID"], type: string): WorkspaceInfo { @@ -36,8 +36,8 @@ function adapter(dir: string) { describe("control-plane/adapters", () => { test("isolates custom adapters by project", async () => { const type = `demo-${Math.random().toString(36).slice(2)}` - const one = ProjectID.make(`project-${Math.random().toString(36).slice(2)}`) - const two = ProjectID.make(`project-${Math.random().toString(36).slice(2)}`) + const one = ProjectV2.ID.make(`project-${Math.random().toString(36).slice(2)}`) + const two = ProjectV2.ID.make(`project-${Math.random().toString(36).slice(2)}`) registerAdapter(one, type, adapter("/one")) registerAdapter(two, type, adapter("/two")) @@ -53,7 +53,7 @@ describe("control-plane/adapters", () => { test("latest install wins within a project", async () => { const type = `demo-${Math.random().toString(36).slice(2)}` - const id = ProjectID.make(`project-${Math.random().toString(36).slice(2)}`) + const id = ProjectV2.ID.make(`project-${Math.random().toString(36).slice(2)}`) registerAdapter(id, type, adapter("/one")) expect(await (await getAdapter(id, type)).target(info(id, type))).toEqual({ diff --git a/packages/opencode/test/control-plane/workspace.test.ts b/packages/opencode/test/control-plane/workspace.test.ts index 09810d57d7..b8928f87a1 100644 --- a/packages/opencode/test/control-plane/workspace.test.ts +++ b/packages/opencode/test/control-plane/workspace.test.ts @@ -10,20 +10,19 @@ import { eq } from "drizzle-orm" import { AppFileSystem } from "@opencode-ai/core/filesystem" import * as Log from "@opencode-ai/core/util/log" import { GlobalBus, type GlobalEvent } from "@/bus/global" -import { Database } from "@/storage/db" -import { ProjectID } from "@/project/schema" -import { ProjectTable } from "@/project/project.sql" +import { Database } from "@opencode-ai/core/database/database" +import { ProjectV2 } from "@opencode-ai/core/project" +import { ProjectTable } from "@opencode-ai/core/project/sql" import { Session as SessionNs } from "@/session/session" import { SessionID } from "@/session/schema" -import { SessionTable } from "@/session/session.sql" -import { SyncEvent } from "@/sync" -import { EventSequenceTable } from "@/sync/event.sql" +import { SessionTable } from "@opencode-ai/core/session/sql" +import { EventSequenceTable } from "@opencode-ai/core/event/sql" import { resetDatabase } from "../fixture/db" import { disposeAllInstances, provideTmpdirInstance, requireInstance, TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" import { registerAdapter } from "../../src/control-plane/adapters" -import { WorkspaceID } from "../../src/control-plane/schema" -import { WorkspaceTable } from "../../src/control-plane/workspace.sql" +import { WorkspaceV2 } from "@opencode-ai/core/workspace" +import { WorkspaceTable } from "@opencode-ai/core/control-plane/workspace.sql" import type { Target, WorkspaceAdapter, WorkspaceInfo } from "../../src/control-plane/types" import * as Workspace from "../../src/control-plane/workspace" import { InstanceStore } from "@/project/instance-store" @@ -33,6 +32,7 @@ import { SessionPrompt } from "@/session/prompt" import { Project } from "@/project/project" import { Vcs } from "@/project/vcs" import { RuntimeFlags } from "@/effect/runtime-flags" +import { EventV2Bridge } from "@/event-v2-bridge" void Log.init({ print: false }) @@ -48,10 +48,11 @@ const workspaceLayer = (experimentalWorkspaces: boolean) => Workspace.layer.pipe( Layer.provide(Auth.defaultLayer), Layer.provide(SessionNs.defaultLayer), - Layer.provide(SyncEvent.defaultLayer), Layer.provide(SessionPrompt.defaultLayer), Layer.provide(Project.defaultLayer), Layer.provide(Vcs.defaultLayer), + Layer.provide(Database.defaultLayer), + Layer.provide(EventV2Bridge.defaultLayer), Layer.provide(FetchHttpClient.layer), Layer.provide(AppFileSystem.defaultLayer), Layer.provide(RuntimeFlags.layer({ experimentalWorkspaces })), @@ -62,6 +63,7 @@ const testServerLayer = Layer.mergeAll( NodeHttpServer.layer(Http.createServer, { host: "127.0.0.1", port: 0 }), workspaceLayer(true), SessionNs.defaultLayer, + Database.defaultLayer, ) const it = testEffect(testServerLayer) @@ -105,7 +107,6 @@ function restoreEnv() { } beforeEach(() => { - Database.close() restoreEnv() process.env.OPENCODE_EXPERIMENTAL_WORKSPACES = "true" }) @@ -129,7 +130,7 @@ async function initGitRepo(dir: string) { await $`git commit -m "base"`.cwd(dir).quiet() } -const startWorkspaceSyncingWithFlag = (projectID: ProjectID, experimentalWorkspaces: boolean) => +const startWorkspaceSyncingWithFlag = (projectID: ProjectV2.ID, experimentalWorkspaces: boolean) => Effect.runPromise( Workspace.use.startWorkspaceSyncing(projectID).pipe(Effect.provide(workspaceLayer(experimentalWorkspaces))), ) @@ -265,9 +266,9 @@ function serverUrl() { }) } -function workspaceInfo(projectID: ProjectID, type: string, input?: Partial): Workspace.Info { +function workspaceInfo(projectID: ProjectV2.ID, type: string, input?: Partial): Workspace.Info { return { - id: input?.id ?? WorkspaceID.ascending(), + id: input?.id ?? WorkspaceV2.ID.ascending(), type, name: input?.name ?? unique("workspace"), branch: input?.branch ?? null, @@ -279,7 +280,7 @@ function workspaceInfo(projectID: ProjectID, type: string, input?: Partial + return Database.Service.use(({ db }) => db .insert(WorkspaceTable) .values({ @@ -292,12 +293,13 @@ function insertWorkspace(info: Workspace.Info) { project_id: info.projectID, time_used: info.timeUsed, }) - .run(), + .run() + .pipe(Effect.orDie), ) } -function insertProject(id: ProjectID, worktree: string) { - Database.use((db) => +function insertProject(id: ProjectV2.ID, worktree: string) { + return Database.Service.use(({ db }) => db .insert(ProjectTable) .values({ @@ -309,38 +311,48 @@ function insertProject(id: ProjectID, worktree: string) { time_updated: Date.now(), sandboxes: [], }) - .run(), + .run() + .pipe(Effect.orDie), ) } -function attachSessionToWorkspace(sessionID: SessionID, workspaceID: WorkspaceID) { - Database.use((db) => - db.update(SessionTable).set({ workspace_id: workspaceID }).where(eq(SessionTable.id, sessionID)).run(), +function attachSessionToWorkspace(sessionID: SessionID, workspaceID: WorkspaceV2.ID) { + return Database.Service.use(({ db }) => + db + .update(SessionTable) + .set({ workspace_id: workspaceID }) + .where(eq(SessionTable.id, sessionID)) + .run() + .pipe(Effect.orDie), ) } function sessionSequence(sessionID: SessionID) { - return Database.use((db) => + return Database.Service.use(({ db }) => db .select({ seq: EventSequenceTable.seq }) .from(EventSequenceTable) .where(eq(EventSequenceTable.aggregate_id, sessionID)) - .get(), - )?.seq + .get() + .pipe( + Effect.orDie, + Effect.map((row) => row?.seq), + ), + ) } function sessionSequenceOwner(sessionID: SessionID) { - return Database.use((db) => + return Database.Service.use(({ db }) => db .select({ ownerID: EventSequenceTable.owner_id }) .from(EventSequenceTable) .where(eq(EventSequenceTable.aggregate_id, sessionID)) - .get(), - )?.ownerID -} - -function sessionUpdatedType() { - return SyncEvent.versionedType(SessionNs.Event.Updated.type, SessionNs.Event.Updated.version) + .get() + .pipe( + Effect.orDie, + Effect.map((row) => row?.ownerID), + ), + ) } describe("workspace schemas and exports", () => { @@ -352,10 +364,10 @@ describe("workspace schemas and exports", () => { test("validates create input with workspace id, project id, branch, type, and extra", () => { const input = { - id: WorkspaceID.ascending("wrk_schema_create"), + id: WorkspaceV2.ID.ascending("wrk_schema_create"), type: "worktree", branch: "feature/schema", - projectID: ProjectID.make("project-schema"), + projectID: ProjectV2.ID.make("project-schema"), extra: { nested: true }, } @@ -372,7 +384,7 @@ describe("workspace CRUD", () => { () => Effect.gen(function* () { const workspace = yield* Workspace.Service - expect(yield* workspace.get(WorkspaceID.ascending("wrk_missing_get"))).toBeUndefined() + expect(yield* workspace.get(WorkspaceV2.ID.ascending("wrk_missing_get"))).toBeUndefined() }), { git: true }, ) @@ -383,24 +395,24 @@ describe("workspace CRUD", () => { Effect.gen(function* () { const instance = yield* requireInstance const workspace = yield* Workspace.Service - const otherProjectID = ProjectID.make("project-other") - insertProject(otherProjectID, "/tmp/other") + const otherProjectID = ProjectV2.ID.make("project-other") + yield* insertProject(otherProjectID, "/tmp/other") const a = workspaceInfo(instance.project.id, "manual", { - id: WorkspaceID.ascending("wrk_a_list"), + id: WorkspaceV2.ID.ascending("wrk_a_list"), branch: "a", directory: "/a", extra: { a: true }, }) const b = workspaceInfo(instance.project.id, "manual", { - id: WorkspaceID.ascending("wrk_b_list"), + id: WorkspaceV2.ID.ascending("wrk_b_list"), branch: "b", directory: "/b", extra: ["b"], }) - const other = workspaceInfo(otherProjectID, "manual", { id: WorkspaceID.ascending("wrk_c_list") }) - insertWorkspace(b) - insertWorkspace(other) - insertWorkspace(a) + const other = workspaceInfo(otherProjectID, "manual", { id: WorkspaceV2.ID.ascending("wrk_c_list") }) + yield* insertWorkspace(b) + yield* insertWorkspace(other) + yield* insertWorkspace(a) expect(yield* workspace.list(instance.project)).toEqual([a, b]) }), @@ -418,7 +430,7 @@ describe("workspace CRUD", () => { process.env.OTEL_EXPORTER_OTLP_ENDPOINT = "https://otel.test" process.env.OTEL_RESOURCE_ATTRIBUTES = "service.name=opencode-test" - const workspaceID = WorkspaceID.ascending("wrk_create_local") + const workspaceID = WorkspaceV2.ID.ascending("wrk_create_local") const type = unique("create-local") const targetDir = path.join(instance.directory, "created-local") const recorded = recordedAdapter({ @@ -578,11 +590,11 @@ describe("workspace CRUD", () => { const workspace = yield* Workspace.Service const type = unique("list-sync") const existing = workspaceInfo(instance.project.id, type, { - id: WorkspaceID.ascending("wrk_list_sync_existing"), + id: WorkspaceV2.ID.ascending("wrk_list_sync_existing"), name: "existing", directory: path.join(instance.directory, "existing"), }) - insertWorkspace(existing) + yield* insertWorkspace(existing) const discovered = { type, @@ -748,7 +760,7 @@ describe("workspace CRUD", () => { () => Effect.gen(function* () { const workspace = yield* Workspace.Service - expect(yield* workspace.remove(WorkspaceID.ascending("wrk_missing_remove"))).toBeUndefined() + expect(yield* workspace.remove(WorkspaceV2.ID.ascending("wrk_missing_remove"))).toBeUndefined() }), { git: true }, ) @@ -767,8 +779,8 @@ describe("workspace CRUD", () => { const info = yield* workspace.create({ type, branch: null, projectID: instance.project.id, extra: null }) const one = yield* sessionSvc.create({}) const two = yield* sessionSvc.create({}) - attachSessionToWorkspace(one.id, info.id) - attachSessionToWorkspace(two.id, info.id) + yield* attachSessionToWorkspace(one.id, info.id) + yield* attachSessionToWorkspace(two.id, info.id) const removed = yield* workspace.remove(info.id) @@ -776,10 +788,14 @@ describe("workspace CRUD", () => { expect(yield* workspace.get(info.id)).toBeUndefined() expect(recorded.calls.remove).toEqual([info]) expect((yield* workspace.status()).find((item) => item.workspaceID === info.id)?.status).toBeUndefined() + const { db } = yield* Database.Service expect( - Database.use((db) => - db.select({ id: SessionTable.id }).from(SessionTable).where(eq(SessionTable.workspace_id, info.id)).all(), - ), + yield* db + .select({ id: SessionTable.id }) + .from(SessionTable) + .where(eq(SessionTable.workspace_id, info.id)) + .all() + .pipe(Effect.orDie), ).toEqual([]) }) }, @@ -793,7 +809,7 @@ describe("workspace CRUD", () => { const instance = yield* requireInstance const workspace = yield* Workspace.Service const type = unique("remove-throws") - const info = workspaceInfo(instance.project.id, type, { id: WorkspaceID.ascending("wrk_remove_throws") }) + const info = workspaceInfo(instance.project.id, type, { id: WorkspaceV2.ID.ascending("wrk_remove_throws") }) registerAdapter( instance.project.id, type, @@ -806,7 +822,7 @@ describe("workspace CRUD", () => { }, }).adapter, ) - insertWorkspace(info) + yield* insertWorkspace(info) expect(yield* workspace.remove(info.id)).toEqual(info) expect(yield* workspace.get(info.id)).toBeUndefined() @@ -826,25 +842,25 @@ describe("workspace CRUD", () => { const targetType = unique("warp-target-local") const previous = workspaceInfo(instance.project.id, previousType) const target = workspaceInfo(instance.project.id, targetType) - insertWorkspace(previous) - insertWorkspace(target) + yield* insertWorkspace(previous) + yield* insertWorkspace(target) registerAdapter(instance.project.id, previousType, localAdapter(path.join(dir, "warp-prev-local")).adapter) registerAdapter(instance.project.id, targetType, localAdapter(path.join(dir, "warp-target-local")).adapter) const session = yield* sessionSvc.create({}) - attachSessionToWorkspace(session.id, previous.id) + yield* attachSessionToWorkspace(session.id, previous.id) yield* workspace.sessionWarp({ workspaceID: target.id, sessionID: session.id }) + const { db } = yield* Database.Service expect( - Database.use((db) => - db - .select({ workspaceID: SessionTable.workspace_id }) - .from(SessionTable) - .where(eq(SessionTable.id, session.id)) - .get(), - )?.workspaceID, + (yield* db + .select({ workspaceID: SessionTable.workspace_id }) + .from(SessionTable) + .where(eq(SessionTable.id, session.id)) + .get() + .pipe(Effect.orDie))?.workspaceID, ).toBe(target.id) - expect(sessionSequenceOwner(session.id)).toBe(target.id) + expect(yield* sessionSequenceOwner(session.id)).toBe(target.id) }) }, { git: true }, @@ -869,12 +885,12 @@ describe("workspace CRUD", () => { const previous = workspaceInfo(instance.project.id, previousType) const target = workspaceInfo(instance.project.id, targetType) - insertWorkspace(previous) - insertWorkspace(target) + yield* insertWorkspace(previous) + yield* insertWorkspace(target) registerAdapter(instance.project.id, previousType, localAdapter(previousDir, { createDir: false }).adapter) registerAdapter(instance.project.id, targetType, localAdapter(targetDir, { createDir: false }).adapter) const session = yield* sessionSvc.create({}) - attachSessionToWorkspace(session.id, previous.id) + yield* attachSessionToWorkspace(session.id, previous.id) yield* workspace.sessionWarp({ workspaceID: target.id, sessionID: session.id, copyChanges: true }) @@ -895,23 +911,23 @@ describe("workspace CRUD", () => { const sessionSvc = yield* SessionNs.Service const previousType = unique("warp-detach-local") const previous = workspaceInfo(instance.project.id, previousType) - insertWorkspace(previous) + yield* insertWorkspace(previous) registerAdapter(instance.project.id, previousType, localAdapter(path.join(dir, "warp-detach-local")).adapter) const session = yield* sessionSvc.create({}) - attachSessionToWorkspace(session.id, previous.id) + yield* attachSessionToWorkspace(session.id, previous.id) yield* workspace.sessionWarp({ workspaceID: null, sessionID: session.id }) + const { db } = yield* Database.Service expect( - Database.use((db) => - db - .select({ workspaceID: SessionTable.workspace_id }) - .from(SessionTable) - .where(eq(SessionTable.id, session.id)) - .get(), - )?.workspaceID, + (yield* db + .select({ workspaceID: SessionTable.workspace_id }) + .from(SessionTable) + .where(eq(SessionTable.id, session.id)) + .get() + .pipe(Effect.orDie))?.workspaceID, ).toBeNull() - expect(sessionSequenceOwner(session.id)).toBe(instance.project.id) + expect(yield* sessionSequenceOwner(session.id)).toBe(instance.project.id) }) }, { git: true }, @@ -928,9 +944,9 @@ describe("workspace CRUD", () => { const sessionSvc = yield* SessionNs.Service const previousType = unique("warp-detach-workspace-instance") const previous = workspaceInfo(projectID, previousType) - insertWorkspace(previous) + yield* insertWorkspace(previous) const session = yield* sessionSvc.create({}) - attachSessionToWorkspace(session.id, previous.id) + yield* attachSessionToWorkspace(session.id, previous.id) const workspaceProjectID = yield* provideTmpdirInstance( (workspaceDir) => @@ -944,17 +960,17 @@ describe("workspace CRUD", () => { { git: true }, ) + const { db } = yield* Database.Service expect( - Database.use((db) => - db - .select({ workspaceID: SessionTable.workspace_id }) - .from(SessionTable) - .where(eq(SessionTable.id, session.id)) - .get(), - )?.workspaceID, + (yield* db + .select({ workspaceID: SessionTable.workspace_id }) + .from(SessionTable) + .where(eq(SessionTable.id, session.id)) + .get() + .pipe(Effect.orDie))?.workspaceID, ).toBeNull() - expect(sessionSequenceOwner(session.id)).toBe(projectID) - expect(sessionSequenceOwner(session.id)).not.toBe(workspaceProjectID) + expect(yield* sessionSequenceOwner(session.id)).toBe(projectID) + expect(yield* sessionSequenceOwner(session.id)).not.toBe(workspaceProjectID) }), { git: true }, ) @@ -962,6 +978,7 @@ describe("workspace CRUD", () => { it.live("sessionWarp syncs previous remote history, replays it, steals, and claims the sequence", () => { const calls: FetchCall[] = [] let historySessionID: SessionID | undefined + let historySession: SessionNs.Info | undefined let historyNextSeq = 0 return Effect.gen(function* () { yield* HttpServer.serveEffect()( @@ -982,8 +999,8 @@ describe("workspace CRUD", () => { id: `evt_${unique("warp-source-history")}`, aggregate_id: historySessionID!, seq: historyNextSeq, - type: sessionUpdatedType(), - data: { sessionID: historySessionID!, info: { title: "from source history" } }, + type: "session.updated.1", + data: { sessionID: historySessionID!, info: historySession! }, }, ]) } @@ -1007,14 +1024,15 @@ describe("workspace CRUD", () => { const targetType = unique("warp-remote-target") const previous = workspaceInfo(instance.project.id, previousType) const target = workspaceInfo(instance.project.id, targetType, { directory: "remote-target-dir" }) - insertWorkspace(previous) - insertWorkspace(target) + yield* insertWorkspace(previous) + yield* insertWorkspace(target) registerAdapter(instance.project.id, previousType, remoteAdapter(`${url}/warp-source`).adapter) registerAdapter(instance.project.id, targetType, remoteAdapter(`${url}/warp-target`).adapter) const session = yield* sessionSvc.create({}) - attachSessionToWorkspace(session.id, previous.id) + yield* attachSessionToWorkspace(session.id, previous.id) historySessionID = session.id - historyNextSeq = (sessionSequence(session.id) ?? -1) + 1 + historySession = { ...session, workspaceID: previous.id, title: "from source history" } + historyNextSeq = ((yield* sessionSequence(session.id)) ?? -1) + 1 yield* workspace.sessionWarp({ workspaceID: target.id, sessionID: session.id, copyChanges: true }) @@ -1033,18 +1051,18 @@ describe("workspace CRUD", () => { { aggregateID: session.id, seq: 0, - type: SyncEvent.versionedType(SessionNs.Event.Created.type, SessionNs.Event.Created.version), + type: "session.created.1", }, { aggregateID: session.id, seq: historyNextSeq, - type: sessionUpdatedType(), + type: "session.updated.1", }, ], }) expect(calls[4].json).toEqual({ sessionID: session.id }) expect((yield* sessionSvc.get(session.id)).title).toBe("from source history") - expect(sessionSequenceOwner(session.id)).toBe(target.id) + expect(yield* sessionSequenceOwner(session.id)).toBe(target.id) }), { git: true }, ) @@ -1064,8 +1082,8 @@ describe("workspace sync state", () => { const type = unique("flag-disabled") const info = workspaceInfo(instance.project.id, type) const session = yield* sessionSvc.create({}) - attachSessionToWorkspace(session.id, info.id) - insertWorkspace(info) + yield* attachSessionToWorkspace(session.id, info.id) + yield* insertWorkspace(info) registerAdapter(instance.project.id, type, localAdapter(path.join(dir, "flag-disabled")).adapter) yield* Effect.promise(() => startWorkspaceSyncingWithFlag(instance.project.id, false)) @@ -1090,12 +1108,10 @@ describe("workspace sync state", () => { const second = workspaceInfo(projectID, secondType) yield* Effect.promise(() => fs.mkdir(path.join(dir, "first"), { recursive: true })) yield* Effect.promise(() => fs.mkdir(path.join(dir, "second"), { recursive: true })) - yield* Effect.sync(() => { - insertWorkspace(first) - insertWorkspace(second) - registerAdapter(projectID, firstType, localAdapter(path.join(dir, "first")).adapter) - registerAdapter(projectID, secondType, localAdapter(path.join(dir, "second")).adapter) - }) + yield* insertWorkspace(first) + yield* insertWorkspace(second) + registerAdapter(projectID, firstType, localAdapter(path.join(dir, "first")).adapter) + registerAdapter(projectID, secondType, localAdapter(path.join(dir, "second")).adapter) yield* Effect.addFinalizer(() => Effect.all([workspace.remove(first.id), workspace.remove(second.id)], { discard: true }).pipe(Effect.ignore), ) @@ -1123,13 +1139,13 @@ describe("workspace sync state", () => { const sessionSvc = yield* SessionNs.Service const type = unique("missing-local") const info = workspaceInfo(instance.project.id, type) - insertWorkspace(info) + yield* insertWorkspace(info) registerAdapter( instance.project.id, type, localAdapter(path.join(dir, "missing-target"), { createDir: false }).adapter, ) - attachSessionToWorkspace((yield* sessionSvc.create({})).id, info.id) + yield* attachSessionToWorkspace((yield* sessionSvc.create({})).id, info.id) yield* workspace.startWorkspaceSyncing(instance.project.id) @@ -1159,9 +1175,9 @@ describe("workspace sync state", () => { const info = workspaceInfo(instance.project.id, type) const target = path.join(dir, "dedupe-local") yield* Effect.promise(() => fs.mkdir(target, { recursive: true })) - insertWorkspace(info) + yield* insertWorkspace(info) registerAdapter(instance.project.id, type, localAdapter(target).adapter) - attachSessionToWorkspace((yield* sessionSvc.create({})).id, info.id) + yield* attachSessionToWorkspace((yield* sessionSvc.create({})).id, info.id) yield* workspace.startWorkspaceSyncing(instance.project.id) yield* workspace.startWorkspaceSyncing(instance.project.id) @@ -1213,9 +1229,9 @@ describe("workspace sync state", () => { try { const type = unique("remote-start") const info = workspaceInfo(instance.project.id, type) - insertWorkspace(info) + yield* insertWorkspace(info) registerAdapter(instance.project.id, type, remoteAdapter(`${url}/sync`).adapter) - attachSessionToWorkspace((yield* sessionSvc.create({})).id, info.id) + yield* attachSessionToWorkspace((yield* sessionSvc.create({})).id, info.id) yield* workspace.startWorkspaceSyncing(instance.project.id) yield* eventuallyEffect( @@ -1267,9 +1283,9 @@ describe("workspace sync state", () => { const instance = yield* requireInstance const type = unique("remote-connect-fail") const info = workspaceInfo(instance.project.id, type) - insertWorkspace(info) + yield* insertWorkspace(info) registerAdapter(instance.project.id, type, remoteAdapter(`${url}/failed`).adapter) - attachSessionToWorkspace((yield* sessionSvc.create({})).id, info.id) + yield* attachSessionToWorkspace((yield* sessionSvc.create({})).id, info.id) yield* workspace.startWorkspaceSyncing(instance.project.id) @@ -1308,9 +1324,9 @@ describe("workspace sync state", () => { const instance = yield* requireInstance const type = unique("remote-history-fail") const info = workspaceInfo(instance.project.id, type) - insertWorkspace(info) + yield* insertWorkspace(info) registerAdapter(instance.project.id, type, remoteAdapter(`${url}/history-failed`).adapter) - attachSessionToWorkspace((yield* sessionSvc.create({})).id, info.id) + yield* attachSessionToWorkspace((yield* sessionSvc.create({})).id, info.id) yield* workspace.startWorkspaceSyncing(instance.project.id) @@ -1330,6 +1346,7 @@ describe("workspace sync state", () => { it.live("sync history sends the local sequence fence and replays returned events in workspace context", () => { const historyBodies: unknown[] = [] let historySessionID: SessionID | undefined + let historySession: SessionNs.Info | undefined let historyNextSeq = 0 return Effect.gen(function* () { yield* HttpServer.serveEffect()( @@ -1346,8 +1363,8 @@ describe("workspace sync state", () => { id: `evt_${unique("history")}`, aggregate_id: historySessionID!, seq: historyNextSeq, - type: sessionUpdatedType(), - data: { sessionID: historySessionID!, info: { title: "from history" } }, + type: "session.updated.1", + data: { sessionID: historySessionID!, info: historySession! }, }, ]), ) @@ -1366,12 +1383,13 @@ describe("workspace sync state", () => { try { const type = unique("history-replay") const info = workspaceInfo(instance.project.id, type) - insertWorkspace(info) + yield* insertWorkspace(info) registerAdapter(instance.project.id, type, remoteAdapter(`${url}/history`).adapter) const session = yield* sessionSvc.create({ title: "before history" }) - attachSessionToWorkspace(session.id, info.id) + yield* attachSessionToWorkspace(session.id, info.id) historySessionID = session.id - historyNextSeq = (sessionSequence(session.id) ?? -1) + 1 + historySession = { ...session, workspaceID: info.id, title: "from history" } + historyNextSeq = ((yield* sessionSequence(session.id)) ?? -1) + 1 yield* workspace.startWorkspaceSyncing(instance.project.id) @@ -1385,8 +1403,9 @@ describe("workspace sync state", () => { captured.events.some( (event) => event.workspace === info.id && - event.payload.type === "sync" && - event.payload.syncEvent.seq === historyNextSeq, + event.payload.type === "session.updated" && + event.payload.properties.sessionID === session.id && + event.payload.properties.info.title === "from history", ), ).toBe(true) yield* workspace.remove(info.id) @@ -1434,9 +1453,9 @@ describe("workspace sync state", () => { try { const type = unique("sse-forward") const info = workspaceInfo(instance.project.id, type) - insertWorkspace(info) + yield* insertWorkspace(info) registerAdapter(instance.project.id, type, remoteAdapter(`${url}/sse-forward`).adapter) - attachSessionToWorkspace((yield* sessionSvc.create({})).id, info.id) + yield* attachSessionToWorkspace((yield* sessionSvc.create({})).id, info.id) yield* workspace.startWorkspaceSyncing(instance.project.id) @@ -1473,6 +1492,7 @@ describe("workspace sync state", () => { it.live("SSE sync events are replayed and forwarded", () => { let sseSessionID: SessionID | undefined + let sseSession: SessionNs.Info | undefined let sseNextSeq = 0 return Effect.gen(function* () { yield* HttpServer.serveEffect()( @@ -1492,8 +1512,8 @@ describe("workspace sync state", () => { id: `evt_${unique("sse")}`, aggregateID: sseSessionID!, seq: sseNextSeq, - type: sessionUpdatedType(), - data: { sessionID: sseSessionID!, info: { title: "from sse" } }, + type: "session.updated.1", + data: { sessionID: sseSessionID!, info: sseSession! }, }, }, }, @@ -1516,12 +1536,13 @@ describe("workspace sync state", () => { try { const type = unique("sse-sync") const info = workspaceInfo(instance.project.id, type) - insertWorkspace(info) + yield* insertWorkspace(info) registerAdapter(instance.project.id, type, remoteAdapter(`${url}/sse-sync`).adapter) const session = yield* sessionSvc.create({ title: "before sse" }) - attachSessionToWorkspace(session.id, info.id) + yield* attachSessionToWorkspace(session.id, info.id) sseSessionID = session.id - sseNextSeq = (sessionSequence(session.id) ?? -1) + 1 + sseSession = { ...session, workspaceID: info.id, title: "from sse" } + sseNextSeq = ((yield* sessionSequence(session.id)) ?? -1) + 1 yield* workspace.startWorkspaceSyncing(instance.project.id) @@ -1555,7 +1576,7 @@ describe("workspace waitForSync", () => { () => Effect.gen(function* () { const workspace = yield* Workspace.Service - expect(yield* workspace.waitForSync(WorkspaceID.ascending("wrk_wait_empty"), {})).toBeUndefined() + expect(yield* workspace.waitForSync(WorkspaceV2.ID.ascending("wrk_wait_empty"), {})).toBeUndefined() }), { git: true }, ) @@ -1566,11 +1587,14 @@ describe("workspace waitForSync", () => { Effect.gen(function* () { const workspace = yield* Workspace.Service const sessionID = SessionID.descending("ses_wait_done") - Database.use((db) => db.insert(EventSequenceTable).values({ aggregate_id: sessionID, seq: 4 }).run()) + const { db } = yield* Database.Service + yield* db.insert(EventSequenceTable).values({ aggregate_id: sessionID, seq: 4 }).run().pipe(Effect.orDie) - expect(yield* workspace.waitForSync(WorkspaceID.ascending("wrk_wait_done"), { [sessionID]: 4 })).toBeUndefined() expect( - yield* workspace.waitForSync(WorkspaceID.ascending("wrk_wait_done_2"), { [sessionID]: 3 }), + yield* workspace.waitForSync(WorkspaceV2.ID.ascending("wrk_wait_done"), { [sessionID]: 4 }), + ).toBeUndefined() + expect( + yield* workspace.waitForSync(WorkspaceV2.ID.ascending("wrk_wait_done_2"), { [sessionID]: 3 }), ).toBeUndefined() }), { git: true }, @@ -1581,22 +1605,22 @@ describe("workspace waitForSync", () => { () => Effect.gen(function* () { const workspace = yield* Workspace.Service - const workspaceID = WorkspaceID.ascending("wrk_wait_event") + const workspaceID = WorkspaceV2.ID.ascending("wrk_wait_event") const sessionID = SessionID.descending("ses_wait_event") - Database.use((db) => db.insert(EventSequenceTable).values({ aggregate_id: sessionID, seq: 1 }).run()) + const { db } = yield* Database.Service + yield* db.insert(EventSequenceTable).values({ aggregate_id: sessionID, seq: 1 }).run().pipe(Effect.orDie) yield* Effect.all( [ workspace.waitForSync(workspaceID, { [sessionID]: 2 }), Effect.gen(function* () { yield* Effect.sleep("10 millis") - Database.use((db) => - db - .update(EventSequenceTable) - .set({ seq: 2 }) - .where(eq(EventSequenceTable.aggregate_id, sessionID)) - .run(), - ) + yield* db + .update(EventSequenceTable) + .set({ seq: 2 }) + .where(eq(EventSequenceTable.aggregate_id, sessionID)) + .run() + .pipe(Effect.orDie) GlobalBus.emit("event", { workspace: workspaceID, payload: { type: "anything" } }) }), ], @@ -1611,24 +1635,24 @@ describe("workspace waitForSync", () => { () => Effect.gen(function* () { const workspace = yield* Workspace.Service - const workspaceID = WorkspaceID.ascending("wrk_wait_sync_any") + const workspaceID = WorkspaceV2.ID.ascending("wrk_wait_sync_any") const sessionID = SessionID.descending("ses_wait_sync_any") - Database.use((db) => db.insert(EventSequenceTable).values({ aggregate_id: sessionID, seq: 0 }).run()) + const { db } = yield* Database.Service + yield* db.insert(EventSequenceTable).values({ aggregate_id: sessionID, seq: 0 }).run().pipe(Effect.orDie) yield* Effect.all( [ workspace.waitForSync(workspaceID, { [sessionID]: 1 }), Effect.gen(function* () { yield* Effect.sleep("10 millis") - Database.use((db) => - db - .update(EventSequenceTable) - .set({ seq: 1 }) - .where(eq(EventSequenceTable.aggregate_id, sessionID)) - .run(), - ) + yield* db + .update(EventSequenceTable) + .set({ seq: 1 }) + .where(eq(EventSequenceTable.aggregate_id, sessionID)) + .run() + .pipe(Effect.orDie) GlobalBus.emit("event", { - workspace: WorkspaceID.ascending("wrk_other_workspace"), + workspace: WorkspaceV2.ID.ascending("wrk_other_workspace"), payload: { type: "sync" }, }) }), @@ -1648,7 +1672,7 @@ describe("workspace waitForSync", () => { const reason = new Error("caller aborted") const fiber = yield* Effect.forkChild( workspace.waitForSync( - WorkspaceID.ascending("wrk_wait_abort"), + WorkspaceV2.ID.ascending("wrk_wait_abort"), { [SessionID.descending("ses_wait_abort")]: 1 }, abort.signal, ), @@ -1668,7 +1692,7 @@ describe("workspace waitForSync", () => { const sessionID = SessionID.descending("ses_wait_timeout") expectExitContains( yield* Effect.exit( - workspace.waitForSync(WorkspaceID.ascending("wrk_wait_timeout"), { [sessionID]: 1 }, undefined, 25), + workspace.waitForSync(WorkspaceV2.ID.ascending("wrk_wait_timeout"), { [sessionID]: 1 }, undefined, 25), ), `Timed out waiting for sync fence: {"${sessionID}":1}`, ) diff --git a/packages/opencode/test/effect/run-service.test.ts b/packages/opencode/test/effect/run-service.test.ts index 16538bb8ae..08c8fef436 100644 --- a/packages/opencode/test/effect/run-service.test.ts +++ b/packages/opencode/test/effect/run-service.test.ts @@ -2,7 +2,7 @@ import { expect } from "bun:test" import { Effect, Layer, Context } from "effect" import { InstanceRef } from "../../src/effect/instance-ref" import { makeRuntime } from "../../src/effect/run-service" -import { ProjectID } from "../../src/project/schema" +import { ProjectV2 } from "@opencode-ai/core/project" import { it } from "../lib/effect" class Shared extends Context.Service()("@test/Shared") {} @@ -79,7 +79,7 @@ it.live("makeRuntime inherits InstanceRef from the current fiber", () => directory: testDirectory, worktree: testDirectory, project: { - id: ProjectID.global, + id: ProjectV2.ID.global, worktree: testDirectory, time: { created: 0, updated: 0 }, sandboxes: [], diff --git a/packages/opencode/test/effect/runtime-flags.test.ts b/packages/opencode/test/effect/runtime-flags.test.ts index 36579f48ea..8227cb50a9 100644 --- a/packages/opencode/test/effect/runtime-flags.test.ts +++ b/packages/opencode/test/effect/runtime-flags.test.ts @@ -24,12 +24,10 @@ describe("RuntimeFlags", () => { fromConfig({ OPENCODE_PURE: "true", OPENCODE_DISABLE_DEFAULT_PLUGINS: "true", - OPENCODE_DISABLE_CHANNEL_DB: "true", OPENCODE_AUTO_SHARE: "true", OPENCODE_DISABLE_EMBEDDED_WEB_UI: "true", OPENCODE_DISABLE_EXTERNAL_SKILLS: "true", OPENCODE_DISABLE_LSP_DOWNLOAD: "true", - OPENCODE_SKIP_MIGRATIONS: "true", OPENCODE_EXPERIMENTAL: "true", OPENCODE_ENABLE_EXA: "true", OPENCODE_ENABLE_PARALLEL: "true", @@ -43,11 +41,9 @@ describe("RuntimeFlags", () => { expect(flags.pure).toBe(true) expect(flags.autoShare).toBe(true) expect(flags.disableDefaultPlugins).toBe(true) - expect(flags.disableChannelDb).toBe(true) expect(flags.disableEmbeddedWebUi).toBe(true) expect(flags.disableExternalSkills).toBe(true) expect(flags.disableLspDownload).toBe(true) - expect(flags.skipMigrations).toBe(true) expect(flags.disableClaudeCodePrompt).toBe(false) expect(flags.enableExa).toBe(true) expect(flags.enableParallel).toBe(true) @@ -111,11 +107,9 @@ describe("RuntimeFlags", () => { expect(flags.pure).toBe(false) expect(flags.autoShare).toBe(false) expect(flags.disableDefaultPlugins).toBe(true) - expect(flags.disableChannelDb).toBe(false) expect(flags.disableEmbeddedWebUi).toBe(false) expect(flags.disableExternalSkills).toBe(false) expect(flags.disableLspDownload).toBe(false) - expect(flags.skipMigrations).toBe(false) expect(flags.disableClaudeCodePrompt).toBe(false) expect(flags.disableClaudeCodeSkills).toBe(false) expect(flags.enableExa).toBe(false) @@ -168,22 +162,6 @@ describe("RuntimeFlags", () => { }), ) - it.effect("skipMigrations defaults to false", () => - Effect.gen(function* () { - const flags = yield* readFlags.pipe(Effect.provide(fromConfig({}))) - - expect(flags.skipMigrations).toBe(false) - }), - ) - - it.effect("skipMigrations reads OPENCODE_SKIP_MIGRATIONS", () => - Effect.gen(function* () { - const flags = yield* readFlags.pipe(Effect.provide(fromConfig({ OPENCODE_SKIP_MIGRATIONS: "true" }))) - - expect(flags.skipMigrations).toBe(true) - }), - ) - it.effect("disableClaudeCodePrompt defaults to false", () => Effect.gen(function* () { const flags = yield* readFlags.pipe(Effect.provide(fromConfig({}))) @@ -344,7 +322,6 @@ describe("RuntimeFlags", () => { OPENCODE_DISABLE_DEFAULT_PLUGINS: "true", OPENCODE_DISABLE_EXTERNAL_SKILLS: "true", OPENCODE_DISABLE_LSP_DOWNLOAD: "true", - OPENCODE_SKIP_MIGRATIONS: "true", OPENCODE_EXPERIMENTAL: "true", OPENCODE_ENABLE_EXA: "true", OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS: "1234", @@ -356,11 +333,9 @@ describe("RuntimeFlags", () => { expect(flags.pure).toBe(false) expect(flags.disableDefaultPlugins).toBe(false) - expect(flags.disableChannelDb).toBe(false) expect(flags.disableEmbeddedWebUi).toBe(false) expect(flags.disableExternalSkills).toBe(false) expect(flags.disableLspDownload).toBe(false) - expect(flags.skipMigrations).toBe(false) expect(flags.disableClaudeCodePrompt).toBe(false) expect(flags.disableClaudeCodeSkills).toBe(false) expect(flags.enableExa).toBe(false) diff --git a/packages/opencode/test/fake/provider.ts b/packages/opencode/test/fake/provider.ts index 5f8f7a3302..e90bde29e2 100644 --- a/packages/opencode/test/fake/provider.ts +++ b/packages/opencode/test/fake/provider.ts @@ -1,11 +1,11 @@ import { Effect, Layer } from "effect" import { Provider } from "@/provider/provider" -import { ModelID, ProviderID } from "../../src/provider/schema" +import { ProviderV2 } from "@opencode-ai/core/provider" export namespace ProviderTest { export function model(override: Partial = {}): Provider.Model { - const id = override.id ?? ModelID.make("gpt-5.2") - const providerID = override.providerID ?? ProviderID.make("openai") + const id = override.id ?? ProviderV2.ModelID.make("gpt-5.2") + const providerID = override.providerID ?? ProviderV2.ID.make("openai") return { id, providerID, diff --git a/packages/opencode/test/file/watcher.test.ts b/packages/opencode/test/file/watcher.test.ts index c205da4862..3137f6c7d6 100644 --- a/packages/opencode/test/file/watcher.test.ts +++ b/packages/opencode/test/file/watcher.test.ts @@ -9,6 +9,7 @@ import { GlobalBus, type GlobalEvent } from "../../src/bus/global" import { Config } from "@/config/config" import { FileWatcher } from "../../src/file/watcher" import { Git } from "../../src/git" +import { EventV2Bridge } from "../../src/event-v2-bridge" // 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 @@ -27,6 +28,7 @@ const watcherConfigLayer = ConfigProvider.layer( const watcherLayer = FileWatcher.layer.pipe( Layer.provide(Config.defaultLayer), Layer.provide(Git.defaultLayer), + Layer.provide(EventV2Bridge.defaultLayer), Layer.provide(watcherConfigLayer), ) diff --git a/packages/opencode/test/fixture/db.ts b/packages/opencode/test/fixture/db.ts index db4a5df20c..88f1097f2d 100644 --- a/packages/opencode/test/fixture/db.ts +++ b/packages/opencode/test/fixture/db.ts @@ -1,11 +1,10 @@ import { rm } from "fs/promises" -import { Database } from "@/storage/db" +import { Database } from "@opencode-ai/core/database/database" import { disposeAllInstances } from "./fixture" export async function resetDatabase() { await disposeAllInstances().catch(() => undefined) - Database.close() - const dbPath = Database.getPath() + const dbPath = Database.path() await rm(dbPath, { force: true }).catch(() => undefined) await rm(`${dbPath}-wal`, { force: true }).catch(() => undefined) await rm(`${dbPath}-shm`, { force: true }).catch(() => undefined) diff --git a/packages/opencode/test/fixture/fixture.ts b/packages/opencode/test/fixture/fixture.ts index 0b26359ad7..41a0953122 100644 --- a/packages/opencode/test/fixture/fixture.ts +++ b/packages/opencode/test/fixture/fixture.ts @@ -1,9 +1,8 @@ import { $ } from "bun" -import * as Observability from "@opencode-ai/core/effect/observability" import * as fs from "fs/promises" import os from "os" import path from "path" -import { Effect, Context, Layer, ManagedRuntime } from "effect" +import { Effect, Context, Layer } 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" @@ -18,35 +17,31 @@ import { TestLLMServer } from "../lib/llm-server" const noopBootstrap = Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void })) export const testInstanceStoreLayer = InstanceStore.defaultLayer.pipe(Layer.provide(noopBootstrap)) -const testInstanceRuntime = ManagedRuntime.make(testInstanceStoreLayer.pipe(Layer.provideMerge(Observability.layer))) - -const runTestInstanceStore = (fn: (store: InstanceStore.Interface) => Effect.Effect) => - testInstanceRuntime.runPromise(InstanceStore.Service.use(fn)) export async function provideTestInstance(input: { directory: string init?: Effect.Effect fn: (ctx: InstanceContext) => R }) { - const ctx = await runTestInstanceStore((store) => store.load({ directory: input.directory })) + const ctx = await InstanceRuntime.load({ directory: input.directory }) try { - if (input.init) await testInstanceRuntime.runPromise(input.init.pipe(Effect.provideService(InstanceRef, ctx))) + if (input.init) await Effect.runPromise(input.init.pipe(Effect.provideService(InstanceRef, ctx))) return await input.fn(ctx) } finally { - await runTestInstanceStore((store) => store.dispose(ctx)) + await InstanceRuntime.disposeInstance(ctx) } } export async function withTestInstance(input: { directory: string; fn: (ctx: InstanceContext) => R }) { - return input.fn(await runTestInstanceStore((store) => store.load({ directory: input.directory }))) + return input.fn(await InstanceRuntime.load({ directory: input.directory })) } export async function reloadTestInstance(input: { directory: string }) { - return runTestInstanceStore((store) => store.reload(input)) + return InstanceRuntime.reloadInstance(input) } export async function disposeAllInstances() { - await Promise.all([InstanceRuntime.disposeAllInstances(), runTestInstanceStore((store) => store.disposeAll())]) + await InstanceRuntime.disposeAllInstances() } // Strip null bytes from paths (defensive fix for CI environment issues) @@ -119,9 +114,10 @@ export async function tmpdir(options?: TmpDirOptions) { } /** Effectful scoped tmpdir. Cleaned up when the scope closes. Make sure these stay in sync */ -export function tmpdirScoped(options?: { +export function tmpdirScoped(options?: { git?: boolean config?: Partial | (() => Partial) + init?: (directory: string) => Effect.Effect }) { return Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner @@ -158,19 +154,16 @@ export function tmpdirScoped(options?: { ) } + if (options?.init) yield* options.init(dir) + return dir }) } export const provideInstance = (directory: string) => - (self: Effect.Effect): Effect.Effect => - Effect.contextWith((services: Context.Context) => - Effect.promise(async () => { - const ctx = await runTestInstanceStore((store) => store.load({ directory })) - return Effect.runPromiseWith(services)(self.pipe(Effect.provideService(InstanceRef, ctx))) - }), - ) + (self: Effect.Effect): Effect.Effect => + InstanceStore.Service.use((store) => store.provide({ directory }, self)) export const provideInstanceEffect = (directory: string) => @@ -188,21 +181,8 @@ export function provideTmpdirInstance( ) { return Effect.gen(function* () { const path = yield* tmpdirScoped(options) - let provided = false - - yield* Effect.addFinalizer(() => - provided - ? Effect.promise(() => - runTestInstanceStore((store) => - store.load({ directory: path }).pipe(Effect.flatMap((ctx) => store.dispose(ctx))), - ), - ).pipe(Effect.ignore) - : Effect.void, - ) - - provided = true return yield* self(path).pipe(provideInstance(path)) - }) + }).pipe(Effect.provide(testInstanceStoreLayer)) } export class TestInstance extends Context.Service()("@test/Instance") {} @@ -214,7 +194,11 @@ export const requireInstance = Effect.gen(function* () { }) export const withTmpdirInstance = - (options?: { git?: boolean; config?: Partial | (() => Partial) }) => + (options?: { + git?: boolean + config?: Partial | (() => Partial) + init?: (directory: string) => Effect.Effect + }) => (self: Effect.Effect) => Effect.gen(function* () { const directory = yield* tmpdirScoped(options) diff --git a/packages/opencode/test/fixture/flag.ts b/packages/opencode/test/fixture/flag.ts index 224c5ef1f4..cf00d9e7b2 100644 --- a/packages/opencode/test/fixture/flag.ts +++ b/packages/opencode/test/fixture/flag.ts @@ -1,4 +1,4 @@ -import type { WorkspaceID } from "@/control-plane/schema" +import type { WorkspaceV2 } from "@opencode-ai/core/workspace" import { Flag } from "@opencode-ai/core/flag/flag" import { Effect, Scope } from "effect" @@ -7,7 +7,7 @@ import { Effect, Scope } from "effect" * on entry and restores it via finalizer when the surrounding scope closes — * preserves the original try/finally semantics regardless of test outcome. */ -export function withFixedWorkspaceID(id: WorkspaceID): Effect.Effect { +export function withFixedWorkspaceID(id: WorkspaceV2.ID): Effect.Effect { return Effect.gen(function* () { const previous = Flag.OPENCODE_WORKSPACE_ID Flag.OPENCODE_WORKSPACE_ID = id diff --git a/packages/opencode/test/fixture/workspace.ts b/packages/opencode/test/fixture/workspace.ts index 9c201d3982..b3dceddf8d 100644 --- a/packages/opencode/test/fixture/workspace.ts +++ b/packages/opencode/test/fixture/workspace.ts @@ -1,5 +1,6 @@ import { FetchHttpClient } from "effect/unstable/http" import { Layer } from "effect" +import { Database } from "@opencode-ai/core/database/database" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Auth } from "../../src/auth" import { Workspace } from "../../src/control-plane/workspace" @@ -10,16 +11,17 @@ import { Project } from "../../src/project/project" import { Vcs } from "../../src/project/vcs" import { Session } from "../../src/session/session" import { SessionPrompt } from "../../src/session/prompt" -import { SyncEvent } from "../../src/sync" +import { EventV2Bridge } from "../../src/event-v2-bridge" export const workspaceLayerWithRuntimeFlags = (overrides: Partial) => Workspace.layer.pipe( Layer.provide(Auth.defaultLayer), Layer.provide(Session.defaultLayer), - Layer.provide(SyncEvent.defaultLayer), Layer.provide(SessionPrompt.defaultLayer), Layer.provide(Project.defaultLayer), Layer.provide(Vcs.defaultLayer), + Layer.provide(Database.defaultLayer), + Layer.provide(EventV2Bridge.defaultLayer), Layer.provide(FetchHttpClient.layer), Layer.provide(AppFileSystem.defaultLayer), Layer.provide(RuntimeFlags.layer(overrides)), diff --git a/packages/opencode/test/format/format.test.ts b/packages/opencode/test/format/format.test.ts index 41468c4d05..96b5ad5aed 100644 --- a/packages/opencode/test/format/format.test.ts +++ b/packages/opencode/test/format/format.test.ts @@ -1,7 +1,7 @@ import { NodeFileSystem } from "@effect/platform-node" import { describe, expect } from "bun:test" import { Effect, Layer } from "effect" -import { provideTmpdirInstance } from "../fixture/fixture" +import { provideTmpdirInstance, testInstanceStoreLayer, TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Format } from "../../src/format" @@ -10,141 +10,102 @@ import * as Formatter from "../../src/format/formatter" const it = testEffect(Layer.mergeAll(Format.defaultLayer, CrossSpawnSpawner.defaultLayer, NodeFileSystem.layer)) describe("Format", () => { - it.live("status() returns empty list when no formatters are configured", () => - provideTmpdirInstance(() => + it.instance("status() returns empty list when no formatters are configured", () => + Format.Service.use((fmt) => + Effect.gen(function* () { + expect(yield* fmt.status()).toEqual([]) + }), + ), + ) + + it.instance( + "status() returns built-in formatters when formatter is true", + () => Format.Service.use((fmt) => Effect.gen(function* () { - expect(yield* fmt.status()).toEqual([]) + const statuses = yield* fmt.status() + const gofmt = statuses.find((item) => item.name === "gofmt") + expect(gofmt).toBeDefined() + expect(gofmt!.extensions).toContain(".go") }), ), - ), + { config: { formatter: true } }, ) - it.live("status() returns built-in formatters when formatter is true", () => - provideTmpdirInstance( - () => - Format.Service.use((fmt) => - Effect.gen(function* () { - const statuses = yield* fmt.status() - const gofmt = statuses.find((item) => item.name === "gofmt") - expect(gofmt).toBeDefined() - expect(gofmt!.extensions).toContain(".go") - }), - ), - { - config: { - formatter: true, - }, - }, - ), - ) - - it.live("status() keeps built-in formatters when config object is provided", () => - provideTmpdirInstance( - () => - Format.Service.use((fmt) => - Effect.gen(function* () { - const statuses = yield* fmt.status() - const gofmt = statuses.find((item) => item.name === "gofmt") - const mix = statuses.find((item) => item.name === "mix") - expect(gofmt).toBeDefined() - expect(gofmt!.extensions).toContain(".go") - expect(mix).toBeDefined() - }), - ), - { - config: { - formatter: { - gofmt: {}, - }, - }, - }, - ), - ) - - it.live("status() excludes formatters marked as disabled in config", () => - provideTmpdirInstance( - () => - Format.Service.use((fmt) => - Effect.gen(function* () { - const statuses = yield* fmt.status() - const gofmt = statuses.find((item) => item.name === "gofmt") - const mix = statuses.find((item) => item.name === "mix") - expect(gofmt).toBeUndefined() - expect(mix).toBeDefined() - }), - ), - { - config: { - formatter: { - gofmt: { disabled: true }, - }, - }, - }, - ), - ) - - it.live("status() excludes uv when ruff is disabled", () => - provideTmpdirInstance( - () => - Format.Service.use((fmt) => - Effect.gen(function* () { - const statuses = yield* fmt.status() - expect(statuses.find((item) => item.name === "ruff")).toBeUndefined() - expect(statuses.find((item) => item.name === "uv")).toBeUndefined() - }), - ), - { - config: { - formatter: { - ruff: { disabled: true }, - }, - }, - }, - ), - ) - - it.live("status() excludes ruff when uv is disabled", () => - provideTmpdirInstance( - () => - Format.Service.use((fmt) => - Effect.gen(function* () { - const statuses = yield* fmt.status() - expect(statuses.find((item) => item.name === "ruff")).toBeUndefined() - expect(statuses.find((item) => item.name === "uv")).toBeUndefined() - }), - ), - { - config: { - formatter: { - uv: { disabled: true }, - }, - }, - }, - ), - ) - - it.live("service initializes without error", () => provideTmpdirInstance(() => Format.Service.use(() => Effect.void))) - - it.live("file() returns false when no formatter runs", () => - provideTmpdirInstance( - (dir) => + it.instance( + "status() keeps built-in formatters when config object is provided", + () => + Format.Service.use((fmt) => Effect.gen(function* () { - const file = `${dir}/test.txt` - yield* Effect.promise(() => Bun.write(file, "x")) - - const formatted = yield* Format.use.file(file) - expect(formatted).toBe(false) + const statuses = yield* fmt.status() + const gofmt = statuses.find((item) => item.name === "gofmt") + const mix = statuses.find((item) => item.name === "mix") + expect(gofmt).toBeDefined() + expect(gofmt!.extensions).toContain(".go") + expect(mix).toBeDefined() }), - { - config: { - formatter: false, - }, - }, - ), + ), + { config: { formatter: { gofmt: {} } } }, ) - it.live("status() initializes formatter state per directory", () => + it.instance( + "status() excludes formatters marked as disabled in config", + () => + Format.Service.use((fmt) => + Effect.gen(function* () { + const statuses = yield* fmt.status() + const gofmt = statuses.find((item) => item.name === "gofmt") + const mix = statuses.find((item) => item.name === "mix") + expect(gofmt).toBeUndefined() + expect(mix).toBeDefined() + }), + ), + { config: { formatter: { gofmt: { disabled: true } } } }, + ) + + it.instance( + "status() excludes uv when ruff is disabled", + () => + Format.Service.use((fmt) => + Effect.gen(function* () { + const statuses = yield* fmt.status() + expect(statuses.find((item) => item.name === "ruff")).toBeUndefined() + expect(statuses.find((item) => item.name === "uv")).toBeUndefined() + }), + ), + { config: { formatter: { ruff: { disabled: true } } } }, + ) + + it.instance( + "status() excludes ruff when uv is disabled", + () => + Format.Service.use((fmt) => + Effect.gen(function* () { + const statuses = yield* fmt.status() + expect(statuses.find((item) => item.name === "ruff")).toBeUndefined() + expect(statuses.find((item) => item.name === "uv")).toBeUndefined() + }), + ), + { config: { formatter: { uv: { disabled: true } } } }, + ) + + it.instance("service initializes without error", () => Format.Service.use(() => Effect.void)) + + it.instance( + "file() returns false when no formatter runs", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const file = `${test.directory}/test.txt` + yield* Effect.promise(() => Bun.write(file, "x")) + + const formatted = yield* Format.use.file(file) + expect(formatted).toBe(false) + }), + { config: { formatter: false } }, + ) + + testEffect(Layer.mergeAll(Format.defaultLayer, CrossSpawnSpawner.defaultLayer, NodeFileSystem.layer, testInstanceStoreLayer)).live("status() initializes formatter state per directory", () => Effect.gen(function* () { const a = yield* provideTmpdirInstance(() => Format.use.status(), { config: { formatter: false }, @@ -160,113 +121,106 @@ describe("Format", () => { }), ) - it.live("runs enabled checks for matching formatters in parallel", () => - provideTmpdirInstance( - (path) => - Effect.gen(function* () { - const file = `${path}/test.parallel` - yield* Effect.promise(() => Bun.write(file, "x")) + it.instance( + "runs enabled checks for matching formatters in parallel", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const file = `${test.directory}/test.parallel` + yield* Effect.promise(() => Bun.write(file, "x")) - const one = { - extensions: Formatter.gofmt.extensions, - enabled: Formatter.gofmt.enabled, - } - const two = { - extensions: Formatter.mix.extensions, - enabled: Formatter.mix.enabled, - } + const one = { + extensions: Formatter.gofmt.extensions, + enabled: Formatter.gofmt.enabled, + } + const two = { + extensions: Formatter.mix.extensions, + enabled: Formatter.mix.enabled, + } - let active = 0 - let max = 0 + let active = 0 + let max = 0 - yield* Effect.acquireUseRelease( - Effect.sync(() => { - Formatter.gofmt.extensions = [".parallel"] - Formatter.mix.extensions = [".parallel"] - Formatter.gofmt.enabled = async () => { - active++ - max = Math.max(max, active) - await Promise.resolve() - active-- - return ["sh", "-c", "true"] - } - Formatter.mix.enabled = async () => { - active++ - max = Math.max(max, active) - await Promise.resolve() - active-- - return ["sh", "-c", "true"] - } - }), - () => - Format.Service.use((fmt) => - Effect.gen(function* () { - yield* fmt.init() - yield* fmt.file(file) - }), - ), - () => - Effect.sync(() => { - Formatter.gofmt.extensions = one.extensions - Formatter.gofmt.enabled = one.enabled - Formatter.mix.extensions = two.extensions - Formatter.mix.enabled = two.enabled + yield* Effect.acquireUseRelease( + Effect.sync(() => { + Formatter.gofmt.extensions = [".parallel"] + Formatter.mix.extensions = [".parallel"] + Formatter.gofmt.enabled = async () => { + active++ + max = Math.max(max, active) + await Promise.resolve() + active-- + return ["sh", "-c", "true"] + } + Formatter.mix.enabled = async () => { + active++ + max = Math.max(max, active) + await Promise.resolve() + active-- + return ["sh", "-c", "true"] + } + }), + () => + Format.Service.use((fmt) => + Effect.gen(function* () { + yield* fmt.init() + yield* fmt.file(file) }), - ) + ), + () => + Effect.sync(() => { + Formatter.gofmt.extensions = one.extensions + Formatter.gofmt.enabled = one.enabled + Formatter.mix.extensions = two.extensions + Formatter.mix.enabled = two.enabled + }), + ) - expect(max).toBe(2) - }), - { - config: { - formatter: { - gofmt: {}, - mix: {}, - }, - }, - }, - ), + expect(max).toBe(2) + }), + { config: { formatter: { gofmt: {}, mix: {} } } }, ) - it.live("runs matching formatters sequentially for the same file", () => - provideTmpdirInstance( - (path) => - Effect.gen(function* () { - const file = `${path}/test.seq` - yield* Effect.promise(() => Bun.write(file, "x")) + it.instance( + "runs matching formatters sequentially for the same file", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const file = `${test.directory}/test.seq` + yield* Effect.promise(() => Bun.write(file, "x")) - yield* Format.Service.use((fmt) => - Effect.gen(function* () { - yield* fmt.init() - expect(yield* fmt.file(file)).toBe(true) - }), - ) + yield* Format.Service.use((fmt) => + Effect.gen(function* () { + yield* fmt.init() + expect(yield* fmt.file(file)).toBe(true) + }), + ) - expect(yield* Effect.promise(() => Bun.file(file).text())).toBe("xAB") - }), - { - config: { - formatter: { - first: { - command: [ - "node", - "-e", - "const fs = require('fs'); const file = process.argv[1]; fs.writeFileSync(file, fs.readFileSync(file, 'utf8') + 'A')", - "$FILE", - ], - extensions: [".seq"], - }, - second: { - command: [ - "node", - "-e", - "const fs = require('fs'); const file = process.argv[1]; fs.writeFileSync(file, fs.readFileSync(file, 'utf8') + 'B')", - "$FILE", - ], - extensions: [".seq"], - }, + expect(yield* Effect.promise(() => Bun.file(file).text())).toBe("xAB") + }), + { + config: { + formatter: { + first: { + command: [ + "node", + "-e", + "const fs = require('fs'); const file = process.argv[1]; fs.writeFileSync(file, fs.readFileSync(file, 'utf8') + 'A')", + "$FILE", + ], + extensions: [".seq"], + }, + second: { + command: [ + "node", + "-e", + "const fs = require('fs'); const file = process.argv[1]; fs.writeFileSync(file, fs.readFileSync(file, 'utf8') + 'B')", + "$FILE", + ], + extensions: [".seq"], }, }, }, - ), + }, ) }) diff --git a/packages/opencode/test/lib/effect.ts b/packages/opencode/test/lib/effect.ts index f04829601d..952cc6b62e 100644 --- a/packages/opencode/test/lib/effect.ts +++ b/packages/opencode/test/lib/effect.ts @@ -6,18 +6,25 @@ import * as TestConsole from "effect/testing/TestConsole" import { memoMap } from "@opencode-ai/core/effect/memo-map" import type { Config } from "@/config/config" import { TestInstance, withTmpdirInstance } from "../fixture/fixture" +import { InstanceStore } from "@/project/instance-store" type Body = Effect.Effect | (() => Effect.Effect) -type InstanceOptions = { git?: boolean; config?: Partial | (() => Partial) } - -function isInstanceOptions(options: InstanceOptions | number | TestOptions | undefined): options is InstanceOptions { - return !!options && typeof options === "object" && ("git" in options || "config" in options) +type InstanceOptions = { + git?: boolean + config?: Partial | (() => Partial) + init?: (directory: string) => Effect.Effect } -function instanceArgs( - options?: InstanceOptions | number | TestOptions, +function isInstanceOptions( + options: InstanceOptions | number | TestOptions | undefined, +): options is InstanceOptions { + return !!options && typeof options === "object" && ("git" in options || "config" in options || "init" in options) +} + +function instanceArgs( + options?: InstanceOptions | number | TestOptions, testOptions?: number | TestOptions, -): { instanceOptions: InstanceOptions | undefined; testOptions: number | TestOptions | undefined } { +): { 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 } @@ -75,10 +82,10 @@ const make = (testLayer: Layer.Layer, liveLayer: Layer.Layer, live.skip = (name: string, value: Body, opts?: number | TestOptions) => test.skip(name, () => run(value, liveLayer), opts) - const instance = ( + const instance = ( name: string, - value: Body, - options?: InstanceOptions | number | TestOptions, + value: Body, + options?: InstanceOptions | number | TestOptions, opts?: number | TestOptions, ) => { const args = instanceArgs(options, opts) @@ -89,10 +96,10 @@ const make = (testLayer: Layer.Layer, liveLayer: Layer.Layer, ) } - instance.only = ( + instance.only = ( name: string, - value: Body, - options?: InstanceOptions | number | TestOptions, + value: Body, + options?: InstanceOptions | number | TestOptions, opts?: number | TestOptions, ) => { const args = instanceArgs(options, opts) @@ -103,10 +110,10 @@ const make = (testLayer: Layer.Layer, liveLayer: Layer.Layer, ) } - instance.skip = ( + instance.skip = ( name: string, - value: Body, - options?: InstanceOptions | number | TestOptions, + value: Body, + options?: InstanceOptions | number | TestOptions, opts?: number | TestOptions, ) => { const args = instanceArgs(options, opts) @@ -126,17 +133,17 @@ const testEnv = Layer.mergeAll(TestConsole.layer, TestClock.layer()) // Live environment - uses real clock, but keeps TestConsole for output capture const liveEnv = TestConsole.layer -export const it = make(testEnv, liveEnv) +export const it = make(testEnv, liveEnv) export const testEffect = (layer: Layer.Layer) => - make(Layer.provideMerge(layer, testEnv), Layer.provideMerge(layer, liveEnv)) + make(Layer.provideMerge(layer, testEnv), Layer.provideMerge(layer, liveEnv)) // Variant of `testEffect` that builds the test layer through the shared // process-wide memoMap so services like Bus/Session resolve to the same // instances Server.Default uses. Use when a test needs pub/sub identity with // an in-process HTTP server — most tests should stick with `testEffect`. export const testEffectShared = (layer: Layer.Layer) => - make(Layer.provideMerge(layer, testEnv), Layer.provideMerge(layer, liveEnv), sharedRun) + make(Layer.provideMerge(layer, testEnv), Layer.provideMerge(layer, liveEnv), sharedRun) export const awaitWithTimeout = ( self: Effect.Effect, diff --git a/packages/opencode/test/lsp/index.test.ts b/packages/opencode/test/lsp/index.test.ts index 78543c4583..b99131e268 100644 --- a/packages/opencode/test/lsp/index.test.ts +++ b/packages/opencode/test/lsp/index.test.ts @@ -1,36 +1,44 @@ import { describe, expect, spyOn } from "bun:test" import path from "path" import { Deferred, Effect, Layer } from "effect" -import { Bus } from "@/bus" +import { EventV2Bridge } from "@/event-v2-bridge" import { Config } from "@/config/config" import { RuntimeFlags } from "@/effect/runtime-flags" import { LSP } from "@/lsp/lsp" import * as LSPServer from "@/lsp/server" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" -import { provideTmpdirInstance } from "../fixture/fixture" +import { TestInstance } from "../fixture/fixture" import { awaitWithTimeout, testEffect } from "../lib/effect" -const it = testEffect(Layer.mergeAll(LSP.defaultLayer, CrossSpawnSpawner.defaultLayer)) +const lspLayer = (flags: Parameters[0] = {}) => + LSP.layer.pipe( + Layer.provide(Config.defaultLayer), + Layer.provide(RuntimeFlags.layer(flags)), + Layer.provideMerge(EventV2Bridge.defaultLayer), + ) + +const it = testEffect(Layer.mergeAll(lspLayer(), CrossSpawnSpawner.defaultLayer)) const experimentalTyIt = testEffect( Layer.mergeAll( - LSP.layer.pipe(Layer.provide(Config.defaultLayer), Layer.provide(RuntimeFlags.layer({ experimentalLspTy: true }))), + lspLayer({ experimentalLspTy: true }), CrossSpawnSpawner.defaultLayer, ), ) const fakeServerPath = path.join(__dirname, "../fixture/lsp/fake-lsp-server.js") const disabledDownloadIt = testEffect( Layer.mergeAll( - LSP.layer.pipe(Layer.provide(Config.defaultLayer), Layer.provide(RuntimeFlags.layer({ disableLspDownload: true }))), + lspLayer({ disableLspDownload: true }), CrossSpawnSpawner.defaultLayer, ), ) describe("lsp.spawn", () => { - it.live("does not spawn builtin LSP for files outside instance", () => - provideTmpdirInstance( - (dir) => - LSP.Service.use((lsp) => + it.instance( + "does not spawn builtin LSP for files outside instance", + () => + LSP.Service.use((lsp) => Effect.gen(function* () { + const dir = (yield* TestInstance).directory const spy = spyOn(LSPServer.Typescript, "spawn").mockResolvedValue(undefined) try { @@ -46,14 +54,13 @@ describe("lsp.spawn", () => { } }), ), - { config: { lsp: true } }, - ), + { config: { lsp: true } }, ) - it.live("does not spawn builtin LSP for files inside instance when LSP is unset", () => - provideTmpdirInstance((dir) => + it.instance("does not spawn builtin LSP for files inside instance when LSP is unset", () => LSP.Service.use((lsp) => Effect.gen(function* () { + const dir = (yield* TestInstance).directory const spy = spyOn(LSPServer.Typescript, "spawn").mockResolvedValue(undefined) try { @@ -68,14 +75,14 @@ describe("lsp.spawn", () => { } }), ), - ), ) - it.live("would spawn builtin LSP for files inside instance when lsp is true", () => - provideTmpdirInstance( - (dir) => - LSP.Service.use((lsp) => + it.instance( + "would spawn builtin LSP for files inside instance when lsp is true", + () => + LSP.Service.use((lsp) => Effect.gen(function* () { + const dir = (yield* TestInstance).directory const spy = spyOn(LSPServer.Typescript, "spawn").mockResolvedValue(undefined) try { @@ -90,44 +97,46 @@ describe("lsp.spawn", () => { } }), ), - { config: { lsp: true } }, - ), + { config: { lsp: true } }, ) - it.live("publishes lsp.updated after custom LSP initialization", () => - provideTmpdirInstance( - (dir) => - Effect.gen(function* () { + it.instance( + "publishes lsp.updated after custom LSP initialization", + () => + Effect.gen(function* () { + const dir = (yield* TestInstance).directory const lsp = yield* LSP.Service const updated = yield* Deferred.make() - const unsubscribe = Bus.subscribe(LSP.Event.Updated, () => - Effect.runSync(Deferred.succeed(updated, undefined)), - ) - yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)) + const events = yield* EventV2Bridge.Service + const unsubscribe = yield* events.listen((event) => { + if (event.type === LSP.Event.Updated.type) Deferred.doneUnsafe(updated, Effect.void) + return Effect.void + }) + yield* Effect.addFinalizer(() => unsubscribe) const file = path.join(dir, "sample.repro") yield* Effect.promise(() => Bun.write(file, "sample\n")) yield* lsp.touchFile(file) yield* awaitWithTimeout(Deferred.await(updated), "lsp.updated event was not published") }), - { - config: { - lsp: { - fake: { - command: [process.execPath, fakeServerPath], - extensions: [".repro"], - }, + { + config: { + lsp: { + fake: { + command: [process.execPath, fakeServerPath], + extensions: [".repro"], }, }, }, - ), + }, ) - it.live("would spawn builtin LSP for files inside instance when config object is provided", () => - provideTmpdirInstance( - (dir) => - LSP.Service.use((lsp) => + it.instance( + "would spawn builtin LSP for files inside instance when config object is provided", + () => + LSP.Service.use((lsp) => Effect.gen(function* () { + const dir = (yield* TestInstance).directory const spy = spyOn(LSPServer.Typescript, "spawn").mockResolvedValue(undefined) try { @@ -142,21 +151,21 @@ describe("lsp.spawn", () => { } }), ), - { - config: { - lsp: { - eslint: { disabled: true }, - }, + { + config: { + lsp: { + eslint: { disabled: true }, }, }, - ), + }, ) - it.live("uses pyright instead of ty by default", () => - provideTmpdirInstance( - (dir) => - LSP.Service.use((lsp) => + it.instance( + "uses pyright instead of ty by default", + () => + LSP.Service.use((lsp) => Effect.gen(function* () { + const dir = (yield* TestInstance).directory const ty = spyOn(LSPServer.Ty, "spawn").mockResolvedValue(undefined) const pyright = spyOn(LSPServer.Pyright, "spawn").mockResolvedValue(undefined) @@ -174,15 +183,15 @@ describe("lsp.spawn", () => { } }), ), - { config: { lsp: true } }, - ), + { config: { lsp: true } }, ) - experimentalTyIt.live("uses ty instead of pyright when experimentalLspTy is enabled", () => - provideTmpdirInstance( - (dir) => - LSP.Service.use((lsp) => + experimentalTyIt.instance( + "uses ty instead of pyright when experimentalLspTy is enabled", + () => + LSP.Service.use((lsp) => Effect.gen(function* () { + const dir = (yield* TestInstance).directory const ty = spyOn(LSPServer.Ty, "spawn").mockResolvedValue(undefined) const pyright = spyOn(LSPServer.Pyright, "spawn").mockResolvedValue(undefined) @@ -200,15 +209,15 @@ describe("lsp.spawn", () => { } }), ), - { config: { lsp: true } }, - ), + { config: { lsp: true } }, ) - disabledDownloadIt.live("passes disableLspDownload to builtin LSP spawn", () => - provideTmpdirInstance( - (dir) => - LSP.Service.use((lsp) => + disabledDownloadIt.instance( + "passes disableLspDownload to builtin LSP spawn", + () => + LSP.Service.use((lsp) => Effect.gen(function* () { + const dir = (yield* TestInstance).directory const pyright = spyOn(LSPServer.Pyright, "spawn").mockResolvedValue(undefined) try { @@ -224,7 +233,6 @@ describe("lsp.spawn", () => { } }), ), - { config: { lsp: true } }, - ), + { config: { lsp: true } }, ) }) diff --git a/packages/opencode/test/lsp/lifecycle.test.ts b/packages/opencode/test/lsp/lifecycle.test.ts index 11b191f005..f6db6c087a 100644 --- a/packages/opencode/test/lsp/lifecycle.test.ts +++ b/packages/opencode/test/lsp/lifecycle.test.ts @@ -4,7 +4,7 @@ import { Effect, Layer } from "effect" import { LSP } from "@/lsp/lsp" import * as LSPServer from "@/lsp/server" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" -import { provideTmpdirInstance } from "../fixture/fixture" +import { TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" const it = testEffect(Layer.mergeAll(LSP.defaultLayer, CrossSpawnSpawner.defaultLayer)) @@ -20,137 +20,113 @@ describe("LSP service lifecycle", () => { spawnSpy.mockRestore() }) - it.live("init() completes without error", () => provideTmpdirInstance(() => LSP.Service.use((lsp) => lsp.init()))) + it.instance("init() completes without error", () => LSP.Service.use((lsp) => lsp.init())) - it.live("status() returns empty array initially", () => - provideTmpdirInstance(() => - LSP.Service.use((lsp) => - Effect.gen(function* () { - const result = yield* lsp.status() - expect(Array.isArray(result)).toBe(true) - expect(result.length).toBe(0) - }), - ), + it.instance("status() returns empty array initially", () => + LSP.Service.use((lsp) => + Effect.gen(function* () { + const result = yield* lsp.status() + expect(Array.isArray(result)).toBe(true) + expect(result.length).toBe(0) + }), ), ) - it.live("diagnostics() returns empty object initially", () => - provideTmpdirInstance(() => - LSP.Service.use((lsp) => - Effect.gen(function* () { - const result = yield* lsp.diagnostics() - expect(typeof result).toBe("object") - expect(Object.keys(result).length).toBe(0) - }), - ), + it.instance("diagnostics() returns empty object initially", () => + LSP.Service.use((lsp) => + Effect.gen(function* () { + const result = yield* lsp.diagnostics() + expect(typeof result).toBe("object") + expect(Object.keys(result).length).toBe(0) + }), ), ) - it.live("hasClients() returns false for .ts files in instance when LSP is unset", () => - provideTmpdirInstance((dir) => + it.instance("hasClients() returns false for .ts files in instance when LSP is unset", () => LSP.Service.use((lsp) => Effect.gen(function* () { - const result = yield* lsp.hasClients(path.join(dir, "test.ts")) + const result = yield* lsp.hasClients(path.join((yield* TestInstance).directory, "test.ts")) expect(result).toBe(false) }), ), - ), ) - it.live("hasClients() returns true for .ts files in instance when lsp is true", () => - provideTmpdirInstance( - (dir) => - LSP.Service.use((lsp) => - Effect.gen(function* () { - const result = yield* lsp.hasClients(path.join(dir, "test.ts")) - expect(result).toBe(true) - }), - ), - { config: { lsp: true } }, - ), - ) - - it.live("hasClients() keeps built-in LSPs when config object is provided", () => - provideTmpdirInstance( - (dir) => - LSP.Service.use((lsp) => - Effect.gen(function* () { - const result = yield* lsp.hasClients(path.join(dir, "test.ts")) - expect(result).toBe(true) - }), - ), - { - config: { - lsp: { - eslint: { disabled: true }, - }, - }, - }, - ), - ) - - it.live("hasClients() returns false for files outside instance", () => - provideTmpdirInstance((dir) => + it.instance( + "hasClients() returns true for .ts files in instance when lsp is true", + () => LSP.Service.use((lsp) => Effect.gen(function* () { - const result = yield* lsp.hasClients(path.join(dir, "..", "outside.ts")) - expect(typeof result).toBe("boolean") + const result = yield* lsp.hasClients(path.join((yield* TestInstance).directory, "test.ts")) + expect(result).toBe(true) }), ), + { config: { lsp: true } }, + ) + + it.instance( + "hasClients() keeps built-in LSPs when config object is provided", + () => + LSP.Service.use((lsp) => + Effect.gen(function* () { + const result = yield* lsp.hasClients(path.join((yield* TestInstance).directory, "test.ts")) + expect(result).toBe(true) + }), + ), + { config: { lsp: { eslint: { disabled: true } } } }, + ) + + it.instance("hasClients() returns false for files outside instance", () => + LSP.Service.use((lsp) => + Effect.gen(function* () { + const result = yield* lsp.hasClients(path.join((yield* TestInstance).directory, "..", "outside.ts")) + expect(typeof result).toBe("boolean") + }), ), ) - it.live("workspaceSymbol() returns empty array with no clients", () => - provideTmpdirInstance(() => - LSP.Service.use((lsp) => - Effect.gen(function* () { - const result = yield* lsp.workspaceSymbol("test") - expect(Array.isArray(result)).toBe(true) - expect(result.length).toBe(0) - }), - ), + it.instance("workspaceSymbol() returns empty array with no clients", () => + LSP.Service.use((lsp) => + Effect.gen(function* () { + const result = yield* lsp.workspaceSymbol("test") + expect(Array.isArray(result)).toBe(true) + expect(result.length).toBe(0) + }), ), ) - it.live("definition() returns empty array for unknown file", () => - provideTmpdirInstance((dir) => - LSP.Service.use((lsp) => - Effect.gen(function* () { - const result = yield* lsp.definition({ - file: path.join(dir, "nonexistent.ts"), - line: 0, - character: 0, - }) - expect(Array.isArray(result)).toBe(true) - }), - ), + it.instance("definition() returns empty array for unknown file", () => + LSP.Service.use((lsp) => + Effect.gen(function* () { + const result = yield* lsp.definition({ + file: path.join((yield* TestInstance).directory, "nonexistent.ts"), + line: 0, + character: 0, + }) + expect(Array.isArray(result)).toBe(true) + }), ), ) - it.live("references() returns empty array for unknown file", () => - provideTmpdirInstance((dir) => - LSP.Service.use((lsp) => - Effect.gen(function* () { - const result = yield* lsp.references({ - file: path.join(dir, "nonexistent.ts"), - line: 0, - character: 0, - }) - expect(Array.isArray(result)).toBe(true) - }), - ), + it.instance("references() returns empty array for unknown file", () => + LSP.Service.use((lsp) => + Effect.gen(function* () { + const result = yield* lsp.references({ + file: path.join((yield* TestInstance).directory, "nonexistent.ts"), + line: 0, + character: 0, + }) + expect(Array.isArray(result)).toBe(true) + }), ), ) - it.live("multiple init() calls are idempotent", () => - provideTmpdirInstance(() => - LSP.Service.use((lsp) => - Effect.gen(function* () { - yield* lsp.init() - yield* lsp.init() - yield* lsp.init() - }), - ), + it.instance("multiple init() calls are idempotent", () => + LSP.Service.use((lsp) => + Effect.gen(function* () { + yield* lsp.init() + yield* lsp.init() + yield* lsp.init() + }), ), ) }) diff --git a/packages/opencode/test/mcp/oauth-auto-connect.test.ts b/packages/opencode/test/mcp/oauth-auto-connect.test.ts index 17bdba690f..542069fc6c 100644 --- a/packages/opencode/test/mcp/oauth-auto-connect.test.ts +++ b/packages/opencode/test/mcp/oauth-auto-connect.test.ts @@ -112,7 +112,7 @@ beforeEach(() => { // Import modules after mocking const { MCP } = await import("../../src/mcp/index") -const { Bus } = await import("../../src/bus") +const { EventV2Bridge } = await import("../../src/event-v2-bridge") const { Config } = await import("../../src/config/config") const { McpAuth } = await import("../../src/mcp/auth") const { McpOAuthProvider } = await import("../../src/mcp/oauth-provider") @@ -123,7 +123,7 @@ const mcpTest = testEffect( Layer.mergeAll( MCP.layer.pipe( Layer.provide(McpAuth.defaultLayer), - Layer.provideMerge(Bus.layer), + Layer.provideMerge(EventV2Bridge.defaultLayer), Layer.provide(Config.defaultLayer), Layer.provide(CrossSpawnSpawner.defaultLayer), Layer.provide(AppFileSystem.defaultLayer), diff --git a/packages/opencode/test/mcp/oauth-browser.test.ts b/packages/opencode/test/mcp/oauth-browser.test.ts index 16d6a2d467..92f473b113 100644 --- a/packages/opencode/test/mcp/oauth-browser.test.ts +++ b/packages/opencode/test/mcp/oauth-browser.test.ts @@ -106,7 +106,7 @@ beforeEach(() => { // Import modules after mocking const { MCP } = await import("../../src/mcp/index") -const { Bus } = await import("../../src/bus") +const { EventV2Bridge } = await import("../../src/event-v2-bridge") const { Config } = await import("../../src/config/config") const { McpAuth } = await import("../../src/mcp/auth") const { McpOAuthCallback } = await import("../../src/mcp/oauth-callback") @@ -115,7 +115,7 @@ const { CrossSpawnSpawner } = await import("@opencode-ai/core/cross-spawn-spawne const mcpTest = testEffect( MCP.layer.pipe( Layer.provide(McpAuth.defaultLayer), - Layer.provideMerge(Bus.layer), + Layer.provideMerge(EventV2Bridge.defaultLayer), Layer.provide(Config.defaultLayer), Layer.provide(CrossSpawnSpawner.defaultLayer), Layer.provide(AppFileSystem.defaultLayer), @@ -142,12 +142,14 @@ const trackBrowserOpen = Effect.gen(function* () { }) const trackBrowserOpenFailed = Effect.gen(function* () { - const bus = yield* Bus.Service + const events = yield* EventV2Bridge.Service const event = yield* Deferred.make<{ mcpName: string; url: string }>() - const unsubscribe = yield* bus.subscribeCallback(MCP.BrowserOpenFailed, (evt) => { - Effect.runSync(Deferred.succeed(event, evt.properties).pipe(Effect.ignore)) + const unsubscribe = yield* events.listen((evt) => { + if (evt.type === MCP.BrowserOpenFailed.type) + Deferred.doneUnsafe(event, Effect.succeed(evt.data as { mcpName: string; url: string })) + return Effect.void }) - yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)) + yield* Effect.addFinalizer(() => unsubscribe) return event }) diff --git a/packages/opencode/test/permission/next.test.ts b/packages/opencode/test/permission/next.test.ts index e969e67ff6..b05da50cb8 100644 --- a/packages/opencode/test/permission/next.test.ts +++ b/packages/opencode/test/permission/next.test.ts @@ -1,8 +1,9 @@ import { test, expect } from "bun:test" import os from "os" import { Cause, Deferred, Effect, Exit, Fiber, Layer } from "effect" -import { Bus } from "../../src/bus" +import { EventV2Bridge } from "../../src/event-v2-bridge" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { Database } from "@opencode-ai/core/database/database" import { Permission } from "../../src/permission" import { PermissionID } from "../../src/permission/schema" import { InstanceBootstrap } from "../../src/project/bootstrap-service" @@ -11,11 +12,11 @@ import { TestInstance, tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" import { MessageID, SessionID } from "../../src/session/schema" -const bus = Bus.layer +const events = EventV2Bridge.defaultLayer const noopBootstrap = Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void })) const env = Layer.mergeAll( - Permission.layer.pipe(Layer.provide(bus)), - bus, + Permission.layer.pipe(Layer.provide(Database.defaultLayer), Layer.provide(events)), + events, CrossSpawnSpawner.defaultLayer, InstanceStore.defaultLayer.pipe(Layer.provide(noopBootstrap)), ) @@ -653,12 +654,13 @@ it.instance( "ask - publishes asked event", () => Effect.gen(function* () { - const bus = yield* Bus.Service + const events = yield* EventV2Bridge.Service const seen = yield* Deferred.make() - const unsub = yield* bus.subscribeCallback(Permission.Event.Asked, (event) => { - Deferred.doneUnsafe(seen, Effect.succeed(event.properties)) + const unsub = yield* events.listen((event) => { + if (event.type === Permission.Event.Asked.type) Deferred.doneUnsafe(seen, Effect.succeed(event.data as Permission.Request)) + return Effect.void }) - yield* Effect.addFinalizer(() => Effect.sync(unsub)) + yield* Effect.addFinalizer(() => unsub) const fiber = yield* ask({ sessionID: SessionID.make("session_test"), @@ -913,7 +915,7 @@ it.instance( "reply - publishes replied event", () => Effect.gen(function* () { - const bus = yield* Bus.Service + const events = yield* EventV2Bridge.Service const seen = yield* Deferred.make<{ sessionID: SessionID; requestID: PermissionID; reply: Permission.Reply }>() const fiber = yield* ask({ @@ -928,10 +930,12 @@ it.instance( yield* waitForPending(1) - const unsub = yield* bus.subscribeCallback(Permission.Event.Replied, (event) => { - Deferred.doneUnsafe(seen, Effect.succeed(event.properties)) + const unsub = yield* events.listen((event) => { + if (event.type === Permission.Event.Replied.type) + Deferred.doneUnsafe(seen, Effect.succeed(event.data as { sessionID: SessionID; requestID: PermissionID; reply: Permission.Reply })) + return Effect.void }) - yield* Effect.addFinalizer(() => Effect.sync(unsub)) + yield* Effect.addFinalizer(() => unsub) yield* reply({ requestID: PermissionID.make("per_test7"), reply: "once" }) yield* Fiber.join(fiber) diff --git a/packages/opencode/test/plugin/auth-override.test.ts b/packages/opencode/test/plugin/auth-override.test.ts index c10957996e..58ffe19775 100644 --- a/packages/opencode/test/plugin/auth-override.test.ts +++ b/packages/opencode/test/plugin/auth-override.test.ts @@ -5,14 +5,15 @@ import { Effect, Layer } from "effect" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { provideInstance, TestInstance, tmpdirScoped } from "../fixture/fixture" import { ProviderAuth } from "@/provider/auth" -import { ProviderID } from "../../src/provider/schema" + import { Plugin } from "@/plugin" import { RuntimeFlags } from "@/effect/runtime-flags" import { Auth } from "@/auth" -import { Bus } from "@/bus" +import { EventV2Bridge } from "@/event-v2-bridge" import { TestConfig } from "../fixture/config" import { testEffect } from "../lib/effect" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { ProviderV2 } from "@opencode-ai/core/provider" const it = testEffect(Layer.mergeAll(CrossSpawnSpawner.defaultLayer, AppFileSystem.defaultLayer)) @@ -21,7 +22,7 @@ function layer(directory: string, plugins: string[]) { Layer.provide(Auth.defaultLayer), Layer.provide( Plugin.layer.pipe( - Layer.provide(Bus.layer), + Layer.provide(EventV2Bridge.defaultLayer), Layer.provide(RuntimeFlags.layer()), Layer.provide( TestConfig.layer({ @@ -77,11 +78,11 @@ describe("plugin.auth-override", () => { .methods() .pipe(Effect.provide(layer(plain, [])), provideInstance(plain)) - const copilot = methods[ProviderID.make("github-copilot")] + const copilot = methods[ProviderV2.ID.make("github-copilot")] expect(copilot).toBeDefined() expect(copilot.length).toBe(1) expect(copilot[0].label).toBe("Test Override Auth") - expect(plainMethods[ProviderID.make("github-copilot")][0].label).not.toBe("Test Override Auth") + expect(plainMethods[ProviderV2.ID.make("github-copilot")][0].label).not.toBe("Test Override Auth") }), { git: true }, 30000, diff --git a/packages/opencode/test/plugin/loader-shared.test.ts b/packages/opencode/test/plugin/loader-shared.test.ts index ad03d229f2..316fb2bc46 100644 --- a/packages/opencode/test/plugin/loader-shared.test.ts +++ b/packages/opencode/test/plugin/loader-shared.test.ts @@ -5,13 +5,13 @@ import path from "path" import { pathToFileURL } from "url" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { AppFileSystem } from "@opencode-ai/core/filesystem" -import { disposeAllInstances, provideInstance, tmpdirScoped } from "../fixture/fixture" +import { disposeAllInstances, provideInstance, testInstanceStoreLayer, tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" const { Plugin } = await import("../../src/plugin/index") const { PluginLoader } = await import("../../src/plugin/loader") const { readPackageThemes } = await import("../../src/plugin/shared") -const { Bus } = await import("../../src/bus") +const { EventV2Bridge } = await import("../../src/event-v2-bridge") const { Npm } = await import("@opencode-ai/core/npm") const { TestConfig } = await import("../fixture/config") const { RuntimeFlags } = await import("../../src/effect/runtime-flags") @@ -20,7 +20,7 @@ afterEach(async () => { await disposeAllInstances() }) -const it = testEffect(Layer.mergeAll(CrossSpawnSpawner.defaultLayer, AppFileSystem.defaultLayer)) +const it = testEffect(Layer.mergeAll(CrossSpawnSpawner.defaultLayer, AppFileSystem.defaultLayer, testInstanceStoreLayer)) function withTmp( init: (dir: string) => Promise, @@ -46,7 +46,7 @@ function load(dir: string, flags?: Parameters[0]) { }).pipe( Effect.provide( Plugin.layer.pipe( - Layer.provide(Bus.layer), + Layer.provide(EventV2Bridge.defaultLayer), Layer.provide(RuntimeFlags.layer({ disableDefaultPlugins: true, ...flags })), Layer.provide( TestConfig.layer({ diff --git a/packages/opencode/test/plugin/trigger.test.ts b/packages/opencode/test/plugin/trigger.test.ts index 3716bc3aca..a3ed8334a5 100644 --- a/packages/opencode/test/plugin/trigger.test.ts +++ b/packages/opencode/test/plugin/trigger.test.ts @@ -6,17 +6,18 @@ import { AppFileSystem } from "@opencode-ai/core/filesystem" import { EffectFlock } from "@opencode-ai/core/util/effect-flock" import path from "path" import { pathToFileURL } from "url" -import { Bus } from "../../src/bus" +import { EventV2Bridge } from "../../src/event-v2-bridge" import { Config } from "../../src/config/config" import { Env } from "../../src/env" import { RuntimeFlags } from "../../src/effect/runtime-flags" import { Plugin } from "../../src/plugin/index" -import { ModelID, ProviderID } from "../../src/provider/schema" -import { provideTmpdirInstance } from "../fixture/fixture" + +import { TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" import { AccountTest } from "../fake/account" import { AuthTest } from "../fake/auth" import { NpmTest } from "../fake/npm" +import { ProviderV2 } from "@opencode-ai/core/provider" const configLayer = Config.layer.pipe( Layer.provide(EffectFlock.defaultLayer), @@ -30,7 +31,7 @@ const configLayer = Config.layer.pipe( const it = testEffect( Layer.mergeAll( Plugin.layer.pipe( - Layer.provide(Bus.layer), + Layer.provide(EventV2Bridge.defaultLayer), Layer.provide(configLayer), Layer.provide(RuntimeFlags.layer({ disableDefaultPlugins: true })), ), @@ -40,31 +41,30 @@ const it = testEffect( const systemHook = "experimental.chat.system.transform" function withProject(source: string, self: Effect.Effect) { - return provideTmpdirInstance((dir) => - Effect.gen(function* () { - const file = path.join(dir, "plugin.ts") - yield* Effect.all( - [ - Effect.promise(() => Bun.write(file, source)), - Effect.promise(() => - Bun.write( - path.join(dir, "opencode.json"), - JSON.stringify( - { - $schema: "https://opencode.ai/config.json", - plugin: [pathToFileURL(file).href], - }, - null, - 2, - ), + return Effect.gen(function* () { + const test = yield* TestInstance + const file = path.join(test.directory, "plugin.ts") + yield* Effect.all( + [ + Effect.promise(() => Bun.write(file, source)), + Effect.promise(() => + Bun.write( + path.join(test.directory, "opencode.json"), + JSON.stringify( + { + $schema: "https://opencode.ai/config.json", + plugin: [pathToFileURL(file).href], + }, + null, + 2, ), ), - ], - { discard: true, concurrency: 2 }, - ) - return yield* self - }), - ) + ), + ], + { discard: true, concurrency: 2 }, + ) + return yield* self + }) } const triggerSystemTransform = Effect.fn("PluginTriggerTest.triggerSystemTransform")(function* () { @@ -74,8 +74,8 @@ const triggerSystemTransform = Effect.fn("PluginTriggerTest.triggerSystemTransfo systemHook, { model: { - providerID: ProviderID.anthropic, - modelID: ModelID.make("claude-sonnet-4-6"), + providerID: ProviderV2.ID.anthropic, + modelID: ProviderV2.ModelID.make("claude-sonnet-4-6"), }, }, out, @@ -84,7 +84,7 @@ const triggerSystemTransform = Effect.fn("PluginTriggerTest.triggerSystemTransfo }) describe("plugin.trigger", () => { - it.live("runs synchronous hooks without crashing", () => + it.instance("runs synchronous hooks without crashing", () => withProject( [ "export default async () => ({", @@ -100,7 +100,7 @@ describe("plugin.trigger", () => { ), ) - it.live("awaits asynchronous hooks", () => + it.instance("awaits asynchronous hooks", () => withProject( [ "export default async () => ({", diff --git a/packages/opencode/test/plugin/workspace-adapter.test.ts b/packages/opencode/test/plugin/workspace-adapter.test.ts index 79964d3dee..2953aaa5f1 100644 --- a/packages/opencode/test/plugin/workspace-adapter.test.ts +++ b/packages/opencode/test/plugin/workspace-adapter.test.ts @@ -2,12 +2,13 @@ import { afterEach, describe, expect } from "bun:test" import { Effect, Layer } from "effect" import { FetchHttpClient } from "effect/unstable/http" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { Database } from "@opencode-ai/core/database/database" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { EffectFlock } from "@opencode-ai/core/util/effect-flock" import path from "path" import { pathToFileURL } from "url" import { Auth } from "../../src/auth" -import { Bus } from "../../src/bus" +import { EventV2Bridge } from "../../src/event-v2-bridge" import { Config } from "../../src/config/config" import { Env } from "../../src/env" import { RuntimeFlags } from "../../src/effect/runtime-flags" @@ -20,8 +21,7 @@ import { Vcs } from "../../src/project/vcs" import { InstanceState } from "../../src/effect/instance-state" import { Session } from "../../src/session/session" import { SessionPrompt } from "../../src/session/prompt" -import { SyncEvent } from "../../src/sync" -import { disposeAllInstances, provideTmpdirInstance } from "../fixture/fixture" +import { disposeAllInstances, TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" import { AccountTest } from "../fake/account" import { AuthTest } from "../fake/auth" @@ -37,7 +37,7 @@ const configLayer = Config.layer.pipe( Layer.provide(FetchHttpClient.layer), ) const pluginLayer = Plugin.layer.pipe( - Layer.provide(Bus.layer), + Layer.provide(EventV2Bridge.defaultLayer), Layer.provide(configLayer), Layer.provide(RuntimeFlags.layer({ disableDefaultPlugins: true })), ) @@ -45,11 +45,12 @@ const noopBootstrapLayer = Layer.succeed(InstanceBootstrap.Service, InstanceBoot const workspaceLayer = Workspace.layer.pipe( Layer.provide(Auth.defaultLayer), Layer.provide(Session.defaultLayer), - Layer.provide(SyncEvent.defaultLayer), Layer.provide(SessionPrompt.defaultLayer), Layer.provide(Project.defaultLayer), Layer.provide(Vcs.defaultLayer), Layer.provide(FetchHttpClient.layer), + Layer.provide(Database.defaultLayer), + Layer.provide(EventV2Bridge.defaultLayer), Layer.provide(AppFileSystem.defaultLayer), Layer.provide(InstanceStore.defaultLayer.pipe(Layer.provide(noopBootstrapLayer))), Layer.provide(RuntimeFlags.layer({ experimentalWorkspaces: true })), @@ -61,9 +62,9 @@ afterEach(async () => { }) describe("plugin.workspace", () => { - it.live("plugin can install a workspace adapter", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { + it.instance("plugin can install a workspace adapter", () => + Effect.gen(function* () { + const dir = (yield* TestInstance).directory const type = `plug-${Math.random().toString(36).slice(2)}` const file = path.join(dir, "plugin.ts") const mark = path.join(dir, "created.json") @@ -131,7 +132,6 @@ describe("plugin.workspace", () => { directory: space, extra: { key: "value" }, }) - }), - ), + }), ) }) diff --git a/packages/opencode/test/preload.ts b/packages/opencode/test/preload.ts index 24b804819e..1e9567c592 100644 --- a/packages/opencode/test/preload.ts +++ b/packages/opencode/test/preload.ts @@ -10,8 +10,6 @@ import { afterAll } from "bun:test" const dir = path.join(os.tmpdir(), "opencode-test-data-" + process.pid) await fs.mkdir(dir, { recursive: true }) afterAll(async () => { - const { Database } = await import("../src/storage/db") - Database.close() const busy = (error: unknown) => typeof error === "object" && error !== null && "code" in error && error.code === "EBUSY" const rm = async (left: number): Promise => { @@ -75,6 +73,11 @@ delete process.env["CEREBRAS_API_KEY"] delete process.env["SAMBANOVA_API_KEY"] delete process.env["OPENCODE_SERVER_PASSWORD"] delete process.env["OPENCODE_SERVER_USERNAME"] +delete process.env["OPENCODE_EXPERIMENTAL"] +delete process.env["OPENCODE_ENABLE_EXPERIMENTAL_MODELS"] +delete process.env["OTEL_EXPORTER_OTLP_ENDPOINT"] +delete process.env["OTEL_EXPORTER_OTLP_HEADERS"] +delete process.env["OTEL_RESOURCE_ATTRIBUTES"] // Use in-memory sqlite process.env["OPENCODE_DB"] = ":memory:" diff --git a/packages/opencode/test/project/migrate-global.test.ts b/packages/opencode/test/project/migrate-global.test.ts index 6efd670c5c..006ae2473a 100644 --- a/packages/opencode/test/project/migrate-global.test.ts +++ b/packages/opencode/test/project/migrate-global.test.ts @@ -1,10 +1,10 @@ import { describe, expect } from "bun:test" import { Project } from "@/project/project" -import { Database } from "@/storage/db" +import { Database } from "@opencode-ai/core/database/database" import { eq } from "drizzle-orm" -import { SessionTable } from "../../src/session/session.sql" -import { ProjectTable } from "../../src/project/project.sql" -import { ProjectID } from "../../src/project/schema" +import { SessionTable } from "@opencode-ai/core/session/sql" +import { ProjectTable } from "@opencode-ai/core/project/sql" +import { ProjectV2 } from "@opencode-ai/core/project" import { SessionID } from "../../src/session/schema" import * as Log from "@opencode-ai/core/util/log" import { $ } from "bun" @@ -15,16 +15,16 @@ import { testEffect } from "../lib/effect" void Log.init({ print: false }) -const it = testEffect(Layer.mergeAll(Project.defaultLayer, CrossSpawnSpawner.defaultLayer)) +const it = testEffect(Layer.mergeAll(Project.defaultLayer, CrossSpawnSpawner.defaultLayer, Database.defaultLayer)) function legacySessionID() { // Global-session migration covers persisted IDs from before prefixed session IDs. return crypto.randomUUID() as SessionID } -function seed(opts: { id: SessionID; dir: string; project: ProjectID }) { +function seed(opts: { id: SessionID; dir: string; project: ProjectV2.ID }) { const now = Date.now() - Database.use((db) => + return Database.Service.use(({ db }) => db .insert(SessionTable) .values({ @@ -37,23 +37,25 @@ function seed(opts: { id: SessionID; dir: string; project: ProjectID }) { time_created: now, time_updated: now, }) - .run(), + .run() + .pipe(Effect.orDie), ) } function ensureGlobal() { - Database.use((db) => + return Database.Service.use(({ db }) => db .insert(ProjectTable) .values({ - id: ProjectID.global, + id: ProjectV2.ID.global, worktree: "/", time_created: Date.now(), time_updated: Date.now(), sandboxes: [], }) .onConflictDoNothing() - .run(), + .run() + .pipe(Effect.orDie), ) } @@ -68,20 +70,22 @@ describe("migrateFromGlobal", () => { yield* Effect.promise(() => $`git config commit.gpgsign false`.cwd(tmp).quiet()) const projects = yield* Project.Service const { project: pre } = yield* projects.fromDirectory(tmp) - expect(pre.id).toBe(ProjectID.global) + expect(pre.id).toBe(ProjectV2.ID.global) // 2. Seed a session under "global" with matching directory const id = legacySessionID() - yield* Effect.sync(() => seed({ id, dir: tmp, project: ProjectID.global })) + yield* seed({ id, dir: tmp, project: ProjectV2.ID.global }) // 3. Make a commit so the project gets a real ID yield* Effect.promise(() => $`git commit --allow-empty -m "root"`.cwd(tmp).quiet()) const { project: real } = yield* projects.fromDirectory(tmp) - expect(real.id).not.toBe(ProjectID.global) + expect(real.id).not.toBe(ProjectV2.ID.global) // 4. The session should have been migrated to the real project ID - const row = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, id)).get()) + const row = yield* Database.Service.use(({ db }) => + db.select().from(SessionTable).where(eq(SessionTable.id, id)).get().pipe(Effect.orDie), + ) expect(row).toBeDefined() expect(row!.project_id).toBe(real.id) }), @@ -93,22 +97,24 @@ describe("migrateFromGlobal", () => { const tmp = yield* tmpdirScoped({ git: true }) const projects = yield* Project.Service const { project } = yield* projects.fromDirectory(tmp) - expect(project.id).not.toBe(ProjectID.global) + expect(project.id).not.toBe(ProjectV2.ID.global) // 2. Ensure "global" project row exists (as it would from a prior no-git session) - yield* Effect.sync(() => ensureGlobal()) + yield* ensureGlobal() // 3. Seed a session under "global" with matching directory. // This simulates a session created before git init that wasn't // present when the real project row was first created. const id = legacySessionID() - yield* Effect.sync(() => seed({ id, dir: tmp, project: ProjectID.global })) + yield* seed({ id, dir: tmp, project: ProjectV2.ID.global }) // 4. Call fromDirectory again — project row already exists, // so the current code skips migration entirely. This is the bug. yield* projects.fromDirectory(tmp) - const row = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, id)).get()) + const row = yield* Database.Service.use(({ db }) => + db.select().from(SessionTable).where(eq(SessionTable.id, id)).get().pipe(Effect.orDie), + ) expect(row).toBeDefined() expect(row!.project_id).toBe(project.id) }), @@ -119,20 +125,22 @@ describe("migrateFromGlobal", () => { const tmp = yield* tmpdirScoped({ git: true }) const projects = yield* Project.Service const { project } = yield* projects.fromDirectory(tmp) - expect(project.id).not.toBe(ProjectID.global) + expect(project.id).not.toBe(ProjectV2.ID.global) - yield* Effect.sync(() => ensureGlobal()) + yield* ensureGlobal() // Legacy sessions may lack a directory value. // Without a matching origin directory, they should remain global. const id = legacySessionID() - yield* Effect.sync(() => seed({ id, dir: "", project: ProjectID.global })) + yield* seed({ id, dir: "", project: ProjectV2.ID.global }) yield* projects.fromDirectory(tmp) - const row = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, id)).get()) + const row = yield* Database.Service.use(({ db }) => + db.select().from(SessionTable).where(eq(SessionTable.id, id)).get().pipe(Effect.orDie), + ) expect(row).toBeDefined() - expect(row!.project_id).toBe(ProjectID.global) + expect(row!.project_id).toBe(ProjectV2.ID.global) }), ) @@ -141,19 +149,21 @@ describe("migrateFromGlobal", () => { const tmp = yield* tmpdirScoped({ git: true }) const projects = yield* Project.Service const { project } = yield* projects.fromDirectory(tmp) - expect(project.id).not.toBe(ProjectID.global) + expect(project.id).not.toBe(ProjectV2.ID.global) - yield* Effect.sync(() => ensureGlobal()) + yield* ensureGlobal() // Seed a session under "global" but for a DIFFERENT directory const id = legacySessionID() - yield* Effect.sync(() => seed({ id, dir: "/some/other/dir", project: ProjectID.global })) + yield* seed({ id, dir: "/some/other/dir", project: ProjectV2.ID.global }) yield* projects.fromDirectory(tmp) - const row = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, id)).get()) + const row = yield* Database.Service.use(({ db }) => + db.select().from(SessionTable).where(eq(SessionTable.id, id)).get().pipe(Effect.orDie), + ) expect(row).toBeDefined() // Should remain under "global" — not stolen - expect(row!.project_id).toBe(ProjectID.global) + expect(row!.project_id).toBe(ProjectV2.ID.global) }), ) }) diff --git a/packages/opencode/test/project/project.test.ts b/packages/opencode/test/project/project.test.ts index 869326d87a..bbad81a713 100644 --- a/packages/opencode/test/project/project.test.ts +++ b/packages/opencode/test/project/project.test.ts @@ -1,27 +1,25 @@ -import { describe, expect, test } from "bun:test" -import { Bus } from "@/bus" +import { describe, expect } from "bun:test" +import { EventV2Bridge } from "@/event-v2-bridge" import { Project } from "@/project/project" import * as Log from "@opencode-ai/core/util/log" import { $ } from "bun" import path from "path" import { tmpdirScoped } from "../fixture/fixture" import { GlobalBus } from "../../src/bus/global" -import { ProjectID } from "../../src/project/schema" -import { Database } from "@/storage/db" -import { ProjectTable } from "@/project/project.sql" -import { SessionTable } from "@/session/session.sql" -import { PermissionTable } from "@/session/session.sql" -import { WorkspaceTable } from "@/control-plane/workspace.sql" +import { Database } from "@opencode-ai/core/database/database" +import { ProjectTable } from "@opencode-ai/core/project/sql" +import { PermissionTable, SessionTable } from "@opencode-ai/core/session/sql" +import { WorkspaceTable } from "@opencode-ai/core/control-plane/workspace.sql" import { eq } from "drizzle-orm" import { Hash } from "@opencode-ai/core/util/hash" import { SessionID } from "@/session/schema" -import { WorkspaceID } from "@/control-plane/schema" +import { WorkspaceV2 } from "@opencode-ai/core/workspace" import { Cause, Effect, Exit, Layer, Stream } from "effect" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import { NodePath } from "@effect/platform-node" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { AppProcess } from "@opencode-ai/core/process" -import { Project as ProjectV2 } from "@opencode-ai/core/project" +import { ProjectV2 } from "@opencode-ai/core/project" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { testEffect } from "../lib/effect" import { RuntimeFlags } from "@/effect/runtime-flags" @@ -30,18 +28,11 @@ void Log.init({ print: false }) const encoder = new TextEncoder() -const layer = Layer.mergeAll(Project.defaultLayer, CrossSpawnSpawner.defaultLayer) +const layer = Layer.mergeAll(Project.defaultLayer, Database.defaultLayer, CrossSpawnSpawner.defaultLayer) const it = testEffect(layer) -function run(fn: (svc: Project.Interface) => Effect.Effect) { - return Effect.gen(function* () { - const svc = yield* Project.Service - return yield* fn(svc) - }) -} - function remoteProjectID(remote: string) { - return ProjectID.make(Hash.fast(`git-remote:${remote}`)) + return ProjectV2.ID.make(Hash.fast(`git-remote:${remote}`)) } /** @@ -84,20 +75,22 @@ function projectLayerWithFailure(failArg: string) { Layer.provide(AppProcess.layer.pipe(Layer.provide(mockGitFailure(failArg)))), Layer.provide(mockGitFailure(failArg)), Layer.provide(ProjectV2.defaultLayer), - Layer.provide(Bus.defaultLayer), + Layer.provide(EventV2Bridge.defaultLayer), Layer.provide(AppFileSystem.defaultLayer), Layer.provide(NodePath.layer), + Layer.provide(Database.defaultLayer), Layer.provide(RuntimeFlags.defaultLayer), ) } function projectLayerWithRuntimeFlags(flags: Parameters[0]) { return Project.layer.pipe( - Layer.provide(Bus.defaultLayer), + Layer.provide(EventV2Bridge.defaultLayer), Layer.provide(ProjectV2.defaultLayer), Layer.provide(AppProcess.defaultLayer), Layer.provide(AppFileSystem.defaultLayer), Layer.provide(NodePath.layer), + Layer.provide(Database.defaultLayer), Layer.provide(RuntimeFlags.layer(flags)), ) } @@ -109,10 +102,11 @@ const iconDiscoveryIt = testEffect( Layer.provideMerge(projectLayerWithRuntimeFlags({ experimentalIconDiscovery: true }), CrossSpawnSpawner.defaultLayer), ) -function waitForProjectIcon(id: ProjectID, attempts = 50): Effect.Effect { +function waitForProjectIcon(id: ProjectV2.ID, attempts = 50): Effect.Effect { return Effect.gen(function* () { - const project = Project.get(id) - if (project?.icon?.url) return project + const project = yield* Project.Service + const info = yield* project.get(id) + if (info?.icon?.url) return info if (attempts <= 0) throw new Error(`Project icon was not discovered: ${id}`) yield* Effect.sleep("10 millis") return yield* waitForProjectIcon(id, attempts - 1) @@ -122,15 +116,16 @@ function waitForProjectIcon(id: ProjectID, attempts = 50): Effect.Effect { it.live("should handle git repository with no commits", () => Effect.gen(function* () { + const project = yield* Project.Service const tmp = yield* tmpdirScoped() yield* Effect.promise(() => $`git init`.cwd(tmp).quiet()) - const { project } = yield* run((svc) => svc.fromDirectory(tmp)) + const result = yield* project.fromDirectory(tmp) - expect(project).toBeDefined() - expect(project.id).toBe(ProjectID.global) - expect(project.vcs).toBe("git") - expect(project.worktree).toBe(tmp) + expect(result.project).toBeDefined() + expect(result.project.id).toBe(ProjectV2.ID.global) + expect(result.project.vcs).toBe("git") + expect(result.project.worktree).toBe(tmp) const opencodeFile = path.join(tmp, ".git", "opencode") expect(yield* Effect.promise(() => Bun.file(opencodeFile).exists())).toBe(false) @@ -139,119 +134,114 @@ describe("Project.fromDirectory", () => { it.live("should handle git repository with commits", () => Effect.gen(function* () { + const project = yield* Project.Service const tmp = yield* tmpdirScoped({ git: true }) - const { project } = yield* run((svc) => svc.fromDirectory(tmp)) + const result = yield* project.fromDirectory(tmp) - expect(project).toBeDefined() - expect(project.id).not.toBe(ProjectID.global) - expect(project.vcs).toBe("git") - expect(project.worktree).toBe(tmp) + expect(result.project).toBeDefined() + expect(result.project.id).not.toBe(ProjectV2.ID.global) + expect(result.project.vcs).toBe("git") + expect(result.project.worktree).toBe(tmp) }), ) it.live("returns global for non-git directory", () => Effect.gen(function* () { + const project = yield* Project.Service const tmp = yield* tmpdirScoped() - const { project } = yield* run((svc) => svc.fromDirectory(tmp)) - expect(project.id).toBe(ProjectID.global) + const result = yield* project.fromDirectory(tmp) + expect(result.project.id).toBe(ProjectV2.ID.global) }), ) it.live("derives stable project ID from root commit", () => Effect.gen(function* () { + const project = yield* Project.Service const tmp = yield* tmpdirScoped({ git: true }) - const { project: a } = yield* run((svc) => svc.fromDirectory(tmp)) - const { project: b } = yield* run((svc) => svc.fromDirectory(tmp)) - expect(b.id).toBe(a.id) + const result = yield* project.fromDirectory(tmp) + const next = yield* project.fromDirectory(tmp) + expect(next.project.id).toBe(result.project.id) }), ) it.live("prefers normalized origin remote over root commit", () => Effect.gen(function* () { + const project = yield* Project.Service const tmp = yield* tmpdirScoped({ git: true }) yield* Effect.promise(() => $`git remote add origin git@github.com:Test-Org/Test-Repo.git`.cwd(tmp).quiet()) - const { project } = yield* run((svc) => svc.fromDirectory(tmp)) + const result = yield* project.fromDirectory(tmp) - expect(project.id).toBe(remoteProjectID("github.com/Test-Org/Test-Repo")) + expect(result.project.id).toBe(remoteProjectID("github.com/Test-Org/Test-Repo")) }), ) it.live("normalizes equivalent origin URL forms to the same project ID", () => Effect.gen(function* () { + const project = yield* Project.Service const ssh = yield* tmpdirScoped({ git: true }) const https = yield* tmpdirScoped({ git: true }) yield* Effect.promise(() => $`git remote add origin git@github.com:owner/repo.git`.cwd(ssh).quiet()) yield* Effect.promise(() => $`git remote add origin https://github.com/owner/repo.git`.cwd(https).quiet()) - const { project: a } = yield* run((svc) => svc.fromDirectory(ssh)) - const { project: b } = yield* run((svc) => svc.fromDirectory(https)) + const result = yield* project.fromDirectory(ssh) + const next = yield* project.fromDirectory(https) - expect(a.id).toBe(remoteProjectID("github.com/owner/repo")) - expect(b.id).toBe(a.id) + expect(result.project.id).toBe(remoteProjectID("github.com/owner/repo")) + expect(next.project.id).toBe(result.project.id) }), ) it.live("migrates cached root project data when origin becomes available", () => Effect.gen(function* () { + const { db } = yield* Database.Service const tmp = yield* tmpdirScoped({ git: true }) const projects = yield* Project.Service - const { project: rootProject } = yield* projects.fromDirectory(tmp) + const rootResult = yield* projects.fromDirectory(tmp) + const rootProject = rootResult.project const remoteID = remoteProjectID("github.com/acme/app") const sessionID = crypto.randomUUID() as SessionID - const workspaceID = WorkspaceID.ascending() + const workspaceID = WorkspaceV2.ID.ascending() - yield* Effect.sync(() => { - Database.use((db) => { - db.insert(SessionTable) - .values({ - id: sessionID, - project_id: rootProject.id, - slug: sessionID, - directory: tmp, - title: "test", - version: "0.0.0-test", - time_created: Date.now(), - time_updated: Date.now(), - }) - .run() - db.insert(PermissionTable) - .values({ - project_id: rootProject.id, - data: [{ permission: "edit", pattern: "*", action: "allow" }], - time_created: Date.now(), - time_updated: Date.now(), - }) - .run() - db.insert(WorkspaceTable) - .values({ - id: workspaceID, - type: "local", - name: "test", - project_id: rootProject.id, - }) - .run() + yield* db + .insert(SessionTable) + .values({ + id: sessionID, + project_id: rootProject.id, + slug: sessionID, + directory: tmp, + title: "test", + version: "0.0.0-test", + time_created: Date.now(), + time_updated: Date.now(), }) - }) + .run() + .pipe(Effect.orDie) + yield* db + .insert(PermissionTable) + .values({ + project_id: rootProject.id, + data: [{ permission: "edit", pattern: "*", action: "allow" }], + time_created: Date.now(), + time_updated: Date.now(), + }) + .run() + .pipe(Effect.orDie) + yield* db + .insert(WorkspaceTable) + .values({ id: workspaceID, type: "local", name: "test", project_id: rootProject.id }) + .run() + .pipe(Effect.orDie) yield* Effect.promise(() => $`git remote add origin git@github.com:acme/app.git`.cwd(tmp).quiet()) - const { project } = yield* projects.fromDirectory(tmp) + const result = yield* projects.fromDirectory(tmp) - expect(project.id).toBe(remoteID) - expect( - Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, rootProject.id)).get()), - ).toBeUndefined() - expect( - Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, sessionID)).get())?.project_id, - ).toBe(remoteID) - expect( - Database.use((db) => db.select().from(PermissionTable).where(eq(PermissionTable.project_id, remoteID)).get()), - ).toBeDefined() - expect( - Database.use((db) => db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, workspaceID)).get()) - ?.project_id, - ).toBe(remoteID) + expect(result.project.id).toBe(remoteID) + expect(yield* db.select().from(ProjectTable).where(eq(ProjectTable.id, rootProject.id)).get().pipe(Effect.orDie)).toBeUndefined() + expect((yield* db.select().from(SessionTable).where(eq(SessionTable.id, sessionID)).get().pipe(Effect.orDie))?.project_id).toBe(remoteID) + expect(yield* db.select().from(PermissionTable).where(eq(PermissionTable.project_id, remoteID)).get().pipe(Effect.orDie)).toBeDefined() + expect((yield* db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, workspaceID)).get().pipe(Effect.orDie))?.project_id).toBe(remoteID) }), ) }) @@ -259,34 +249,37 @@ describe("Project.fromDirectory", () => { describe("Project.fromDirectory git failure paths", () => { it.live("keeps vcs when rev-list exits non-zero (no commits)", () => Effect.gen(function* () { + const project = yield* Project.Service const tmp = yield* tmpdirScoped() yield* Effect.promise(() => $`git init`.cwd(tmp).quiet()) // rev-list fails because HEAD doesn't exist yet: this is the natural scenario. - const { project } = yield* run((svc) => svc.fromDirectory(tmp)) - expect(project.vcs).toBe("git") - expect(project.id).toBe(ProjectID.global) - expect(project.worktree).toBe(tmp) + const result = yield* project.fromDirectory(tmp) + expect(result.project.vcs).toBe("git") + expect(result.project.id).toBe(ProjectV2.ID.global) + expect(result.project.worktree).toBe(tmp) }), ) failureIt("--show-toplevel").live("handles show-toplevel failure gracefully", () => Effect.gen(function* () { + const project = yield* Project.Service const tmp = yield* tmpdirScoped({ git: true }) - const { project, sandbox } = yield* run((svc) => svc.fromDirectory(tmp)) - expect(project.worktree).toBe(tmp) - expect(sandbox).toBe(tmp) + const result = yield* project.fromDirectory(tmp) + expect(result.project.worktree).toBe(tmp) + expect(result.sandbox).toBe(tmp) }), ) failureIt("--git-common-dir").live("handles git-common-dir failure gracefully", () => Effect.gen(function* () { + const project = yield* Project.Service const tmp = yield* tmpdirScoped({ git: true }) - const { project, sandbox } = yield* run((svc) => svc.fromDirectory(tmp)) - expect(project.worktree).toBe(tmp) - expect(sandbox).toBe(tmp) + const result = yield* project.fromDirectory(tmp) + expect(result.project.worktree).toBe(tmp) + expect(result.sandbox).toBe(tmp) }), ) }) @@ -294,18 +287,20 @@ describe("Project.fromDirectory git failure paths", () => { describe("Project.fromDirectory with worktrees", () => { it.live("should set worktree to root when called from root", () => Effect.gen(function* () { + const project = yield* Project.Service const tmp = yield* tmpdirScoped({ git: true }) - const { project, sandbox } = yield* run((svc) => svc.fromDirectory(tmp)) + const result = yield* project.fromDirectory(tmp) - expect(project.worktree).toBe(tmp) - expect(sandbox).toBe(tmp) - expect(project.sandboxes).not.toContain(tmp) + expect(result.project.worktree).toBe(tmp) + expect(result.sandbox).toBe(tmp) + expect(result.project.sandboxes).not.toContain(tmp) }), ) it.live("tracks a linked worktree as the opened project directory", () => Effect.gen(function* () { + const project = yield* Project.Service const tmp = yield* tmpdirScoped({ git: true }) const worktreePath = path.join(tmp, "..", path.basename(tmp) + "-worktree") @@ -319,20 +314,21 @@ describe("Project.fromDirectory with worktrees", () => { ) yield* Effect.promise(() => $`git worktree add ${worktreePath} -b test-branch-${Date.now()}`.cwd(tmp).quiet()) - const { project, sandbox } = yield* run((svc) => svc.fromDirectory(worktreePath)) + const result = yield* project.fromDirectory(worktreePath) - expect(project.worktree).toBe(worktreePath) - expect(sandbox).toBe(worktreePath) - expect(project.sandboxes).not.toContain(worktreePath) - expect(project.sandboxes).not.toContain(tmp) + expect(result.project.worktree).toBe(worktreePath) + expect(result.sandbox).toBe(worktreePath) + expect(result.project.sandboxes).not.toContain(worktreePath) + expect(result.project.sandboxes).not.toContain(tmp) }), ) it.live("worktree should share project ID with main repo", () => Effect.gen(function* () { + const project = yield* Project.Service const tmp = yield* tmpdirScoped({ git: true }) - const { project: main } = yield* run((svc) => svc.fromDirectory(tmp)) + const result = yield* project.fromDirectory(tmp) const worktreePath = path.join(tmp, "..", path.basename(tmp) + "-wt-shared") yield* Effect.addFinalizer(() => @@ -345,9 +341,9 @@ describe("Project.fromDirectory with worktrees", () => { ) yield* Effect.promise(() => $`git worktree add ${worktreePath} -b shared-${Date.now()}`.cwd(tmp).quiet()) - const { project: wt } = yield* run((svc) => svc.fromDirectory(worktreePath)) + const next = yield* project.fromDirectory(worktreePath) - expect(wt.id).toBe(main.id) + expect(next.project.id).toBe(result.project.id) const cache = path.join(tmp, ".git", "opencode") const exists = yield* Effect.promise(() => Bun.file(cache).exists()) @@ -357,6 +353,7 @@ describe("Project.fromDirectory with worktrees", () => { it.live("separate clones of the same repo should share project ID", () => Effect.gen(function* () { + const project = yield* Project.Service const tmp = yield* tmpdirScoped({ git: true }) // Create a bare remote, push, then clone into a second directory @@ -368,15 +365,16 @@ describe("Project.fromDirectory with worktrees", () => { yield* Effect.promise(() => $`git clone --bare ${tmp} ${bare}`.quiet()) yield* Effect.promise(() => $`git clone ${bare} ${clone}`.quiet()) - const { project: a } = yield* run((svc) => svc.fromDirectory(tmp)) - const { project: b } = yield* run((svc) => svc.fromDirectory(clone)) + const result = yield* project.fromDirectory(tmp) + const next = yield* project.fromDirectory(clone) - expect(b.id).toBe(a.id) + expect(next.project.id).toBe(result.project.id) }), ) it.live("should accumulate multiple worktrees in sandboxes", () => Effect.gen(function* () { + const project = yield* Project.Service const tmp = yield* tmpdirScoped({ git: true }) const worktree1 = path.join(tmp, "..", path.basename(tmp) + "-wt1") @@ -400,12 +398,12 @@ describe("Project.fromDirectory with worktrees", () => { yield* Effect.promise(() => $`git worktree add ${worktree1} -b branch-${Date.now()}`.cwd(tmp).quiet()) yield* Effect.promise(() => $`git worktree add ${worktree2} -b branch-${Date.now() + 1}`.cwd(tmp).quiet()) - yield* run((svc) => svc.fromDirectory(worktree1)) - const { project } = yield* run((svc) => svc.fromDirectory(worktree2)) + yield* project.fromDirectory(worktree1) + const result = yield* project.fromDirectory(worktree2) - expect(project.worktree).toBe(worktree1) - expect(project.sandboxes).toContain(worktree2) - expect(project.sandboxes).not.toContain(tmp) + expect(result.project.worktree).toBe(worktree1) + expect(result.project.sandboxes).toContain(worktree2) + expect(result.project.sandboxes).not.toContain(tmp) }), ) }) @@ -413,12 +411,13 @@ describe("Project.fromDirectory with worktrees", () => { describe("Project.discover", () => { iconDiscoveryIt.live("discovers favicon from fromDirectory when enabled", () => Effect.gen(function* () { + const project = yield* Project.Service const tmp = yield* tmpdirScoped({ git: true }) const pngData = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) yield* Effect.promise(() => Bun.write(path.join(tmp, "favicon.png"), pngData)) - const { project } = yield* run((svc) => svc.fromDirectory(tmp)) - const updated = yield* waitForProjectIcon(project.id) + const result = yield* project.fromDirectory(tmp) + const updated = yield* waitForProjectIcon(result.project.id) expect(updated.icon?.url).toStartWith("data:") expect(updated.icon?.url).toContain("base64") @@ -427,15 +426,16 @@ describe("Project.discover", () => { it.live("should discover favicon.png in root", () => Effect.gen(function* () { + const project = yield* Project.Service const tmp = yield* tmpdirScoped({ git: true }) - const { project } = yield* run((svc) => svc.fromDirectory(tmp)) + const result = yield* project.fromDirectory(tmp) const pngData = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) yield* Effect.promise(() => Bun.write(path.join(tmp, "favicon.png"), pngData)) - yield* run((svc) => svc.discover(project)) + yield* project.discover(result.project) - const updated = Project.get(project.id) + const updated = yield* project.get(result.project.id) expect(updated).toBeDefined() expect(updated!.icon).toBeDefined() expect(updated!.icon?.url).toStartWith("data:") @@ -446,14 +446,15 @@ describe("Project.discover", () => { it.live("should not discover non-image files", () => Effect.gen(function* () { + const project = yield* Project.Service const tmp = yield* tmpdirScoped({ git: true }) - const { project } = yield* run((svc) => svc.fromDirectory(tmp)) + const result = yield* project.fromDirectory(tmp) yield* Effect.promise(() => Bun.write(path.join(tmp, "favicon.txt"), "not an image")) - yield* run((svc) => svc.discover(project)) + yield* project.discover(result.project) - const updated = Project.get(project.id) + const updated = yield* project.get(result.project.id) expect(updated).toBeDefined() expect(updated!.icon).toBeUndefined() }), @@ -461,25 +462,24 @@ describe("Project.discover", () => { it.live("should not discover favicon when override is set", () => Effect.gen(function* () { + const project = yield* Project.Service const tmp = yield* tmpdirScoped({ git: true }) - const { project } = yield* run((svc) => svc.fromDirectory(tmp)) + const result = yield* project.fromDirectory(tmp) - yield* run((svc) => - svc.update({ - projectID: project.id, - icon: { override: "data:image/png;base64,override" }, - }), - ) + yield* project.update({ + projectID: result.project.id, + icon: { override: "data:image/png;base64,override" }, + }) - const updatedProject = yield* run((svc) => svc.get(project.id)) + const updatedProject = yield* project.get(result.project.id) if (!updatedProject) throw new Error("Project not found") const pngData = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) yield* Effect.promise(() => Bun.write(path.join(tmp, "favicon.png"), pngData)) - yield* run((svc) => svc.discover(updatedProject)) + yield* project.discover(updatedProject) - const updated = Project.get(project.id) + const updated = yield* project.get(result.project.id) expect(updated).toBeDefined() expect(updated!.icon?.override).toBe("data:image/png;base64,override") expect(updated!.icon?.url).toBeUndefined() @@ -490,107 +490,100 @@ describe("Project.discover", () => { describe("Project.update", () => { it.live("should update name", () => Effect.gen(function* () { + const project = yield* Project.Service const tmp = yield* tmpdirScoped({ git: true }) - const { project } = yield* run((svc) => svc.fromDirectory(tmp)) + const result = yield* project.fromDirectory(tmp) - const updated = yield* run((svc) => - svc.update({ - projectID: project.id, - name: "New Project Name", - }), - ) + const updated = yield* project.update({ + projectID: result.project.id, + name: "New Project Name", + }) expect(updated.name).toBe("New Project Name") - const fromDb = Project.get(project.id) + const fromDb = yield* project.get(result.project.id) expect(fromDb?.name).toBe("New Project Name") }), ) it.live("should update icon url", () => Effect.gen(function* () { + const project = yield* Project.Service const tmp = yield* tmpdirScoped({ git: true }) - const { project } = yield* run((svc) => svc.fromDirectory(tmp)) + const result = yield* project.fromDirectory(tmp) - const updated = yield* run((svc) => - svc.update({ - projectID: project.id, - icon: { url: "https://example.com/icon.png" }, - }), - ) + const updated = yield* project.update({ + projectID: result.project.id, + icon: { url: "https://example.com/icon.png" }, + }) expect(updated.icon?.url).toBe("https://example.com/icon.png") - const fromDb = Project.get(project.id) + const fromDb = yield* project.get(result.project.id) expect(fromDb?.icon?.url).toBe("https://example.com/icon.png") }), ) it.live("should update icon color", () => Effect.gen(function* () { + const project = yield* Project.Service const tmp = yield* tmpdirScoped({ git: true }) - const { project } = yield* run((svc) => svc.fromDirectory(tmp)) + const result = yield* project.fromDirectory(tmp) - const updated = yield* run((svc) => - svc.update({ - projectID: project.id, - icon: { color: "#ff0000" }, - }), - ) + const updated = yield* project.update({ + projectID: result.project.id, + icon: { color: "#ff0000" }, + }) expect(updated.icon?.color).toBe("#ff0000") - const fromDb = Project.get(project.id) + const fromDb = yield* project.get(result.project.id) expect(fromDb?.icon?.color).toBe("#ff0000") }), ) it.live("should update icon override", () => Effect.gen(function* () { + const project = yield* Project.Service const tmp = yield* tmpdirScoped({ git: true }) - const { project } = yield* run((svc) => svc.fromDirectory(tmp)) + const result = yield* project.fromDirectory(tmp) - const updated = yield* run((svc) => - svc.update({ - projectID: project.id, - icon: { override: "data:image/png;base64,abc123" }, - }), - ) + const updated = yield* project.update({ + projectID: result.project.id, + icon: { override: "data:image/png;base64,abc123" }, + }) expect(updated.icon?.override).toBe("data:image/png;base64,abc123") - const fromDb = Project.get(project.id) + const fromDb = yield* project.get(result.project.id) expect(fromDb?.icon?.override).toBe("data:image/png;base64,abc123") }), ) it.live("should update commands", () => Effect.gen(function* () { + const project = yield* Project.Service const tmp = yield* tmpdirScoped({ git: true }) - const { project } = yield* run((svc) => svc.fromDirectory(tmp)) + const result = yield* project.fromDirectory(tmp) - const updated = yield* run((svc) => - svc.update({ - projectID: project.id, - commands: { start: "npm run dev" }, - }), - ) + const updated = yield* project.update({ + projectID: result.project.id, + commands: { start: "npm run dev" }, + }) expect(updated.commands?.start).toBe("npm run dev") - const fromDb = Project.get(project.id) + const fromDb = yield* project.get(result.project.id) expect(fromDb?.commands?.start).toBe("npm run dev") }), ) it.live("should fail when project not found", () => Effect.gen(function* () { - const exit = yield* run((svc) => - svc.update({ - projectID: ProjectID.make("nonexistent-project-id"), - name: "Should Fail", - }), - ).pipe(Effect.exit) + const project = yield* Project.Service + const exit = yield* project + .update({ projectID: ProjectV2.ID.make("nonexistent-project-id"), name: "Should Fail" }) + .pipe(Effect.exit) expect(Exit.isFailure(exit)).toBe(true) if (Exit.isFailure(exit)) { const error = Cause.squash(exit.cause) @@ -601,8 +594,9 @@ describe("Project.update", () => { it.live("should emit GlobalBus event on update", () => Effect.gen(function* () { + const project = yield* Project.Service const tmp = yield* tmpdirScoped({ git: true }) - const { project } = yield* run((svc) => svc.fromDirectory(tmp)) + const result = yield* project.fromDirectory(tmp) let eventPayload: any = null const on = (data: any) => { @@ -611,7 +605,7 @@ describe("Project.update", () => { GlobalBus.on("event", on) yield* Effect.addFinalizer(() => Effect.sync(() => GlobalBus.off("event", on))) - yield* run((svc) => svc.update({ projectID: project.id, name: "Updated Name" })) + yield* project.update({ projectID: result.project.id, name: "Updated Name" }) expect(eventPayload).not.toBeNull() expect(eventPayload.payload.type).toBe("project.updated") @@ -621,17 +615,16 @@ describe("Project.update", () => { it.live("should update multiple fields at once", () => Effect.gen(function* () { + const project = yield* Project.Service const tmp = yield* tmpdirScoped({ git: true }) - const { project } = yield* run((svc) => svc.fromDirectory(tmp)) + const result = yield* project.fromDirectory(tmp) - const updated = yield* run((svc) => - svc.update({ - projectID: project.id, - name: "Multi Update", - icon: { url: "https://example.com/favicon.ico", override: "data:image/png;base64,abc123", color: "#00ff00" }, - commands: { start: "make start" }, - }), - ) + const updated = yield* project.update({ + projectID: result.project.id, + name: "Multi Update", + icon: { url: "https://example.com/favicon.ico", override: "data:image/png;base64,abc123", color: "#00ff00" }, + commands: { start: "make start" }, + }) expect(updated.name).toBe("Multi Update") expect(updated.icon?.url).toBe("https://example.com/favicon.ico") @@ -645,43 +638,49 @@ describe("Project.update", () => { describe("Project.list and Project.get", () => { it.live("list returns all projects", () => Effect.gen(function* () { + const project = yield* Project.Service const tmp = yield* tmpdirScoped({ git: true }) - const { project } = yield* run((svc) => svc.fromDirectory(tmp)) + const result = yield* project.fromDirectory(tmp) - const all = Project.list() + const all = yield* project.list() expect(all.length).toBeGreaterThan(0) - expect(all.find((p) => p.id === project.id)).toBeDefined() + expect(all.find((p) => p.id === result.project.id)).toBeDefined() }), ) it.live("get returns project by id", () => Effect.gen(function* () { + const project = yield* Project.Service const tmp = yield* tmpdirScoped({ git: true }) - const { project } = yield* run((svc) => svc.fromDirectory(tmp)) + const result = yield* project.fromDirectory(tmp) - const found = Project.get(project.id) + const found = yield* project.get(result.project.id) expect(found).toBeDefined() - expect(found!.id).toBe(project.id) + expect(found!.id).toBe(result.project.id) }), ) - test("get returns undefined for unknown id", () => { - const found = Project.get(ProjectID.make("nonexistent")) - expect(found).toBeUndefined() - }) + it.live("get returns undefined for unknown id", () => + Effect.gen(function* () { + const project = yield* Project.Service + const found = yield* project.get(ProjectV2.ID.make("nonexistent")) + expect(found).toBeUndefined() + }), + ) }) describe("Project.setInitialized", () => { it.live("sets time_initialized on project", () => Effect.gen(function* () { + const project = yield* Project.Service const tmp = yield* tmpdirScoped({ git: true }) - const { project } = yield* run((svc) => svc.fromDirectory(tmp)) + const result = yield* project.fromDirectory(tmp) - expect(project.time.initialized).toBeUndefined() + expect(result.project.time.initialized).toBeUndefined() - Project.setInitialized(project.id) + yield* project.setInitialized(result.project.id) - const updated = Project.get(project.id) + const updated = yield* project.get(result.project.id) expect(updated?.time.initialized).toBeDefined() }), ) @@ -690,26 +689,28 @@ describe("Project.setInitialized", () => { describe("Project.addSandbox and Project.removeSandbox", () => { it.live("addSandbox adds directory and removeSandbox removes it", () => Effect.gen(function* () { + const project = yield* Project.Service const tmp = yield* tmpdirScoped({ git: true }) - const { project } = yield* run((svc) => svc.fromDirectory(tmp)) + const result = yield* project.fromDirectory(tmp) const sandboxDir = path.join(tmp, "sandbox-test") - yield* run((svc) => svc.addSandbox(project.id, sandboxDir)) + yield* project.addSandbox(result.project.id, sandboxDir) - let found = Project.get(project.id) + let found = yield* project.get(result.project.id) expect(found?.sandboxes).toContain(sandboxDir) - yield* run((svc) => svc.removeSandbox(project.id, sandboxDir)) + yield* project.removeSandbox(result.project.id, sandboxDir) - found = Project.get(project.id) + found = yield* project.get(result.project.id) expect(found?.sandboxes).not.toContain(sandboxDir) }), ) it.live("addSandbox emits GlobalBus event", () => Effect.gen(function* () { + const project = yield* Project.Service const tmp = yield* tmpdirScoped({ git: true }) - const { project } = yield* run((svc) => svc.fromDirectory(tmp)) + const result = yield* project.fromDirectory(tmp) const sandboxDir = path.join(tmp, "sandbox-event") const events: any[] = [] @@ -717,7 +718,7 @@ describe("Project.addSandbox and Project.removeSandbox", () => { GlobalBus.on("event", on) yield* Effect.addFinalizer(() => Effect.sync(() => GlobalBus.off("event", on))) - yield* run((svc) => svc.addSandbox(project.id, sandboxDir)) + yield* project.addSandbox(result.project.id, sandboxDir) expect(events.some((e) => e.payload.type === Project.Event.Updated.type)).toBe(true) }), @@ -727,6 +728,7 @@ describe("Project.addSandbox and Project.removeSandbox", () => { describe("Project.fromDirectory with bare repos", () => { it.live("worktree from bare repo should cache in bare repo, not parent", () => Effect.gen(function* () { + const project = yield* Project.Service const tmp = yield* tmpdirScoped({ git: true }) const parentDir = path.dirname(tmp) @@ -739,10 +741,10 @@ describe("Project.fromDirectory with bare repos", () => { yield* Effect.promise(() => $`git clone --bare ${tmp} ${barePath}`.quiet()) yield* Effect.promise(() => $`git worktree add ${worktreePath} HEAD`.cwd(barePath).quiet()) - const { project } = yield* run((svc) => svc.fromDirectory(worktreePath)) + const result = yield* project.fromDirectory(worktreePath) - expect(project.id).not.toBe(ProjectID.global) - expect(project.worktree).toBe(worktreePath) + expect(result.project.id).not.toBe(ProjectV2.ID.global) + expect(result.project.worktree).toBe(worktreePath) const correctCache = path.join(barePath, "opencode") const wrongCache = path.join(parentDir, ".git", "opencode") @@ -754,6 +756,7 @@ describe("Project.fromDirectory with bare repos", () => { it.live("different bare repos under same parent should not share project ID", () => Effect.gen(function* () { + const project = yield* Project.Service const tmp1 = yield* tmpdirScoped({ git: true }) const tmp2 = yield* tmpdirScoped({ git: true }) @@ -773,10 +776,10 @@ describe("Project.fromDirectory with bare repos", () => { yield* Effect.promise(() => $`git worktree add ${worktreeA} HEAD`.cwd(bareA).quiet()) yield* Effect.promise(() => $`git worktree add ${worktreeB} HEAD`.cwd(bareB).quiet()) - const { project: projA } = yield* run((svc) => svc.fromDirectory(worktreeA)) - const { project: projB } = yield* run((svc) => svc.fromDirectory(worktreeB)) + const result = yield* project.fromDirectory(worktreeA) + const next = yield* project.fromDirectory(worktreeB) - expect(projA.id).not.toBe(projB.id) + expect(result.project.id).not.toBe(next.project.id) const cacheA = path.join(bareA, "opencode") const cacheB = path.join(bareB, "opencode") @@ -790,6 +793,7 @@ describe("Project.fromDirectory with bare repos", () => { it.live("bare repo without .git suffix is still detected via core.bare", () => Effect.gen(function* () { + const project = yield* Project.Service const tmp = yield* tmpdirScoped({ git: true }) const parentDir = path.dirname(tmp) @@ -802,10 +806,10 @@ describe("Project.fromDirectory with bare repos", () => { yield* Effect.promise(() => $`git clone --bare ${tmp} ${barePath}`.quiet()) yield* Effect.promise(() => $`git worktree add ${worktreePath} HEAD`.cwd(barePath).quiet()) - const { project } = yield* run((svc) => svc.fromDirectory(worktreePath)) + const result = yield* project.fromDirectory(worktreePath) - expect(project.id).not.toBe(ProjectID.global) - expect(project.worktree).toBe(worktreePath) + expect(result.project.id).not.toBe(ProjectV2.ID.global) + expect(result.project.worktree).toBe(worktreePath) const correctCache = path.join(barePath, "opencode") expect(yield* Effect.promise(() => Bun.file(correctCache).exists())).toBe(true) diff --git a/packages/opencode/test/project/vcs.test.ts b/packages/opencode/test/project/vcs.test.ts index b1d637302d..be8d22331e 100644 --- a/packages/opencode/test/project/vcs.test.ts +++ b/packages/opencode/test/project/vcs.test.ts @@ -5,8 +5,8 @@ import { Deferred, Effect, Layer } from "effect" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import fs from "fs/promises" import path from "path" -import { disposeAllInstances, provideInstance, TestInstance, tmpdirScoped } from "../fixture/fixture" -import { Bus } from "../../src/bus" +import { disposeAllInstances, provideInstance, testInstanceStoreLayer, TestInstance, tmpdirScoped } from "../fixture/fixture" +import { EventV2Bridge } from "../../src/event-v2-bridge" import { FileWatcher } from "../../src/file/watcher" import { Git } from "../../src/git" import { Vcs } from "@/project/vcs" @@ -19,11 +19,12 @@ import { testEffect } from "../lib/effect" const weird = process.platform === "win32" ? "space file.txt" : "tab\tfile.txt" const layer = Layer.mergeAll( - Vcs.layer.pipe(Layer.provideMerge(Git.defaultLayer), Layer.provideMerge(Bus.layer)), + Vcs.layer.pipe(Layer.provideMerge(Git.defaultLayer), Layer.provideMerge(EventV2Bridge.defaultLayer)), CrossSpawnSpawner.defaultLayer, AppFileSystem.defaultLayer, ) const it = testEffect(layer) +const worktreeIt = testEffect(Layer.mergeAll(layer, testInstanceStoreLayer)) const git = Effect.fn("VcsTest.git")(function* (cwd: string, args: string[]) { const result = yield* Git.Service.use((git) => git.run(args, { cwd })) @@ -47,13 +48,15 @@ const init = Effect.fn("VcsTest.init")(function* () { }) const nextBranchUpdate = Effect.fn("VcsTest.nextBranchUpdate")(function* () { - const bus = yield* Bus.Service + const events = yield* EventV2Bridge.Service const updated = yield* Deferred.make() - const off = yield* bus.subscribeCallback(Vcs.Event.BranchUpdated, (evt) => { - Effect.runSync(Deferred.succeed(updated, evt.properties.branch)) + const off = yield* events.listen((event) => { + if (event.type === Vcs.Event.BranchUpdated.type) + Deferred.doneUnsafe(updated, Effect.succeed((event.data as typeof Vcs.Event.BranchUpdated.data.Type).branch)) + return Effect.void }) - yield* Effect.addFinalizer(() => Effect.sync(off)) + yield* Effect.addFinalizer(() => off) return updated }) @@ -62,9 +65,9 @@ const publishHeadChangeUntil = Effect.fn("VcsTest.publishHeadChangeUntil")(funct pending: Deferred.Deferred, head: string, ) { - const bus = yield* Bus.Service + const events = yield* EventV2Bridge.Service for (let i = 0; i < 50; i++) { - yield* bus.publish(FileWatcher.Event.Updated, { file: head, event: "change" }) + yield* events.publish(FileWatcher.Event.Updated, { file: head, event: "change" }) if (yield* Deferred.isDone(pending)) return yield* Effect.sleep("10 millis") } @@ -183,7 +186,7 @@ describe("Vcs diff", () => { { git: true }, ) - it.live("detects current branch from the active worktree", () => + worktreeIt.live("detects current branch from the active worktree", () => Effect.gen(function* () { const tmp = yield* tmpdirScoped({ git: true }) const wt = yield* tmpdirScoped() diff --git a/packages/opencode/test/project/worktree-remove.test.ts b/packages/opencode/test/project/worktree-remove.test.ts index fa70ecb893..230ade33a8 100644 --- a/packages/opencode/test/project/worktree-remove.test.ts +++ b/packages/opencode/test/project/worktree-remove.test.ts @@ -5,17 +5,18 @@ import path from "path" import { Effect, Layer } from "effect" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Worktree } from "../../src/worktree" -import { provideTmpdirInstance } from "../fixture/fixture" +import { TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" const it = testEffect(Layer.mergeAll(Worktree.defaultLayer, CrossSpawnSpawner.defaultLayer)) -const wintest = process.platform === "win32" ? it.live : it.live.skip +const wintest = process.platform === "win32" ? it.instance : it.instance.skip describe("Worktree.remove", () => { - it.live("continues when git remove exits non-zero after detaching", () => - provideTmpdirInstance( - (root) => - Effect.gen(function* () { + it.instance( + "continues when git remove exits non-zero after detaching", + () => + Effect.gen(function* () { + const root = (yield* TestInstance).directory const svc = yield* Worktree.Service const name = `remove-regression-${Date.now().toString(36)}` const branch = `opencode/${name}` @@ -79,15 +80,15 @@ describe("Worktree.remove", () => { $`git show-ref --verify --quiet refs/heads/${branch}`.cwd(root).quiet().nothrow(), ) expect(ref.exitCode).not.toBe(0) - }), - { git: true }, - ), + }), + { git: true }, ) - wintest("stops fsmonitor before removing a worktree", () => - provideTmpdirInstance( - (root) => - Effect.gen(function* () { + wintest( + "stops fsmonitor before removing a worktree", + () => + Effect.gen(function* () { + const root = (yield* TestInstance).directory const svc = yield* Worktree.Service const name = `remove-fsmonitor-${Date.now().toString(36)}` const branch = `opencode/${name}` @@ -119,8 +120,7 @@ describe("Worktree.remove", () => { $`git show-ref --verify --quiet refs/heads/${branch}`.cwd(root).quiet().nothrow(), ) expect(ref.exitCode).not.toBe(0) - }), - { git: true }, - ), + }), + { git: true }, ) }) diff --git a/packages/opencode/test/project/worktree.test.ts b/packages/opencode/test/project/worktree.test.ts index fedf98371e..3da2d02ea0 100644 --- a/packages/opencode/test/project/worktree.test.ts +++ b/packages/opencode/test/project/worktree.test.ts @@ -5,8 +5,6 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Cause, Deferred, Effect, Exit, Fiber, Layer } from "effect" import { GlobalBus, type GlobalEvent } from "../../src/bus/global" import { Git } from "../../src/git" -import { InstanceRef } from "../../src/effect/instance-ref" -import { InstanceRuntime } from "../../src/project/instance-runtime" import { Worktree } from "../../src/worktree" import { disposeAllInstances, provideInstance, TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" @@ -41,11 +39,6 @@ const waitReady = Effect.fn("WorktreeTest.waitReady")(function* () { const removeCreatedWorktree = (directory: string) => Effect.gen(function* () { const svc = yield* Worktree.Service - const ctx = yield* Effect.gen(function* () { - return yield* InstanceRef - }).pipe(provideInstance(directory)) - if (!ctx) return yield* Effect.die(new Error("missing test instance")) - yield* Effect.promise(() => InstanceRuntime.disposeInstance(ctx)) const ok = yield* svc.remove({ directory }) if (!ok) return yield* Effect.fail(new Error(`failed to remove worktree ${directory}`)) }) diff --git a/packages/opencode/test/provider/amazon-bedrock.test.ts b/packages/opencode/test/provider/amazon-bedrock.test.ts index 763b724b63..084d28dd44 100644 --- a/packages/opencode/test/provider/amazon-bedrock.test.ts +++ b/packages/opencode/test/provider/amazon-bedrock.test.ts @@ -6,9 +6,10 @@ import { Global } from "@opencode-ai/core/global" import { Filesystem } from "@/util/filesystem" import { Env } from "../../src/env" import { Provider } from "@/provider/provider" -import { ProviderID } from "../../src/provider/schema" + import { disposeAllInstances } from "../fixture/fixture" import { testEffect } from "../lib/effect" +import { ProviderV2 } from "@opencode-ai/core/provider" const it = testEffect(Layer.mergeAll(Provider.defaultLayer, Env.defaultLayer)) @@ -62,8 +63,8 @@ it.instance( yield* set("AWS_REGION", "us-east-1") yield* set("AWS_PROFILE", "default") const providers = yield* list - expect(providers[ProviderID.amazonBedrock]).toBeDefined() - expect(providers[ProviderID.amazonBedrock].options?.region).toBe("eu-west-1") + expect(providers[ProviderV2.ID.amazonBedrock]).toBeDefined() + expect(providers[ProviderV2.ID.amazonBedrock].options?.region).toBe("eu-west-1") }), { config: { provider: { "amazon-bedrock": { options: { region: "eu-west-1" } } } } }, ) @@ -73,8 +74,8 @@ it.instance("Bedrock: falls back to AWS_REGION env var when no config region", ( yield* set("AWS_REGION", "eu-west-1") yield* set("AWS_PROFILE", "default") const providers = yield* list - expect(providers[ProviderID.amazonBedrock]).toBeDefined() - expect(providers[ProviderID.amazonBedrock].options?.region).toBe("eu-west-1") + expect(providers[ProviderV2.ID.amazonBedrock]).toBeDefined() + expect(providers[ProviderV2.ID.amazonBedrock].options?.region).toBe("eu-west-1") }), ) @@ -87,8 +88,8 @@ it.instance( yield* set("AWS_ACCESS_KEY_ID", "") yield* set("AWS_BEARER_TOKEN_BEDROCK", "") const providers = yield* list - expect(providers[ProviderID.amazonBedrock]).toBeDefined() - expect(providers[ProviderID.amazonBedrock].options?.region).toBe("eu-west-1") + expect(providers[ProviderV2.ID.amazonBedrock]).toBeDefined() + expect(providers[ProviderV2.ID.amazonBedrock].options?.region).toBe("eu-west-1") }), { config: { provider: { "amazon-bedrock": { options: { region: "eu-west-1" } } } } }, ) @@ -100,8 +101,8 @@ it.instance( yield* set("AWS_PROFILE", "default") yield* set("AWS_ACCESS_KEY_ID", "test-key-id") const providers = yield* list - expect(providers[ProviderID.amazonBedrock]).toBeDefined() - expect(providers[ProviderID.amazonBedrock].options?.region).toBe("us-east-1") + expect(providers[ProviderV2.ID.amazonBedrock]).toBeDefined() + expect(providers[ProviderV2.ID.amazonBedrock].options?.region).toBe("us-east-1") }), { config: { @@ -116,8 +117,8 @@ it.instance( Effect.gen(function* () { yield* set("AWS_PROFILE", "default") const providers = yield* list - expect(providers[ProviderID.amazonBedrock]).toBeDefined() - expect(providers[ProviderID.amazonBedrock].options?.endpoint).toBe( + expect(providers[ProviderV2.ID.amazonBedrock]).toBeDefined() + expect(providers[ProviderV2.ID.amazonBedrock].options?.endpoint).toBe( "https://bedrock-runtime.us-east-1.vpce-xxxxx.amazonaws.com", ) }), @@ -141,8 +142,8 @@ it.instance( yield* set("AWS_PROFILE", "") yield* set("AWS_ACCESS_KEY_ID", "") const providers = yield* list - expect(providers[ProviderID.amazonBedrock]).toBeDefined() - expect(providers[ProviderID.amazonBedrock].options?.region).toBe("us-east-1") + expect(providers[ProviderV2.ID.amazonBedrock]).toBeDefined() + expect(providers[ProviderV2.ID.amazonBedrock].options?.region).toBe("us-east-1") }), { config: { provider: { "amazon-bedrock": { options: { region: "us-east-1" } } } } }, ) @@ -157,8 +158,8 @@ it.instance( Effect.gen(function* () { yield* set("AWS_PROFILE", "default") const providers = yield* list - expect(providers[ProviderID.amazonBedrock]).toBeDefined() - expect(providers[ProviderID.amazonBedrock].models["us.anthropic.claude-opus-4-5-20251101-v1:0"]).toBeDefined() + expect(providers[ProviderV2.ID.amazonBedrock]).toBeDefined() + expect(providers[ProviderV2.ID.amazonBedrock].models["us.anthropic.claude-opus-4-5-20251101-v1:0"]).toBeDefined() }), { config: { @@ -178,8 +179,8 @@ it.instance( Effect.gen(function* () { yield* set("AWS_PROFILE", "default") const providers = yield* list - expect(providers[ProviderID.amazonBedrock]).toBeDefined() - expect(providers[ProviderID.amazonBedrock].models["global.anthropic.claude-opus-4-5-20251101-v1:0"]).toBeDefined() + expect(providers[ProviderV2.ID.amazonBedrock]).toBeDefined() + expect(providers[ProviderV2.ID.amazonBedrock].models["global.anthropic.claude-opus-4-5-20251101-v1:0"]).toBeDefined() }), { config: { @@ -199,8 +200,8 @@ it.instance( Effect.gen(function* () { yield* set("AWS_PROFILE", "default") const providers = yield* list - expect(providers[ProviderID.amazonBedrock]).toBeDefined() - expect(providers[ProviderID.amazonBedrock].models["eu.anthropic.claude-opus-4-5-20251101-v1:0"]).toBeDefined() + expect(providers[ProviderV2.ID.amazonBedrock]).toBeDefined() + expect(providers[ProviderV2.ID.amazonBedrock].models["eu.anthropic.claude-opus-4-5-20251101-v1:0"]).toBeDefined() }), { config: { @@ -220,8 +221,8 @@ it.instance( Effect.gen(function* () { yield* set("AWS_PROFILE", "default") const providers = yield* list - expect(providers[ProviderID.amazonBedrock]).toBeDefined() - expect(providers[ProviderID.amazonBedrock].models["anthropic.claude-opus-4-5-20251101-v1:0"]).toBeDefined() + expect(providers[ProviderV2.ID.amazonBedrock]).toBeDefined() + expect(providers[ProviderV2.ID.amazonBedrock].models["anthropic.claude-opus-4-5-20251101-v1:0"]).toBeDefined() }), { config: { diff --git a/packages/opencode/test/provider/cf-ai-gateway-e2e.test.ts b/packages/opencode/test/provider/cf-ai-gateway-e2e.test.ts index 0c692c50c8..cf18e842f5 100644 --- a/packages/opencode/test/provider/cf-ai-gateway-e2e.test.ts +++ b/packages/opencode/test/provider/cf-ai-gateway-e2e.test.ts @@ -13,7 +13,7 @@ import { createAiGateway } from "ai-gateway-provider" import { createUnified } from "ai-gateway-provider/providers/unified" import { ProviderTransform } from "@/provider/transform" import type * as Provider from "@/provider/provider" -import { ModelID, ProviderID } from "@/provider/schema" +import { ProviderV2 } from "@opencode-ai/core/provider" type Captured = { url: string; outerBody: unknown } type ProviderOptions = Record> @@ -56,8 +56,8 @@ afterEach(() => { }) const cfModel = (apiId: string, releaseDate = "2026-03-05"): Provider.Model => ({ - id: ModelID.make(`cloudflare-ai-gateway/${apiId}`), - providerID: ProviderID.make("cloudflare-ai-gateway"), + id: ProviderV2.ModelID.make(`cloudflare-ai-gateway/${apiId}`), + providerID: ProviderV2.ID.make("cloudflare-ai-gateway"), name: apiId, api: { id: apiId, url: "https://gateway.ai.cloudflare.com/v1/compat", npm: "ai-gateway-provider" }, capabilities: { diff --git a/packages/opencode/test/provider/digitalocean.test.ts b/packages/opencode/test/provider/digitalocean.test.ts index 665c792deb..59c3f8da7c 100644 --- a/packages/opencode/test/provider/digitalocean.test.ts +++ b/packages/opencode/test/provider/digitalocean.test.ts @@ -1,10 +1,11 @@ import { expect } from "bun:test" import { Provider } from "../../src/provider/provider" -import { ProviderID } from "../../src/provider/schema" + import { Effect } from "effect" import { testEffect } from "../lib/effect" +import { ProviderV2 } from "@opencode-ai/core/provider" -const DIGITALOCEAN = ProviderID.make("digitalocean") +const DIGITALOCEAN = ProviderV2.ID.make("digitalocean") const it = testEffect(Provider.defaultLayer) const withEnv = (values: Record, effect: Effect.Effect) => diff --git a/packages/opencode/test/provider/gitlab-duo.test.ts b/packages/opencode/test/provider/gitlab-duo.test.ts index 4ac62cf69d..563fa02555 100644 --- a/packages/opencode/test/provider/gitlab-duo.test.ts +++ b/packages/opencode/test/provider/gitlab-duo.test.ts @@ -6,7 +6,7 @@ export {} // import { test, expect, describe } from "bun:test" // import path from "path" -// import { ProviderID, ModelID } from "../../src/provider/schema" +// import { ProviderV2 } from "@opencode-ai/core/provider" // import { tmpdir, withTestInstance } from "../fixture/fixture" // import { Provider } from "@/provider/provider" // import { Env } from "../../src/env" diff --git a/packages/opencode/test/provider/header-timeout.test.ts b/packages/opencode/test/provider/header-timeout.test.ts index 0763ed2010..e52d6885c2 100644 --- a/packages/opencode/test/provider/header-timeout.test.ts +++ b/packages/opencode/test/provider/header-timeout.test.ts @@ -3,6 +3,7 @@ import { createServer, type Server } from "node:http" import { streamText } from "ai" import { Effect, Layer } from "effect" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { ProviderV2 } from "@opencode-ai/core/provider" import { disposeAllInstances, provideTmpdirInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" import { testProviderConfig } from "../lib/test-provider" @@ -10,7 +11,6 @@ import { Env } from "@/env" import { Plugin } from "@/plugin" import { Provider } from "@/provider/provider" import { ProviderError } from "@/provider/error" -import { ModelID, ProviderID } from "@/provider/schema" afterEach(async () => { await disposeAllInstances() @@ -31,7 +31,7 @@ it.live("headerTimeout does not abort delayed SSE body after headers arrive", () () => Effect.gen(function* () { const provider = yield* Provider.Service - const model = yield* provider.getModel(ProviderID.make("test"), ModelID.make("test-model")) + const model = yield* provider.getModel(ProviderV2.ID.make("test"), ProviderV2.ModelID.make("test-model")) const result = streamText({ model: yield* provider.getLanguage(model), messages: [{ role: "user", content: "hello" }], @@ -55,7 +55,7 @@ it.live("chunkTimeout raises a response stream error when SSE body stalls", () = () => Effect.gen(function* () { const provider = yield* Provider.Service - const model = yield* provider.getModel(ProviderID.make("test"), ModelID.make("test-model")) + const model = yield* provider.getModel(ProviderV2.ID.make("test"), ProviderV2.ModelID.make("test-model")) const result = streamText({ model: yield* provider.getLanguage(model), onError() {}, @@ -89,7 +89,7 @@ it.live("headerTimeout aborts when response headers do not arrive", () => () => Effect.gen(function* () { const provider = yield* Provider.Service - const model = yield* provider.getModel(ProviderID.make("test"), ModelID.make("test-model")) + const model = yield* provider.getModel(ProviderV2.ID.make("test"), ProviderV2.ModelID.make("test-model")) const result = streamText({ model: yield* provider.getLanguage(model), onError() {}, @@ -121,7 +121,7 @@ it.live("headerTimeout is opt-in for non-OpenAI providers", () => () => Effect.gen(function* () { const provider = yield* Provider.Service - const model = yield* provider.getModel(ProviderID.make("test"), ModelID.make("test-model")) + const model = yield* provider.getModel(ProviderV2.ID.make("test"), ProviderV2.ModelID.make("test-model")) const result = streamText({ model: yield* provider.getLanguage(model), messages: [{ role: "user", content: "hello" }], @@ -142,7 +142,7 @@ it.live("OpenAI Codex headerTimeout default can be disabled by config", () => () => Effect.gen(function* () { const provider = yield* Provider.Service - const openai = yield* provider.getProvider(ProviderID.openai) + const openai = yield* provider.getProvider(ProviderV2.ID.openai) expect(openai.options.headerTimeout).toBe(false) }), { config: { provider: { openai: { options: { headerTimeout: false } } } } }, @@ -159,7 +159,7 @@ it.live("OpenAI API auth gets default headerTimeout", () => yield* provideTmpdirInstance(() => Effect.gen(function* () { const provider = yield* Provider.Service - const openai = yield* provider.getProvider(ProviderID.openai) + const openai = yield* provider.getProvider(ProviderV2.ID.openai) expect(openai.options.headerTimeout).toBe(10_000) }), ) diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index 1ea2c82424..c055476572 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -13,11 +13,12 @@ import { Config } from "@/config/config" import { Env } from "../../src/env" import { Plugin } from "../../src/plugin/index" import { Provider } from "@/provider/provider" -import { ProviderID, ModelID } from "../../src/provider/schema" + import { RuntimeFlags } from "@/effect/runtime-flags" import { Filesystem } from "@/util/filesystem" import { InstanceLayer } from "@/project/instance-layer" import { testEffect } from "../lib/effect" +import { ProviderV2 } from "@opencode-ai/core/provider" const originalEnv = new Map() @@ -68,7 +69,7 @@ const providerLayer = (flags: Partial = {}) => const list = Provider.use.list() const paid = (providers: Record }>) => { - const item = providers[ProviderID.make("opencode")] + const item = providers[ProviderV2.ID.make("opencode")] expect(item).toBeDefined() return Object.values(item.models).filter((model) => model.cost.input > 0).length } @@ -104,11 +105,11 @@ it.instance("provider loaded from env variable", () => Effect.gen(function* () { yield* setProcessEnv("ANTHROPIC_API_KEY", "test-api-key") const providers = yield* list - expect(providers[ProviderID.anthropic]).toBeDefined() + expect(providers[ProviderV2.ID.anthropic]).toBeDefined() // Provider should retain its connection source even if custom loaders // merge additional options. - expect(providers[ProviderID.anthropic].source).toBe("env") - expect(providers[ProviderID.anthropic].options.headers["anthropic-beta"]).toBeDefined() + expect(providers[ProviderV2.ID.anthropic].source).toBe("env") + expect(providers[ProviderV2.ID.anthropic].options.headers["anthropic-beta"]).toBeDefined() }), ) @@ -116,7 +117,7 @@ it.instance( "provider loaded from config with apiKey option", Effect.gen(function* () { const providers = yield* list - expect(providers[ProviderID.anthropic]).toBeDefined() + expect(providers[ProviderV2.ID.anthropic]).toBeDefined() }), { config: { provider: { anthropic: { options: { apiKey: "config-api-key" } } } } }, ) @@ -126,7 +127,7 @@ it.instance( Effect.gen(function* () { yield* setProcessEnv("ANTHROPIC_API_KEY", "test-api-key") const providers = yield* list - expect(providers[ProviderID.anthropic]).toBeUndefined() + expect(providers[ProviderV2.ID.anthropic]).toBeUndefined() }), { config: { disabled_providers: ["anthropic"] } }, ) @@ -137,8 +138,8 @@ it.instance( yield* setProcessEnv("ANTHROPIC_API_KEY", "test-api-key") yield* setProcessEnv("OPENAI_API_KEY", "test-openai-key") const providers = yield* list - expect(providers[ProviderID.anthropic]).toBeDefined() - expect(providers[ProviderID.openai]).toBeUndefined() + expect(providers[ProviderV2.ID.anthropic]).toBeDefined() + expect(providers[ProviderV2.ID.openai]).toBeUndefined() }), { config: { enabled_providers: ["anthropic"] } }, ) @@ -148,8 +149,8 @@ it.instance( Effect.gen(function* () { yield* setProcessEnv("ANTHROPIC_API_KEY", "test-api-key") const providers = yield* list - expect(providers[ProviderID.anthropic]).toBeDefined() - const models = Object.keys(providers[ProviderID.anthropic].models) + expect(providers[ProviderV2.ID.anthropic]).toBeDefined() + const models = Object.keys(providers[ProviderV2.ID.anthropic].models) expect(models).toContain("claude-sonnet-4-20250514") expect(models.length).toBe(1) }), @@ -161,8 +162,8 @@ it.instance( Effect.gen(function* () { yield* setProcessEnv("ANTHROPIC_API_KEY", "test-api-key") const providers = yield* list - expect(providers[ProviderID.anthropic]).toBeDefined() - const models = Object.keys(providers[ProviderID.anthropic].models) + expect(providers[ProviderV2.ID.anthropic]).toBeDefined() + const models = Object.keys(providers[ProviderV2.ID.anthropic].models) expect(models).not.toContain("claude-sonnet-4-20250514") }), { config: { provider: { anthropic: { blacklist: ["claude-sonnet-4-20250514"] } } } }, @@ -173,9 +174,9 @@ it.instance( Effect.gen(function* () { yield* setProcessEnv("ANTHROPIC_API_KEY", "test-api-key") const providers = yield* list - expect(providers[ProviderID.anthropic]).toBeDefined() - expect(providers[ProviderID.anthropic].models["my-alias"]).toBeDefined() - expect(providers[ProviderID.anthropic].models["my-alias"].name).toBe("My Custom Alias") + expect(providers[ProviderV2.ID.anthropic]).toBeDefined() + expect(providers[ProviderV2.ID.anthropic].models["my-alias"]).toBeDefined() + expect(providers[ProviderV2.ID.anthropic].models["my-alias"].name).toBe("My Custom Alias") }), { config: { @@ -190,9 +191,9 @@ it.instance( "custom provider with npm package", Effect.gen(function* () { const providers = yield* list - expect(providers[ProviderID.make("custom-provider")]).toBeDefined() - expect(providers[ProviderID.make("custom-provider")].name).toBe("Custom Provider") - expect(providers[ProviderID.make("custom-provider")].models["custom-model"]).toBeDefined() + expect(providers[ProviderV2.ID.make("custom-provider")]).toBeDefined() + expect(providers[ProviderV2.ID.make("custom-provider")].name).toBe("Custom Provider") + expect(providers[ProviderV2.ID.make("custom-provider")].models["custom-model"]).toBeDefined() }), { config: { @@ -220,8 +221,8 @@ it.instance( "filters alpha provider models by default", Effect.gen(function* () { const providers = yield* list - expect(providers[ProviderID.make("custom-provider")].models["active-model"]).toBeDefined() - expect(providers[ProviderID.make("custom-provider")].models["alpha-model"]).toBeUndefined() + expect(providers[ProviderV2.ID.make("custom-provider")].models["active-model"]).toBeDefined() + expect(providers[ProviderV2.ID.make("custom-provider")].models["alpha-model"]).toBeUndefined() }), { config: alphaProviderConfig }, ) @@ -230,8 +231,8 @@ experimentalModels.instance( "includes alpha provider models when experimental models are enabled", Effect.gen(function* () { const providers = yield* list - expect(providers[ProviderID.make("custom-provider")].models["active-model"]).toBeDefined() - expect(providers[ProviderID.make("custom-provider")].models["alpha-model"]).toBeDefined() + expect(providers[ProviderV2.ID.make("custom-provider")].models["active-model"]).toBeDefined() + expect(providers[ProviderV2.ID.make("custom-provider")].models["alpha-model"]).toBeDefined() }), { config: alphaProviderConfig }, ) @@ -240,11 +241,11 @@ it.instance( "custom DeepSeek openai-compatible model defaults interleaved reasoning field", Effect.gen(function* () { const providers = yield* list - const provider = providers[ProviderID.make("custom-provider")] + const provider = providers[ProviderV2.ID.make("custom-provider")] expect(provider.models["deepseek-r1"].capabilities.interleaved).toEqual({ field: "reasoning_content" }) expect(provider.models["deepseek-details"].capabilities.interleaved).toEqual({ field: "reasoning_details" }) expect(provider.models["custom-model"].capabilities.interleaved).toBe(false) - expect(providers[ProviderID.make("custom-anthropic-provider")].models["deepseek-r1"].capabilities.interleaved).toBe( + expect(providers[ProviderV2.ID.make("custom-anthropic-provider")].models["deepseek-r1"].capabilities.interleaved).toBe( false, ) }), @@ -279,11 +280,11 @@ it.instance( Effect.gen(function* () { yield* setProcessEnv("ANTHROPIC_API_KEY", "env-api-key") const providers = yield* list - expect(providers[ProviderID.anthropic]).toBeDefined() + expect(providers[ProviderV2.ID.anthropic]).toBeDefined() // Config options should be merged - expect(providers[ProviderID.anthropic].options.timeout).toBe(60000) - expect(providers[ProviderID.anthropic].options.headerTimeout).toBe(10000) - expect(providers[ProviderID.anthropic].options.chunkTimeout).toBe(15000) + expect(providers[ProviderV2.ID.anthropic].options.timeout).toBe(60000) + expect(providers[ProviderV2.ID.anthropic].options.headerTimeout).toBe(10000) + expect(providers[ProviderV2.ID.anthropic].options.chunkTimeout).toBe(15000) }), { config: { provider: { anthropic: { options: { timeout: 60000, headerTimeout: 10000, chunkTimeout: 15000 } } } } }, ) @@ -292,7 +293,7 @@ it.instance("getModel returns model for valid provider/model", () => Effect.gen(function* () { yield* setProcessEnv("ANTHROPIC_API_KEY", "test-api-key") const provider = yield* Provider.Service - const model = yield* provider.getModel(ProviderID.anthropic, ModelID.make("claude-sonnet-4-20250514")) + const model = yield* provider.getModel(ProviderV2.ID.anthropic, ProviderV2.ModelID.make("claude-sonnet-4-20250514")) expect(model).toBeDefined() expect(String(model.providerID)).toBe("anthropic") expect(String(model.id)).toBe("claude-sonnet-4-20250514") @@ -304,7 +305,7 @@ it.instance("getModel returns model for valid provider/model", () => it.instance("getModel throws ModelNotFoundError for invalid model", () => Effect.gen(function* () { yield* set("ANTHROPIC_API_KEY", "test-api-key") - const exit = yield* Provider.use.getModel(ProviderID.anthropic, ModelID.make("nonexistent-model")).pipe(Effect.exit) + const exit = yield* Provider.use.getModel(ProviderV2.ID.anthropic, ProviderV2.ModelID.make("nonexistent-model")).pipe(Effect.exit) expect(exit._tag).toBe("Failure") }), ) @@ -312,7 +313,7 @@ it.instance("getModel throws ModelNotFoundError for invalid model", () => it.instance("getModel throws ModelNotFoundError for invalid provider", () => Effect.gen(function* () { const exit = yield* Provider.use - .getModel(ProviderID.make("nonexistent-provider"), ModelID.make("some-model")) + .getModel(ProviderV2.ID.make("nonexistent-provider"), ProviderV2.ModelID.make("some-model")) .pipe(Effect.exit) expect(exit._tag).toBe("Failure") }), @@ -366,8 +367,8 @@ it.instance( "provider with baseURL from config", Effect.gen(function* () { const providers = yield* list - expect(providers[ProviderID.make("custom-openai")]).toBeDefined() - expect(providers[ProviderID.make("custom-openai")].options.baseURL).toBe("https://custom.openai.com/v1") + expect(providers[ProviderV2.ID.make("custom-openai")]).toBeDefined() + expect(providers[ProviderV2.ID.make("custom-openai")].options.baseURL).toBe("https://custom.openai.com/v1") }), { config: { @@ -388,7 +389,7 @@ it.instance( "model cost defaults to zero when not specified", Effect.gen(function* () { const providers = yield* list - const model = providers[ProviderID.make("test-provider")].models["test-model"] + const model = providers[ProviderV2.ID.make("test-provider")].models["test-model"] expect(model.cost.input).toBe(0) expect(model.cost.output).toBe(0) expect(model.cost.cache.read).toBe(0) @@ -413,7 +414,7 @@ it.instance( "model options are merged from existing model", Effect.gen(function* () { const providers = yield* list - const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"] + const model = providers[ProviderV2.ID.anthropic].models["claude-sonnet-4-20250514"] expect(model.options.customOption).toBe("custom-value") }), { @@ -432,7 +433,7 @@ it.instance( "provider removed when all models filtered out", Effect.gen(function* () { const providers = yield* list - expect(providers[ProviderID.anthropic]).toBeUndefined() + expect(providers[ProviderV2.ID.anthropic]).toBeUndefined() }), { config: { provider: { anthropic: { options: { apiKey: "test-api-key" }, whitelist: ["nonexistent-model"] } } } }, ) @@ -440,7 +441,7 @@ it.instance( it.instance("closest finds model by partial match", () => Effect.gen(function* () { yield* set("ANTHROPIC_API_KEY", "test-api-key") - const result = yield* Provider.use.closest(ProviderID.anthropic, ["sonnet-4"]) + const result = yield* Provider.use.closest(ProviderV2.ID.anthropic, ["sonnet-4"]) expect(result).toBeDefined() expect(String(result?.providerID)).toBe("anthropic") expect(String(result?.modelID)).toContain("sonnet-4") @@ -449,7 +450,7 @@ it.instance("closest finds model by partial match", () => it.instance("closest returns undefined for nonexistent provider", () => Effect.gen(function* () { - const result = yield* Provider.use.closest(ProviderID.make("nonexistent"), ["model"]) + const result = yield* Provider.use.closest(ProviderV2.ID.make("nonexistent"), ["model"]) expect(result).toBeUndefined() }), ) @@ -459,9 +460,9 @@ it.instance( Effect.gen(function* () { yield* set("ANTHROPIC_API_KEY", "test-api-key") const providers = yield* list - expect(providers[ProviderID.anthropic].models["my-sonnet"]).toBeDefined() + expect(providers[ProviderV2.ID.anthropic].models["my-sonnet"]).toBeDefined() - const model = yield* Provider.use.getModel(ProviderID.anthropic, ModelID.make("my-sonnet")) + const model = yield* Provider.use.getModel(ProviderV2.ID.anthropic, ProviderV2.ModelID.make("my-sonnet")) expect(model).toBeDefined() expect(String(model.id)).toBe("my-sonnet") expect(model.name).toBe("My Sonnet Alias") @@ -482,7 +483,7 @@ it.instance( Effect.gen(function* () { const providers = yield* list // api field is stored on model.api.url, used by getSDK to set baseURL - expect(providers[ProviderID.make("custom-api")].models["model-1"].api.url).toBe("https://api.example.com/v1") + expect(providers[ProviderV2.ID.make("custom-api")].models["model-1"].api.url).toBe("https://api.example.com/v1") }), { config: { @@ -504,7 +505,7 @@ it.instance( "explicit baseURL overrides api field", Effect.gen(function* () { const providers = yield* list - expect(providers[ProviderID.make("custom-api")].options.baseURL).toBe("https://custom.override.com/v1") + expect(providers[ProviderV2.ID.make("custom-api")].options.baseURL).toBe("https://custom.override.com/v1") }), { config: { @@ -527,7 +528,7 @@ it.instance( Effect.gen(function* () { yield* set("ANTHROPIC_API_KEY", "test-api-key") const providers = yield* list - const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"] + const model = providers[ProviderV2.ID.anthropic].models["claude-sonnet-4-20250514"] expect(model.name).toBe("Custom Name for Sonnet") expect(model.capabilities.toolcall).toBe(true) expect(model.capabilities.attachment).toBe(true) @@ -545,7 +546,7 @@ it.instance( Effect.gen(function* () { yield* set("OPENAI_API_KEY", "test-openai-key") const providers = yield* list - expect(providers[ProviderID.openai]).toBeUndefined() + expect(providers[ProviderV2.ID.openai]).toBeUndefined() }), { config: { disabled_providers: ["openai"] } }, ) @@ -566,8 +567,8 @@ it.instance( Effect.gen(function* () { yield* set("ANTHROPIC_API_KEY", "test-api-key") const providers = yield* list - expect(providers[ProviderID.anthropic]).toBeDefined() - const models = Object.keys(providers[ProviderID.anthropic].models) + expect(providers[ProviderV2.ID.anthropic]).toBeDefined() + const models = Object.keys(providers[ProviderV2.ID.anthropic].models) expect(models).toContain("claude-sonnet-4-20250514") expect(models).not.toContain("claude-opus-4-20250514") expect(models.length).toBe(1) @@ -588,7 +589,7 @@ it.instance( "model modalities default correctly", Effect.gen(function* () { const providers = yield* list - const model = providers[ProviderID.make("test-provider")].models["test-model"] + const model = providers[ProviderV2.ID.make("test-provider")].models["test-model"] expect(model.capabilities.input.text).toBe(true) expect(model.capabilities.output.text).toBe(true) }), @@ -611,7 +612,7 @@ it.instance( "model with custom cost values", Effect.gen(function* () { const providers = yield* list - const model = providers[ProviderID.make("test-provider")].models["test-model"] + const model = providers[ProviderV2.ID.make("test-provider")].models["test-model"] expect(model.cost.input).toBe(5) expect(model.cost.output).toBe(15) expect(model.cost.cache.read).toBe(2.5) @@ -642,7 +643,7 @@ it.instance( it.instance("getSmallModel returns appropriate small model", () => Effect.gen(function* () { yield* set("ANTHROPIC_API_KEY", "test-api-key") - const model = yield* Provider.use.getSmallModel(ProviderID.anthropic) + const model = yield* Provider.use.getSmallModel(ProviderV2.ID.anthropic) expect(model).toBeDefined() expect(model?.id).toContain("haiku") }), @@ -652,7 +653,7 @@ it.instance( "getSmallModel respects config small_model override", Effect.gen(function* () { yield* set("ANTHROPIC_API_KEY", "test-api-key") - const model = yield* Provider.use.getSmallModel(ProviderID.anthropic) + const model = yield* Provider.use.getSmallModel(ProviderV2.ID.anthropic) expect(model).toBeDefined() expect(String(model?.providerID)).toBe("anthropic") expect(String(model?.id)).toBe("claude-sonnet-4-20250514") @@ -664,7 +665,7 @@ it.instance( "getSmallModel ignores invalid config small_model", Effect.gen(function* () { yield* set("ANTHROPIC_API_KEY", "test-api-key") - const model = yield* Provider.use.getSmallModel(ProviderID.anthropic) + const model = yield* Provider.use.getSmallModel(ProviderV2.ID.anthropic) expect(model).toBeUndefined() }), { config: { small_model: "anthropic/not-a-real-model" } }, @@ -691,10 +692,10 @@ it.instance( yield* set("ANTHROPIC_API_KEY", "test-anthropic-key") yield* set("OPENAI_API_KEY", "test-openai-key") const providers = yield* list - expect(providers[ProviderID.anthropic]).toBeDefined() - expect(providers[ProviderID.openai]).toBeDefined() - expect(providers[ProviderID.anthropic].options.timeout).toBe(30000) - expect(providers[ProviderID.openai].options.timeout).toBe(60000) + expect(providers[ProviderV2.ID.anthropic]).toBeDefined() + expect(providers[ProviderV2.ID.openai]).toBeDefined() + expect(providers[ProviderV2.ID.anthropic].options.timeout).toBe(30000) + expect(providers[ProviderV2.ID.openai].options.timeout).toBe(60000) }), { config: { @@ -710,9 +711,9 @@ it.instance( "provider with custom npm package", Effect.gen(function* () { const providers = yield* list - expect(providers[ProviderID.make("local-llm")]).toBeDefined() - expect(providers[ProviderID.make("local-llm")].models["llama-3"].api.npm).toBe("@ai-sdk/openai-compatible") - expect(providers[ProviderID.make("local-llm")].options.baseURL).toBe("http://localhost:11434/v1") + expect(providers[ProviderV2.ID.make("local-llm")]).toBeDefined() + expect(providers[ProviderV2.ID.make("local-llm")].models["llama-3"].api.npm).toBe("@ai-sdk/openai-compatible") + expect(providers[ProviderV2.ID.make("local-llm")].options.baseURL).toBe("http://localhost:11434/v1") }), { config: { @@ -736,7 +737,7 @@ it.instance( Effect.gen(function* () { yield* set("ANTHROPIC_API_KEY", "test-api-key") const providers = yield* list - expect(providers[ProviderID.anthropic].models["sonnet"].name).toBe("sonnet") + expect(providers[ProviderV2.ID.anthropic].models["sonnet"].name).toBe("sonnet") }), { config: { @@ -754,9 +755,9 @@ it.instance( Effect.gen(function* () { yield* set("MULTI_ENV_KEY_1", "test-key") const providers = yield* list - expect(providers[ProviderID.make("multi-env")]).toBeDefined() + expect(providers[ProviderV2.ID.make("multi-env")]).toBeDefined() // When multiple env options exist, key should NOT be auto-set - expect(providers[ProviderID.make("multi-env")].key).toBeUndefined() + expect(providers[ProviderV2.ID.make("multi-env")].key).toBeUndefined() }), { config: { @@ -778,9 +779,9 @@ it.instance( Effect.gen(function* () { yield* set("SINGLE_ENV_KEY", "my-api-key") const providers = yield* list - expect(providers[ProviderID.make("single-env")]).toBeDefined() + expect(providers[ProviderV2.ID.make("single-env")]).toBeDefined() // Single env option should auto-set key - expect(providers[ProviderID.make("single-env")].key).toBe("my-api-key") + expect(providers[ProviderV2.ID.make("single-env")].key).toBe("my-api-key") }), { config: { @@ -802,7 +803,7 @@ it.instance( Effect.gen(function* () { yield* set("ANTHROPIC_API_KEY", "test-api-key") const providers = yield* list - const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"] + const model = providers[ProviderV2.ID.anthropic].models["claude-sonnet-4-20250514"] expect(model.cost.input).toBe(999) expect(model.cost.output).toBe(888) }), @@ -821,9 +822,9 @@ it.instance( "completely new provider not in database can be configured", Effect.gen(function* () { const providers = yield* list - expect(providers[ProviderID.make("brand-new-provider")]).toBeDefined() - expect(providers[ProviderID.make("brand-new-provider")].name).toBe("Brand New") - const model = providers[ProviderID.make("brand-new-provider")].models["new-model"] + expect(providers[ProviderV2.ID.make("brand-new-provider")]).toBeDefined() + expect(providers[ProviderV2.ID.make("brand-new-provider")].name).toBe("Brand New") + const model = providers[ProviderV2.ID.make("brand-new-provider")].models["new-model"] expect(model.capabilities.reasoning).toBe(true) expect(model.capabilities.attachment).toBe(true) expect(model.capabilities.input.image).toBe(true) @@ -862,11 +863,11 @@ it.instance( yield* set("GOOGLE_GENERATIVE_AI_API_KEY", "test-google") const providers = yield* list // anthropic: in enabled, not in disabled = allowed - expect(providers[ProviderID.anthropic]).toBeDefined() + expect(providers[ProviderV2.ID.anthropic]).toBeDefined() // openai: in enabled, but also in disabled = NOT allowed - expect(providers[ProviderID.openai]).toBeUndefined() + expect(providers[ProviderV2.ID.openai]).toBeUndefined() // google: not in enabled = NOT allowed (even though not disabled) - expect(providers[ProviderID.google]).toBeUndefined() + expect(providers[ProviderV2.ID.google]).toBeUndefined() }), { // enabled_providers takes precedence — only these are considered @@ -879,7 +880,7 @@ it.instance( "model with tool_call false", Effect.gen(function* () { const providers = yield* list - expect(providers[ProviderID.make("no-tools")].models["basic-model"].capabilities.toolcall).toBe(false) + expect(providers[ProviderV2.ID.make("no-tools")].models["basic-model"].capabilities.toolcall).toBe(false) }), { config: { @@ -900,7 +901,7 @@ it.instance( "model defaults tool_call to true when not specified", Effect.gen(function* () { const providers = yield* list - expect(providers[ProviderID.make("default-tools")].models["model"].capabilities.toolcall).toBe(true) + expect(providers[ProviderV2.ID.make("default-tools")].models["model"].capabilities.toolcall).toBe(true) }), { config: { @@ -921,7 +922,7 @@ it.instance( "model headers are preserved", Effect.gen(function* () { const providers = yield* list - const model = providers[ProviderID.make("headers-provider")].models["model"] + const model = providers[ProviderV2.ID.make("headers-provider")].models["model"] expect(model.headers).toEqual({ "X-Custom-Header": "custom-value", Authorization: "Bearer special-token", @@ -956,7 +957,7 @@ it.instance( yield* set("FALLBACK_KEY", "fallback-api-key") const providers = yield* list // Provider should load because fallback env var is set - expect(providers[ProviderID.make("fallback-env")]).toBeDefined() + expect(providers[ProviderV2.ID.make("fallback-env")]).toBeDefined() }), { config: { @@ -976,8 +977,8 @@ it.instance( it.instance("getModel returns consistent results", () => Effect.gen(function* () { yield* set("ANTHROPIC_API_KEY", "test-api-key") - const model1 = yield* Provider.use.getModel(ProviderID.anthropic, ModelID.make("claude-sonnet-4-20250514")) - const model2 = yield* Provider.use.getModel(ProviderID.anthropic, ModelID.make("claude-sonnet-4-20250514")) + const model1 = yield* Provider.use.getModel(ProviderV2.ID.anthropic, ProviderV2.ModelID.make("claude-sonnet-4-20250514")) + const model2 = yield* Provider.use.getModel(ProviderV2.ID.anthropic, ProviderV2.ModelID.make("claude-sonnet-4-20250514")) expect(model1.providerID).toEqual(model2.providerID) expect(model1.id).toEqual(model2.id) expect(model1).toEqual(model2) @@ -988,7 +989,7 @@ it.instance( "provider name defaults to id when not in database", Effect.gen(function* () { const providers = yield* list - expect(providers[ProviderID.make("my-custom-id")].name).toBe("my-custom-id") + expect(providers[ProviderV2.ID.make("my-custom-id")].name).toBe("my-custom-id") }), { config: { @@ -1007,7 +1008,7 @@ it.instance( it.instance("ModelNotFoundError includes suggestions for typos", () => Effect.gen(function* () { yield* set("ANTHROPIC_API_KEY", "test-api-key") - const error = yield* Provider.use.getModel(ProviderID.anthropic, ModelID.make("claude-sonet-4")).pipe(Effect.flip) + const error = yield* Provider.use.getModel(ProviderV2.ID.anthropic, ProviderV2.ModelID.make("claude-sonet-4")).pipe(Effect.flip) expect(error.suggestions).toBeDefined() expect((error.suggestions ?? []).length).toBeGreaterThan(0) }), @@ -1017,7 +1018,7 @@ it.instance("ModelNotFoundError for provider includes suggestions", () => Effect.gen(function* () { yield* set("ANTHROPIC_API_KEY", "test-api-key") const error = yield* Provider.use - .getModel(ProviderID.make("antropic"), ModelID.make("claude-sonnet-4")) + .getModel(ProviderV2.ID.make("antropic"), ProviderV2.ModelID.make("claude-sonnet-4")) .pipe(Effect.flip) expect(error.suggestions).toBeDefined() expect(error.suggestions).toContain("anthropic") @@ -1028,7 +1029,7 @@ it.instance("ModelNotFoundError suggests catalog models for unloaded providers", Effect.gen(function* () { yield* remove("OPENCODE_API_KEY") const error = yield* Provider.use - .getModel(ProviderID.opencode, ModelID.make("claude-haiku-fake-model")) + .getModel(ProviderV2.ID.opencode, ProviderV2.ModelID.make("claude-haiku-fake-model")) .pipe(Effect.flip) if (!Provider.ModelNotFoundError.isInstance(error)) throw error expect(error.suggestions ?? []).toContain("claude-haiku-4-5") @@ -1037,7 +1038,7 @@ it.instance("ModelNotFoundError suggests catalog models for unloaded providers", it.instance("getProvider returns undefined for nonexistent provider", () => Effect.gen(function* () { - const provider = yield* Provider.Service.use((svc) => svc.getProvider(ProviderID.make("nonexistent"))) + const provider = yield* Provider.Service.use((svc) => svc.getProvider(ProviderV2.ID.make("nonexistent"))) expect(provider).toBeUndefined() }), ) @@ -1045,7 +1046,7 @@ it.instance("getProvider returns undefined for nonexistent provider", () => it.instance("getProvider returns provider info", () => Effect.gen(function* () { yield* set("ANTHROPIC_API_KEY", "test-api-key") - const provider = yield* Provider.use.getProvider(ProviderID.anthropic) + const provider = yield* Provider.use.getProvider(ProviderV2.ID.anthropic) expect(provider).toBeDefined() expect(String(provider?.id)).toBe("anthropic") }), @@ -1054,7 +1055,7 @@ it.instance("getProvider returns provider info", () => it.instance("closest returns undefined when no partial match found", () => Effect.gen(function* () { yield* set("ANTHROPIC_API_KEY", "test-api-key") - const result = yield* Provider.use.closest(ProviderID.anthropic, ["nonexistent-xyz-model"]) + const result = yield* Provider.use.closest(ProviderV2.ID.anthropic, ["nonexistent-xyz-model"]) expect(result).toBeUndefined() }), ) @@ -1063,7 +1064,7 @@ it.instance("closest checks multiple query terms in order", () => Effect.gen(function* () { yield* set("ANTHROPIC_API_KEY", "test-api-key") // First term won't match, second will - const result = yield* Provider.use.closest(ProviderID.anthropic, ["nonexistent", "haiku"]) + const result = yield* Provider.use.closest(ProviderV2.ID.anthropic, ["nonexistent", "haiku"]) expect(result).toBeDefined() expect(result?.modelID).toContain("haiku") }), @@ -1073,7 +1074,7 @@ it.instance( "model limit defaults to zero when not specified", Effect.gen(function* () { const providers = yield* list - const model = providers[ProviderID.make("no-limit")].models["model"] + const model = providers[ProviderV2.ID.make("no-limit")].models["model"] expect(model.limit.context).toBe(0) expect(model.limit.output).toBe(0) }), @@ -1098,10 +1099,10 @@ it.instance( yield* set("ANTHROPIC_API_KEY", "test-api-key") const providers = yield* list // Custom options should be merged - expect(providers[ProviderID.anthropic].options.timeout).toBe(30000) - expect(providers[ProviderID.anthropic].options.headers["X-Custom"]).toBe("custom-value") + expect(providers[ProviderV2.ID.anthropic].options.timeout).toBe(30000) + expect(providers[ProviderV2.ID.anthropic].options.headers["X-Custom"]).toBe("custom-value") // anthropic custom loader adds its own headers, they should coexist - expect(providers[ProviderID.anthropic].options.headers["anthropic-beta"]).toBeDefined() + expect(providers[ProviderV2.ID.anthropic].options.headers["anthropic-beta"]).toBeDefined() }), { config: { @@ -1114,7 +1115,7 @@ it.instance( "hosted nvidia provider adds billing origin header", Effect.gen(function* () { const providers = yield* list - expect(providers[ProviderID.make("nvidia")].options.headers).toEqual({ + expect(providers[ProviderV2.ID.make("nvidia")].options.headers).toEqual({ "HTTP-Referer": "https://opencode.ai/", "X-Title": "opencode", "X-BILLING-INVOKE-ORIGIN": "OpenCode", @@ -1127,7 +1128,7 @@ it.instance( "custom nvidia baseURL adds billing origin header", Effect.gen(function* () { const providers = yield* list - expect(providers[ProviderID.make("nvidia")].options.headers).toEqual({ + expect(providers[ProviderV2.ID.make("nvidia")].options.headers).toEqual({ "HTTP-Referer": "https://opencode.ai/", "X-Title": "opencode", "X-BILLING-INVOKE-ORIGIN": "OpenCode", @@ -1140,7 +1141,7 @@ it.instance( "explicit nvidia billing origin header is preserved", Effect.gen(function* () { const providers = yield* list - expect(providers[ProviderID.make("nvidia")].options.headers["X-BILLING-INVOKE-ORIGIN"]).toBe("CustomOrigin") + expect(providers[ProviderV2.ID.make("nvidia")].options.headers["X-BILLING-INVOKE-ORIGIN"]).toBe("CustomOrigin") }), { config: { @@ -1162,7 +1163,7 @@ it.instance( Effect.gen(function* () { yield* set("OPENAI_API_KEY", "test-api-key") const providers = yield* list - const model = providers[ProviderID.openai].models["my-custom-model"] + const model = providers[ProviderV2.ID.openai].models["my-custom-model"] expect(model).toBeDefined() expect(model.api.npm).toBe("@ai-sdk/openai") }), @@ -1188,15 +1189,15 @@ it.instance( Effect.gen(function* () { yield* set("OPENROUTER_API_KEY", "test-api-key") const providers = yield* list - expect(providers[ProviderID.openrouter]).toBeDefined() + expect(providers[ProviderV2.ID.openrouter]).toBeDefined() // New model not in database should inherit api.url from provider - const intellect = providers[ProviderID.openrouter].models["prime-intellect/intellect-3"] + const intellect = providers[ProviderV2.ID.openrouter].models["prime-intellect/intellect-3"] expect(intellect).toBeDefined() expect(intellect.api.url).toBe("https://openrouter.ai/api/v1") // Another new model should also inherit api.url - const deepseek = providers[ProviderID.openrouter].models["deepseek/deepseek-r1-0528"] + const deepseek = providers[ProviderV2.ID.openrouter].models["deepseek/deepseek-r1-0528"] expect(deepseek).toBeDefined() expect(deepseek.api.url).toBe("https://openrouter.ai/api/v1") expect(deepseek.name).toBe("DeepSeek R1") @@ -1309,7 +1310,7 @@ it.instance("model variants are generated for reasoning models", () => yield* set("ANTHROPIC_API_KEY", "test-api-key") const providers = yield* list // Claude sonnet 4 has reasoning capability - const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"] + const model = providers[ProviderV2.ID.anthropic].models["claude-sonnet-4-20250514"] expect(model.capabilities.reasoning).toBe(true) expect(model.variants).toBeDefined() expect(Object.keys(model.variants!).length).toBeGreaterThan(0) @@ -1321,7 +1322,7 @@ it.instance( Effect.gen(function* () { yield* set("ANTHROPIC_API_KEY", "test-api-key") const providers = yield* list - const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"] + const model = providers[ProviderV2.ID.anthropic].models["claude-sonnet-4-20250514"] expect(model.variants).toBeDefined() expect(model.variants!["high"]).toBeUndefined() // max variant should still exist @@ -1343,7 +1344,7 @@ it.instance( Effect.gen(function* () { yield* set("ANTHROPIC_API_KEY", "test-api-key") const providers = yield* list - const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"] + const model = providers[ProviderV2.ID.anthropic].models["claude-sonnet-4-20250514"] expect(model.variants!["high"]).toBeDefined() expect(model.variants!["high"].thinking.budgetTokens).toBe(20000) }), @@ -1367,7 +1368,7 @@ it.instance( Effect.gen(function* () { yield* set("ANTHROPIC_API_KEY", "test-api-key") const providers = yield* list - const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"] + const model = providers[ProviderV2.ID.anthropic].models["claude-sonnet-4-20250514"] expect(model.variants!["max"]).toBeDefined() expect(model.variants!["max"].disabled).toBeUndefined() expect(model.variants!["max"].customField).toBe("test") @@ -1392,7 +1393,7 @@ it.instance( Effect.gen(function* () { yield* set("ANTHROPIC_API_KEY", "test-api-key") const providers = yield* list - const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"] + const model = providers[ProviderV2.ID.anthropic].models["claude-sonnet-4-20250514"] expect(model.variants).toBeDefined() expect(Object.keys(model.variants!).length).toBe(0) }), @@ -1416,7 +1417,7 @@ it.instance( Effect.gen(function* () { yield* set("ANTHROPIC_API_KEY", "test-api-key") const providers = yield* list - const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"] + const model = providers[ProviderV2.ID.anthropic].models["claude-sonnet-4-20250514"] expect(model.variants!["high"]).toBeDefined() // Should have both the generated thinking config and the custom option expect(model.variants!["high"].thinking).toBeDefined() @@ -1440,7 +1441,7 @@ it.instance( Effect.gen(function* () { yield* set("OPENAI_API_KEY", "test-api-key") const providers = yield* list - const model = providers[ProviderID.openai].models["gpt-5"] + const model = providers[ProviderV2.ID.openai].models["gpt-5"] expect(model.variants).toBeDefined() expect(model.variants!["high"]).toBeUndefined() // Other variants should still exist @@ -1457,7 +1458,7 @@ it.instance( "custom model with variants enabled and disabled", Effect.gen(function* () { const providers = yield* list - const model = providers[ProviderID.make("custom-reasoning")].models["reasoning-model"] + const model = providers[ProviderV2.ID.make("custom-reasoning")].models["reasoning-model"] expect(model.variants).toBeDefined() // Enabled variants should exist expect(model.variants!["low"]).toBeDefined() @@ -1507,8 +1508,8 @@ it.instance( Effect.gen(function* () { yield* set("GOOGLE_APPLICATION_CREDENTIALS", "test-creds") const providers = yield* list - expect(providers[ProviderID.make("vertex-proxy")]).toBeDefined() - expect(providers[ProviderID.make("vertex-proxy")].options.baseURL).toBe("https://my-proxy.com/v1") + expect(providers[ProviderV2.ID.make("vertex-proxy")]).toBeDefined() + expect(providers[ProviderV2.ID.make("vertex-proxy")].options.baseURL).toBe("https://my-proxy.com/v1") }), { config: { @@ -1535,7 +1536,7 @@ it.instance( Effect.gen(function* () { yield* set("GOOGLE_APPLICATION_CREDENTIALS", "test-creds") const providers = yield* list - const model = providers[ProviderID.make("vertex-openai")].models["gpt-4"] + const model = providers[ProviderV2.ID.make("vertex-openai")].models["gpt-4"] expect(model).toBeDefined() expect(model.api.npm).toBe("@ai-sdk/openai-compatible") }), @@ -1564,7 +1565,7 @@ it.instance("Google Vertex: uses REP endpoint for Claude continental multi-regio yield* set("GOOGLE_CLOUD_PROJECT", "test-project") yield* set("VERTEX_LOCATION", "eu") const provider = yield* Provider.Service - const model = yield* provider.getModel(ProviderID.make("google-vertex"), ModelID.make("claude-sonnet-4-6@default")) + const model = yield* provider.getModel(ProviderV2.ID.make("google-vertex"), ProviderV2.ModelID.make("claude-sonnet-4-6@default")) const language = yield* provider.getLanguage(model) expect(languageBaseURL(language)).toBe( "https://aiplatform.eu.rep.googleapis.com/v1/projects/test-project/locations/eu/publishers/anthropic/models", @@ -1578,8 +1579,8 @@ it.instance("Google Vertex Anthropic: uses REP endpoint for continental multi-re yield* set("VERTEX_LOCATION", "us") const provider = yield* Provider.Service const model = yield* provider.getModel( - ProviderID.make("google-vertex-anthropic"), - ModelID.make("claude-sonnet-4-6@default"), + ProviderV2.ID.make("google-vertex-anthropic"), + ProviderV2.ModelID.make("claude-sonnet-4-6@default"), ) const language = yield* provider.getLanguage(model) expect(languageBaseURL(language)).toBe( @@ -1593,7 +1594,7 @@ it.instance("Google Vertex: keeps regional Claude endpoints unchanged", () => yield* set("GOOGLE_CLOUD_PROJECT", "test-project") yield* set("VERTEX_LOCATION", "europe-west1") const provider = yield* Provider.Service - const model = yield* provider.getModel(ProviderID.make("google-vertex"), ModelID.make("claude-sonnet-4-6@default")) + const model = yield* provider.getModel(ProviderV2.ID.make("google-vertex"), ProviderV2.ModelID.make("claude-sonnet-4-6@default")) const language = yield* provider.getLanguage(model) expect(languageBaseURL(language)).toBe( "https://europe-west1-aiplatform.googleapis.com/v1/projects/test-project/locations/europe-west1/publishers/anthropic/models", @@ -1607,7 +1608,7 @@ it.instance("cloudflare-ai-gateway loads with env variables", () => yield* set("CLOUDFLARE_GATEWAY_ID", "test-gateway") yield* set("CLOUDFLARE_API_TOKEN", "test-token") const providers = yield* list - expect(providers[ProviderID.make("cloudflare-ai-gateway")]).toBeDefined() + expect(providers[ProviderV2.ID.make("cloudflare-ai-gateway")]).toBeDefined() }), ) @@ -1618,8 +1619,8 @@ it.instance( yield* set("CLOUDFLARE_GATEWAY_ID", "test-gateway") yield* set("CLOUDFLARE_API_TOKEN", "test-token") const providers = yield* list - expect(providers[ProviderID.make("cloudflare-ai-gateway")]).toBeDefined() - expect(providers[ProviderID.make("cloudflare-ai-gateway")].options.metadata).toEqual({ + expect(providers[ProviderV2.ID.make("cloudflare-ai-gateway")]).toBeDefined() + expect(providers[ProviderV2.ID.make("cloudflare-ai-gateway")].options.metadata).toEqual({ invoked_by: "test", project: "opencode", }) @@ -1682,14 +1683,14 @@ it.effect("plugin config providers persist after instance dispose", () => }).pipe(provideInstanceEffect(dir)) const first = yield* loadAndList - expect(first[ProviderID.make("demo")]).toBeDefined() - expect(first[ProviderID.make("demo")].models[ModelID.make("chat")]).toBeDefined() + expect(first[ProviderV2.ID.make("demo")]).toBeDefined() + expect(first[ProviderV2.ID.make("demo")].models[ProviderV2.ModelID.make("chat")]).toBeDefined() yield* Effect.promise(() => disposeAllInstances()) const second = yield* loadAndList - expect(second[ProviderID.make("demo")]).toBeDefined() - expect(second[ProviderID.make("demo")].models[ModelID.make("chat")]).toBeDefined() + expect(second[ProviderV2.ID.make("demo")]).toBeDefined() + expect(second[ProviderV2.ID.make("demo")].models[ProviderV2.ModelID.make("chat")]).toBeDefined() }).pipe(provideMultiInstance), ) @@ -1722,8 +1723,8 @@ it.instance( yield* set("ANTHROPIC_API_KEY", "test-anthropic-key") yield* set("OPENAI_API_KEY", "test-openai-key") const providers = yield* list - expect(providers[ProviderID.anthropic]).toBeDefined() - expect(providers[ProviderID.openai]).toBeUndefined() + expect(providers[ProviderV2.ID.anthropic]).toBeDefined() + expect(providers[ProviderV2.ID.openai]).toBeUndefined() }), ) diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index 6da3f493bb..7fb22ddf57 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test" import { ProviderTransform } from "@/provider/transform" -import { ModelID, ProviderID } from "../../src/provider/schema" +import { ProviderV2 } from "@opencode-ai/core/provider" describe("ProviderTransform.options - setCacheKey", () => { const sessionID = "test-session-123" @@ -1089,8 +1089,8 @@ describe("ProviderTransform.message - DeepSeek reasoning content", () => { const result = ProviderTransform.message( msgs, { - id: ModelID.make("deepseek/deepseek-chat"), - providerID: ProviderID.make("deepseek"), + id: ProviderV2.ModelID.make("deepseek/deepseek-chat"), + providerID: ProviderV2.ID.make("deepseek"), api: { id: "deepseek-chat", url: "https://api.deepseek.com", @@ -1151,8 +1151,8 @@ describe("ProviderTransform.message - DeepSeek reasoning content", () => { const result = ProviderTransform.message( msgs, { - id: ModelID.make("openai/gpt-4"), - providerID: ProviderID.make("openai"), + id: ProviderV2.ModelID.make("openai/gpt-4"), + providerID: ProviderV2.ID.make("openai"), api: { id: "gpt-4", url: "https://api.openai.com", diff --git a/packages/opencode/test/pty/pty-output-isolation.test.ts b/packages/opencode/test/pty/pty-output-isolation.test.ts index 0fa710f02a..20975d986e 100644 --- a/packages/opencode/test/pty/pty-output-isolation.test.ts +++ b/packages/opencode/test/pty/pty-output-isolation.test.ts @@ -1,5 +1,5 @@ import { describe, expect } from "bun:test" -import { Bus } from "../../src/bus" +import { EventV2Bridge } from "../../src/event-v2-bridge" import { Config } from "../../src/config/config" import { Plugin } from "../../src/plugin" import { Pty } from "../../src/pty" @@ -10,7 +10,7 @@ type Socket = Parameters[1] const it = testEffect( Pty.layer.pipe( - Layer.provideMerge(Bus.layer), + Layer.provideMerge(EventV2Bridge.defaultLayer), Layer.provideMerge(Config.defaultLayer), Layer.provideMerge(Plugin.defaultLayer), ), diff --git a/packages/opencode/test/pty/pty-session.test.ts b/packages/opencode/test/pty/pty-session.test.ts index 9fda48cc91..74c9f70ec3 100644 --- a/packages/opencode/test/pty/pty-session.test.ts +++ b/packages/opencode/test/pty/pty-session.test.ts @@ -1,5 +1,5 @@ import { describe, expect } from "bun:test" -import { Bus } from "../../src/bus" +import { EventV2Bridge } from "../../src/event-v2-bridge" import { Config } from "../../src/config/config" import { Plugin } from "../../src/plugin" import { Pty } from "../../src/pty" @@ -11,7 +11,7 @@ type PtyEvent = { type: "created" | "exited" | "deleted"; id: PtyID } const it = testEffect( Pty.layer.pipe( - Layer.provideMerge(Bus.layer), + Layer.provideMerge(EventV2Bridge.defaultLayer), Layer.provideMerge(Config.defaultLayer), Layer.provideMerge(Plugin.defaultLayer), ), @@ -19,27 +19,19 @@ const it = testEffect( const ptyTest = process.platform === "win32" ? it.instance.skip : it.instance const subscribePtyEvents = Effect.fn("PtySessionTest.subscribePtyEvents")(function* () { - const bus = yield* Bus.Service + const source = yield* EventV2Bridge.Service const events = yield* Queue.unbounded() - const subscribe = (effect: Effect.Effect<() => void, never, A>) => - Effect.acquireRelease(effect, (off) => Effect.sync(off)) - - yield* subscribe( - bus.subscribeCallback(Pty.Event.Created, (evt) => { - Queue.offerUnsafe(events, { type: "created", id: evt.properties.info.id }) - }), - ) - yield* subscribe( - bus.subscribeCallback(Pty.Event.Exited, (evt) => { - Queue.offerUnsafe(events, { type: "exited", id: evt.properties.id }) - }), - ) - yield* subscribe( - bus.subscribeCallback(Pty.Event.Deleted, (evt) => { - Queue.offerUnsafe(events, { type: "deleted", id: evt.properties.id }) - }), - ) + const unsubscribe = yield* source.listen((event) => { + if (event.type === Pty.Event.Created.type) + Queue.offerUnsafe(events, { type: "created", id: (event.data as typeof Pty.Event.Created.data.Type).info.id }) + if (event.type === Pty.Event.Exited.type) + Queue.offerUnsafe(events, { type: "exited", id: (event.data as typeof Pty.Event.Exited.data.Type).id }) + if (event.type === Pty.Event.Deleted.type) + Queue.offerUnsafe(events, { type: "deleted", id: (event.data as typeof Pty.Event.Deleted.data.Type).id }) + return Effect.void + }) + yield* Effect.addFinalizer(() => unsubscribe) return events }) diff --git a/packages/opencode/test/pty/ticket.test.ts b/packages/opencode/test/pty/ticket.test.ts index 4886f250f9..2a6124f5d6 100644 --- a/packages/opencode/test/pty/ticket.test.ts +++ b/packages/opencode/test/pty/ticket.test.ts @@ -1,6 +1,6 @@ import { describe, expect } from "bun:test" import { Effect, Layer } from "effect" -import { WorkspaceID } from "../../src/control-plane/schema" +import { WorkspaceV2 } from "@opencode-ai/core/workspace" import { PtyID } from "../../src/pty/schema" import { PtyTicket } from "../../src/pty/ticket" import { testEffect } from "../lib/effect" @@ -47,10 +47,10 @@ describe("PTY websocket tickets", () => { Effect.gen(function* () { const tickets = yield* PtyTicket.Service const ptyID = PtyID.ascending() - const workspaceID = WorkspaceID.ascending() + const workspaceID = WorkspaceV2.ID.ascending() const issued = yield* tickets.issue({ ptyID, workspaceID }) - expect(yield* tickets.consume({ ptyID, workspaceID: WorkspaceID.ascending(), ticket: issued.ticket })).toBe(false) + expect(yield* tickets.consume({ ptyID, workspaceID: WorkspaceV2.ID.ascending(), ticket: issued.ticket })).toBe(false) expect(yield* tickets.consume({ ptyID, workspaceID, ticket: issued.ticket })).toBe(true) }), ) diff --git a/packages/opencode/test/question/question.test.ts b/packages/opencode/test/question/question.test.ts index 5f6f87972e..47c71e9e07 100644 --- a/packages/opencode/test/question/question.test.ts +++ b/packages/opencode/test/question/question.test.ts @@ -2,16 +2,19 @@ import { afterEach, expect } from "bun:test" import { Cause, Effect, Exit, Fiber, Layer, Queue } from "effect" import { Question } from "../../src/question" import { InstanceRef } from "../../src/effect/instance-ref" -import { InstanceRuntime } from "../../src/project/instance-runtime" +import { InstanceStore } from "../../src/project/instance-store" import { QuestionID } from "../../src/question/schema" -import { disposeAllInstances, provideInstance, reloadTestInstance, tmpdirScoped } from "../fixture/fixture" +import { disposeAllInstances, provideInstance, testInstanceStoreLayer, tmpdirScoped } from "../fixture/fixture" import { SessionID } from "../../src/session/schema" import { testEffect } from "../lib/effect" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" -import { Bus } from "../../src/bus" +import { EventV2Bridge } from "../../src/event-v2-bridge" const it = testEffect( - Layer.mergeAll(Question.layer.pipe(Layer.provideMerge(Bus.layer)), CrossSpawnSpawner.defaultLayer), + Layer.mergeAll(Question.layer.pipe(Layer.provideMerge(EventV2Bridge.defaultLayer)), CrossSpawnSpawner.defaultLayer), +) +const lifecycle = testEffect( + Layer.mergeAll(Question.layer.pipe(Layer.provideMerge(EventV2Bridge.defaultLayer)), CrossSpawnSpawner.defaultLayer, testInstanceStoreLayer), ) const askEffect = Effect.fn("QuestionTest.ask")(function* (input: { @@ -49,10 +52,13 @@ const rejectAll = Effect.gen(function* () { const waitForPending = Effect.fn("QuestionTest.waitForPending")(function* (count: number) { const question = yield* Question.Service - const bus = yield* Bus.Service + const events = yield* EventV2Bridge.Service const asked = yield* Queue.unbounded() - const off = yield* bus.subscribeCallback(Question.Event.Asked, () => Queue.offerUnsafe(asked, undefined)) - yield* Effect.addFinalizer(() => Effect.sync(off)) + const off = yield* events.listen((event) => { + if (event.type === Question.Event.Asked.type) Queue.offerUnsafe(asked, undefined) + return Effect.void + }) + yield* Effect.addFinalizer(() => off) for (;;) { const pending = yield* question.list() @@ -361,7 +367,7 @@ it.instance( { git: true }, ) -it.live("questions stay isolated by directory", () => +lifecycle.live("questions stay isolated by directory", () => Effect.gen(function* () { const one = yield* tmpdirScoped({ git: true }) const two = yield* tmpdirScoped({ git: true }) @@ -404,7 +410,7 @@ it.live("questions stay isolated by directory", () => }), ) -it.live("pending question rejects on instance dispose", () => +lifecycle.live("pending question rejects on instance dispose", () => Effect.gen(function* () { const dir = yield* tmpdirScoped({ git: true }) const fiber = yield* askEffect({ @@ -423,7 +429,7 @@ it.live("pending question rejects on instance dispose", () => return yield* InstanceRef }).pipe(provideInstance(dir)) if (!ctx) return yield* Effect.die(new Error("missing test instance")) - yield* Effect.promise(() => InstanceRuntime.disposeInstance(ctx)) + yield* InstanceStore.Service.use((store) => store.dispose(ctx)) const exit = yield* Fiber.await(fiber) expect(Exit.isFailure(exit)).toBe(true) @@ -431,7 +437,7 @@ it.live("pending question rejects on instance dispose", () => }), ) -it.live("pending question rejects on instance reload", () => +lifecycle.live("pending question rejects on instance reload", () => Effect.gen(function* () { const dir = yield* tmpdirScoped({ git: true }) const fiber = yield* askEffect({ @@ -446,7 +452,7 @@ it.live("pending question rejects on instance reload", () => }).pipe(provideInstance(dir), Effect.forkScoped) expect(yield* waitForPending(1).pipe(provideInstance(dir))).toHaveLength(1) - yield* Effect.promise(() => reloadTestInstance({ directory: dir })) + yield* InstanceStore.Service.use((store) => store.reload({ directory: dir })) const exit = yield* Fiber.await(fiber) expect(Exit.isFailure(exit)).toBe(true) diff --git a/packages/opencode/test/server/global-session-list.test.ts b/packages/opencode/test/server/global-session-list.test.ts index df49ae0841..278e36a966 100644 --- a/packages/opencode/test/server/global-session-list.test.ts +++ b/packages/opencode/test/server/global-session-list.test.ts @@ -27,7 +27,7 @@ describe("session.listGlobal", () => { const firstSession = yield* withSession({ title: "first-session" }) const secondSession = yield* withSession({ title: "second-session" }).pipe(provideInstance(second)) - const sessions = yield* Effect.sync(() => [...SessionNs.listGlobal({ limit: 200 })]) + const sessions = yield* SessionNs.Service.use((session) => session.listGlobal({ limit: 200 })) const ids = sessions.map((session) => session.id) expect(ids).toContain(firstSession.id) @@ -56,12 +56,14 @@ describe("session.listGlobal", () => { yield* SessionNs.Service.use((session) => session.setArchived({ sessionID: archived.id, time: Date.now() })) - const sessions = yield* Effect.sync(() => [...SessionNs.listGlobal({ limit: 200 })]) + const sessions = yield* SessionNs.Service.use((session) => session.listGlobal({ limit: 200 })) const ids = sessions.map((session) => session.id) expect(ids).not.toContain(archived.id) - const allSessions = yield* Effect.sync(() => [...SessionNs.listGlobal({ limit: 200, archived: true })]) + const allSessions = yield* SessionNs.Service.use((session) => + session.listGlobal({ limit: 200, archived: true }), + ) const allIds = allSessions.map((session) => session.id) expect(allIds).toContain(archived.id) @@ -86,13 +88,15 @@ describe("session.listGlobal", () => { ) const second = yield* withSession({ title: "page-two" }) - const page = yield* Effect.sync(() => [...SessionNs.listGlobal({ directory: test.directory, limit: 1 })]) + const page = yield* SessionNs.Service.use((session) => + session.listGlobal({ directory: test.directory, limit: 1 }), + ) expect(page.length).toBe(1) expect(page[0].id).toBe(second.id) - const next = yield* Effect.sync(() => [ - ...SessionNs.listGlobal({ directory: test.directory, limit: 10, cursor: page[0].time.updated }), - ]) + const next = yield* SessionNs.Service.use((session) => + session.listGlobal({ directory: test.directory, limit: 10, cursor: page[0].time.updated }), + ) const ids = next.map((session) => session.id) expect(ids).toContain(first.id) diff --git a/packages/opencode/test/server/httpapi-event-diagnostics.test.ts b/packages/opencode/test/server/httpapi-event-diagnostics.test.ts deleted file mode 100644 index 66bd0bcedd..0000000000 --- a/packages/opencode/test/server/httpapi-event-diagnostics.test.ts +++ /dev/null @@ -1,279 +0,0 @@ -// Diagnostic suite for /event SSE delivery. -// -// Each test isolates ONE variable in the publisher chain while keeping the -// subscriber path constant (in-process HttpApi via Server.Default reading the -// SSE body). The pass/fail pattern across tests tells us where the bug lives: -// -// D1 (baseline): publish via Bus.use.publish — mirror of httpapi-event.test.ts -// test 3. Confirms /event SSE delivery works for SOME publish path. -// -// D2: publish N times in quick succession via Bus.use.publish. If the bus -// subscription is acquired correctly there should be no message loss. -// -// D3: publish via SyncEvent.use.run — exercises the same path the HTTP -// handlers use (Session.updatePart → sync.run → bus.publish) without -// the HTTP roundtrip. Tells us whether the sync path itself can deliver -// in-process. -// -// D4: publish via SyncEvent.use.run; subscriber is an in-process Bus -// callback. Confirms pub/sub identity end-to-end without /event SSE. -// -// D5: in-process Bus callback subscriber AND raw /event SSE subscriber -// receive the same publish. If both receive: no bug. If only the -// callback receives: the /event handler has an acquisition race. -// -// D6: same as D5 but the callback subscriber is attached AFTER /event SSE -// subscription is established. Order-of-setup variable. -import { afterEach, describe, expect } from "bun:test" -import { Deferred, Effect, Layer, Schema } from "effect" -import * as Log from "@opencode-ai/core/util/log" -import { Bus } from "../../src/bus" -import { Event as ServerEvent } from "../../src/server/event" -import { Server } from "../../src/server/server" -import { EventPaths } from "../../src/server/routes/instance/httpapi/groups/event" -import { MessageV2 } from "../../src/session/message-v2" -import { MessageID, PartID, SessionID } from "../../src/session/schema" -import { SyncEvent } from "../../src/sync" -import { resetDatabase } from "../fixture/db" -import { disposeAllInstances, TestInstance } from "../fixture/fixture" -import { testEffectShared } from "../lib/effect" - -void Log.init({ print: false }) - -const SseEvent = Schema.Struct({ - id: Schema.optional(Schema.String), - type: Schema.String, - properties: Schema.Record(Schema.String, Schema.Any), -}) - -type SseEvent = Schema.Schema.Type -type BusEvent = { type: string; properties: unknown } - -afterEach(async () => { - await disposeAllInstances() - await resetDatabase() -}) - -const it = testEffectShared(Layer.mergeAll(Bus.defaultLayer, SyncEvent.defaultLayer)) - -const publishConnected = Bus.use.publish(ServerEvent.Connected, {}) - -const publishPartUpdated = (partID: ReturnType) => { - const sessionID = SessionID.make(`ses_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`) - return SyncEvent.use.run(MessageV2.Event.PartUpdated, { - sessionID, - part: { id: partID, sessionID, messageID: MessageID.ascending(), type: "text", text: "diag" }, - time: Date.now(), - }) -} - -const subscribeAllCallback = (handler: (event: BusEvent) => void) => - Effect.acquireRelease(Bus.use.subscribeAllCallback(handler), (dispose) => Effect.sync(() => dispose())) - -const openEventStream = (directory: string) => - Effect.gen(function* () { - const response = yield* Effect.promise(async () => - Server.Default().app.request(EventPaths.event, { headers: { "x-opencode-directory": directory } }), - ) - if (!response.body) return yield* Effect.die("missing SSE response body") - const reader = response.body.getReader() - yield* Effect.addFinalizer(() => Effect.promise(() => reader.cancel().catch(() => undefined))) - return reader - }) - -const decoder = new TextDecoder() - -function decodeFrame(value: Uint8Array): SseEvent[] { - return decoder - .decode(value) - .split(/\n\n+/) - .map((part) => part.trim()) - .filter((part) => part.length > 0) - .map((part) => Schema.decodeUnknownSync(SseEvent)(JSON.parse(part.replace(/^data: /, "")))) -} - -const readNextEvent = (reader: ReadableStreamDefaultReader) => - Effect.promise(() => reader.read()).pipe( - Effect.timeoutOrElse({ - duration: "3 seconds", - orElse: () => Effect.fail(new Error("timed out reading SSE chunk")), - }), - Effect.flatMap((result) => { - if (result.done || !result.value) return Effect.fail(new Error("event stream closed")) - const frames = decodeFrame(result.value) - if (frames.length === 0) return Effect.fail(new Error("empty SSE frame")) - return Effect.succeed(frames[0]!) - }), - ) - -const collectUntilEvent = (reader: ReadableStreamDefaultReader, predicate: (event: SseEvent) => boolean) => - Effect.gen(function* () { - const events: SseEvent[] = [] - while (true) { - const event = yield* readNextEvent(reader) - events.push(event) - if (predicate(event)) return events - } - }).pipe( - Effect.timeoutOrElse({ - duration: "4 seconds", - orElse: () => Effect.fail(new Error("collectUntil deadline exceeded")), - }), - ) - -const isPartUpdated = (event: { type: string }) => event.type === MessageV2.Event.PartUpdated.type - -describe("/event SSE delivery diagnostics", () => { - // Sanity: baseline same as httpapi-event.test.ts test 3 (already known to pass) - // but explicit about timing — publish happens with NO wait after reading - // server.connected. If this fails we have a deeper problem than just sync. - it.instance( - "D1: delivers a single bus event published right after server.connected", - () => - Effect.gen(function* () { - const { directory } = yield* TestInstance - const reader = yield* openEventStream(directory) - - expect((yield* readNextEvent(reader)).type).toBe("server.connected") - yield* publishConnected - expect((yield* readNextEvent(reader)).type).toBe("server.connected") - }), - { git: true, config: { formatter: false, lsp: false } }, - ) - - // If D1 passes but D2 fails, we have a queue-drain or partial-loss issue. - it.instance( - "D2: delivers all N bus events published in rapid succession", - () => - Effect.gen(function* () { - const { directory } = yield* TestInstance - const reader = yield* openEventStream(directory) - expect((yield* readNextEvent(reader)).type).toBe("server.connected") - - const N = 5 - yield* Effect.replicateEffect(publishConnected, N) - - const received = yield* Effect.replicateEffect(readNextEvent(reader), N) - expect(received).toHaveLength(N) - for (const event of received) expect(event.type).toBe("server.connected") - }), - { git: true, config: { formatter: false, lsp: false } }, - ) - - // The critical test. If D1 passes but this fails, the bus-identity fix is - // incomplete OR the sync.run publish path doesn't reach the same bus - // /event subscribes to, even when both share the memoMap. - it.instance( - "D3: delivers a SyncEvent published via SyncEvent.use.run after server.connected", - () => - Effect.gen(function* () { - const { directory } = yield* TestInstance - const reader = yield* openEventStream(directory) - expect((yield* readNextEvent(reader)).type).toBe("server.connected") - - const partID = PartID.ascending() - yield* publishPartUpdated(partID) - - const collected = yield* collectUntilEvent(reader, isPartUpdated) - const updated = collected.find(isPartUpdated) - expect(updated?.properties.part.id).toBe(partID) - }), - { git: true, config: { formatter: false, lsp: false } }, - ) - - // If D3 passes but D5 (the SDK E2E in httpapi-sdk.test.ts) fails, then the - // bug is specifically in the cross-request / cross-fiber HTTP path, not in - // the publish itself. If D3 also fails, the publish chain is broken. - // - // D4: ensure the publish reaches an in-process Bus subscriber too. Confirms - // pub/sub identity end-to-end without involving /event SSE. - it.instance( - "D4: SyncEvent.use.run publish reaches an in-process Bus callback", - () => - Effect.gen(function* () { - const received = yield* Deferred.make() - yield* subscribeAllCallback((event) => { - if (isPartUpdated(event)) Deferred.doneUnsafe(received, Effect.succeed(event)) - }) - - const partID = PartID.ascending() - yield* publishPartUpdated(partID) - - const event = yield* Deferred.await(received).pipe( - Effect.timeoutOrElse({ - duration: "3 seconds", - orElse: () => Effect.fail(new Error("D4 timed out waiting for callback")), - }), - ) - expect(event.type).toBe(MessageV2.Event.PartUpdated.type) - expect(event.properties).toMatchObject({ part: { id: partID } }) - }), - { git: true, config: { formatter: false, lsp: false } }, - ) - - // D5: BOTH subscribers attached simultaneously. Trigger ONE publish via - // SyncEvent.use.run. Both subscribers should receive it. If only one does - // we know exactly which side of the chain is failing. - it.instance( - "D5: same SyncEvent.use.run publish reaches BOTH /event SSE and in-process callback", - () => - Effect.gen(function* () { - const { directory } = yield* TestInstance - const callbackReceived = yield* Deferred.make() - yield* subscribeAllCallback((event) => { - if (isPartUpdated(event)) Deferred.doneUnsafe(callbackReceived, Effect.succeed(event)) - }) - const reader = yield* openEventStream(directory) - expect((yield* readNextEvent(reader)).type).toBe("server.connected") - - const partID = PartID.ascending() - yield* publishPartUpdated(partID) - - const sseSaw = yield* collectUntilEvent(reader, isPartUpdated).pipe( - Effect.map((events) => events.some(isPartUpdated)), - Effect.catch(() => Effect.succeed(false)), - ) - const callbackSaw = yield* Deferred.await(callbackReceived).pipe( - Effect.timeoutOrElse({ duration: "1 second", orElse: () => Effect.succeed(undefined) }), - Effect.map((event) => event !== undefined), - ) - - // Single assert with the boolean pair so the failure message tells us - // exactly which side broke. - expect({ sseSaw, callbackSaw }).toEqual({ sseSaw: true, callbackSaw: true }) - }), - { git: true, config: { formatter: false, lsp: false } }, - ) - - // D6: same as D5 but the callback subscriber is attached AFTER /event SSE - // subscription is established. If D5 fails and D6 passes, the order of - // subscriber setup is the determining factor. - it.instance( - "D6: /event SSE receives sync.run publish when callback is attached AFTER /event opens", - () => - Effect.gen(function* () { - const { directory } = yield* TestInstance - const reader = yield* openEventStream(directory) - expect((yield* readNextEvent(reader)).type).toBe("server.connected") - - const callbackReceived = yield* Deferred.make() - yield* subscribeAllCallback((event) => { - if (isPartUpdated(event)) Deferred.doneUnsafe(callbackReceived, Effect.succeed(event)) - }) - - const partID = PartID.ascending() - yield* publishPartUpdated(partID) - - const sseSaw = yield* collectUntilEvent(reader, isPartUpdated).pipe( - Effect.map((events) => events.some(isPartUpdated)), - Effect.catch(() => Effect.succeed(false)), - ) - const callbackSaw = yield* Deferred.await(callbackReceived).pipe( - Effect.timeoutOrElse({ duration: "1 second", orElse: () => Effect.succeed(undefined) }), - Effect.map((event) => event !== undefined), - ) - expect({ sseSaw, callbackSaw }).toEqual({ sseSaw: true, callbackSaw: true }) - }), - { git: true, config: { formatter: false, lsp: false } }, - ) -}) diff --git a/packages/opencode/test/server/httpapi-event.test.ts b/packages/opencode/test/server/httpapi-event.test.ts index 44d421ea0a..14e246c2b2 100644 --- a/packages/opencode/test/server/httpapi-event.test.ts +++ b/packages/opencode/test/server/httpapi-event.test.ts @@ -1,13 +1,11 @@ import { afterEach, describe, expect } from "bun:test" -import { Effect, Schema } from "effect" +import { Effect, Layer, Queue, Schema, Stream } from "effect" import * as Log from "@opencode-ai/core/util/log" -import { Bus } from "../../src/bus" -import { Event as ServerEvent } from "../../src/server/event" -import { Server } from "../../src/server/server" import { EventPaths } from "../../src/server/routes/instance/httpapi/groups/event" import { resetDatabase } from "../fixture/db" import { disposeAllInstances, TestInstance } from "../fixture/fixture" -import { testEffectShared } from "../lib/effect" +import { testEffect } from "../lib/effect" +import { httpApiLayer, requestInDirectory } from "./httpapi-layer" void Log.init({ print: false }) @@ -17,28 +15,25 @@ const EventData = Schema.Struct({ properties: Schema.Record(Schema.String, Schema.Any), }) -const readEvent = (reader: ReadableStreamDefaultReader) => +const readEvent = (reader: Queue.Dequeue) => Effect.gen(function* () { - const result = yield* Effect.promise(() => reader.read()).pipe( + const value = yield* Queue.take(reader).pipe( Effect.timeoutOrElse({ duration: "5 seconds", orElse: () => Effect.fail(new Error("timed out waiting for event")), }), ) - if (result.done || !result.value) return yield* Effect.fail(new Error("event stream closed")) - return Schema.decodeUnknownSync(EventData)( - JSON.parse(new TextDecoder().decode(result.value).replace(/^data: /, "")), - ) + return Schema.decodeUnknownSync(EventData)(JSON.parse(new TextDecoder().decode(value).replace(/^data: /, ""))) }) const openEventStream = (directory: string) => Effect.gen(function* () { - const response = yield* Effect.promise(async () => - Server.Default().app.request(EventPaths.event, { headers: { "x-opencode-directory": directory } }), + const response = yield* requestInDirectory(EventPaths.event, directory) + const reader = yield* Queue.unbounded() + yield* response.stream.pipe( + Stream.runForEach((value) => Queue.offer(reader, value)), + Effect.forkScoped, ) - if (!response.body) return yield* Effect.die("missing SSE response body") - const reader = response.body.getReader() - yield* Effect.addFinalizer(() => Effect.promise(() => reader.cancel().catch(() => undefined))) return { response, reader } }) @@ -47,7 +42,7 @@ afterEach(async () => { await resetDatabase() }) -const it = testEffectShared(Bus.defaultLayer) +const it = testEffect(httpApiLayer) describe("event HttpApi", () => { it.instance( @@ -58,10 +53,10 @@ describe("event HttpApi", () => { const { response, reader } = yield* openEventStream(directory) expect(response.status).toBe(200) - expect(response.headers.get("content-type")).toContain("text/event-stream") - 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(response.headers["content-type"]).toContain("text/event-stream") + expect(response.headers["cache-control"]).toBe("no-cache, no-transform") + expect(response.headers["x-accel-buffering"]).toBe("no") + expect(response.headers["x-content-type-options"]).toBe("nosniff") expect(yield* readEvent(reader)).toMatchObject({ type: "server.connected", properties: {} }) }), { git: true, config: { formatter: false, lsp: false } }, @@ -76,8 +71,8 @@ describe("event HttpApi", () => { expect(yield* readEvent(reader)).toMatchObject({ type: "server.connected", properties: {} }) // If no second event arrives within 250ms, the stream is still open. - const status = yield* Effect.promise(() => reader.read()).pipe( - Effect.map((result) => (result.done ? ("closed" as const) : ("event" as const))), + const status = yield* Queue.take(reader).pipe( + Effect.as("event" as const), Effect.timeoutOrElse({ duration: "250 millis", orElse: () => Effect.succeed("open" as const) }), ) expect(status).toBe("open") @@ -86,16 +81,18 @@ describe("event HttpApi", () => { ) it.instance( - "delivers instance bus events after the initial event", + "delivers instance events after the initial event", () => Effect.gen(function* () { const { directory } = yield* TestInstance const { reader } = yield* openEventStream(directory) expect(yield* readEvent(reader)).toMatchObject({ type: "server.connected", properties: {} }) - yield* Bus.use.publish(ServerEvent.Connected, {}) - expect(yield* readEvent(reader)).toMatchObject({ type: "server.connected", properties: {} }) + const created = yield* requestInDirectory("/session", directory, { method: "POST" }) + expect(created.status).toBe(200) + expect(yield* readEvent(reader)).toMatchObject({ type: "session.created" }) }), { git: true, config: { formatter: false, lsp: false } }, ) + }) diff --git a/packages/opencode/test/server/httpapi-exercise/backend.ts b/packages/opencode/test/server/httpapi-exercise/backend.ts index ce94ddda91..6bd060e52c 100644 --- a/packages/opencode/test/server/httpapi-exercise/backend.ts +++ b/packages/opencode/test/server/httpapi-exercise/backend.ts @@ -56,7 +56,7 @@ function app(modules: Runtime, options: CallOptions) { ), ), ), - { disableLogger: true }, + { disableLogger: true, memoMap: modules.memoMap }, ).handler return (appCache[cacheKey] = { request(input: string | URL | Request, init?: RequestInit) { diff --git a/packages/opencode/test/server/httpapi-exercise/runner.ts b/packages/opencode/test/server/httpapi-exercise/runner.ts index b14647680c..86cbd13d9b 100644 --- a/packages/opencode/test/server/httpapi-exercise/runner.ts +++ b/packages/opencode/test/server/httpapi-exercise/runner.ts @@ -1,14 +1,16 @@ import { Flag } from "@opencode-ai/core/flag/flag" -import { Cause, Duration, Effect } from "effect" +import { SessionLegacy } from "@opencode-ai/core/session/legacy" +import { Cause, Duration, Effect, Layer, Scope } from "effect" import { TestLLMServer } from "../../lib/llm-server" import type { Config } from "../../../src/config/config" -import { ModelID, ProviderID } from "../../../src/provider/schema" + import type { MessageV2 } from "../../../src/session/message-v2" import { MessageID, PartID } from "../../../src/session/schema" import { call, callAuthProbe } from "./backend" import { original } from "./environment" import { runtime } from "./runtime" import type { ActiveScenario, Options, ProjectOptions, Result, Scenario, ScenarioContext, SeededContext } from "./types" +import { ProviderV2 } from "@opencode-ai/core/provider" export function runScenario(options: Options) { return (scenario: Scenario) => { @@ -85,18 +87,20 @@ function withContext( Effect.gen(function* () { yield* trace(options, scenario, `${label} runtime start`) const modules = yield* Effect.promise(() => runtime()) + const scope = yield* Scope.Scope + const app = yield* Layer.buildWithMemoMap(modules.AppLayer, modules.memoMap, scope) yield* trace(options, scenario, `${label} runtime done`) const path = context.dir?.path const instance = path ? yield* trace(options, scenario, `${label} instance load start`).pipe( Effect.andThen( modules.InstanceStore.Service.use((store) => store.load({ directory: path })).pipe( - Effect.provide(modules.AppLayer), + Effect.provide(app), 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.provide(app), ), ), Effect.catchCause(() => Effect.failCause(cause)), @@ -108,7 +112,7 @@ function withContext( ) : undefined const run = (effect: Effect.Effect) => - effect.pipe(Effect.provideService(modules.InstanceRef, instance), Effect.provide(modules.AppLayer)) + effect.pipe(Effect.provideService(modules.InstanceRef, instance), Effect.provide(app)) const directory = () => { if (!context.dir?.path) throw new Error("scenario needs a project directory") return context.dir.path @@ -140,18 +144,18 @@ function withContext( }), message: (sessionID, input) => Effect.gen(function* () { - const info: MessageV2.User = { + const info: SessionLegacy.User = { id: MessageID.ascending(), sessionID, role: "user", time: { created: Date.now() }, agent: "build", model: { - providerID: ProviderID.opencode, - modelID: ModelID.make("test"), + providerID: ProviderV2.ID.opencode, + modelID: ProviderV2.ModelID.make("test"), }, } - const part: MessageV2.TextPart = { + const part: SessionLegacy.TextPart = { id: PartID.ascending(), sessionID, messageID: info.id, diff --git a/packages/opencode/test/server/httpapi-exercise/runtime.ts b/packages/opencode/test/server/httpapi-exercise/runtime.ts index 7842752ad9..bd261cbe40 100644 --- a/packages/opencode/test/server/httpapi-exercise/runtime.ts +++ b/packages/opencode/test/server/httpapi-exercise/runtime.ts @@ -2,6 +2,7 @@ export type Runtime = { PublicApi: (typeof import("../../../src/server/routes/instance/httpapi/public"))["PublicApi"] HttpApiApp: (typeof import("../../../src/server/routes/instance/httpapi/server"))["HttpApiApp"] AppLayer: (typeof import("../../../src/effect/app-runtime"))["AppLayer"] + memoMap: (typeof import("@opencode-ai/core/effect/memo-map"))["memoMap"] InstanceRef: (typeof import("../../../src/effect/instance-ref"))["InstanceRef"] InstanceStore: (typeof import("../../../src/project/instance-store"))["InstanceStore"] Session: (typeof import("../../../src/session/session"))["Session"] @@ -21,6 +22,7 @@ export function runtime() { const publicApi = await import("../../../src/server/routes/instance/httpapi/public") const httpApiServer = await import("../../../src/server/routes/instance/httpapi/server") const appRuntime = await import("../../../src/effect/app-runtime") + const memoMap = await import("@opencode-ai/core/effect/memo-map") const instanceRef = await import("../../../src/effect/instance-ref") const instanceStore = await import("../../../src/project/instance-store") const session = await import("../../../src/session/session") @@ -34,6 +36,7 @@ export function runtime() { PublicApi: publicApi.PublicApi, HttpApiApp: httpApiServer.HttpApiApp, AppLayer: appRuntime.AppLayer, + memoMap: memoMap.memoMap, InstanceRef: instanceRef.InstanceRef, InstanceStore: instanceStore.InstanceStore, Session: session.Session, diff --git a/packages/opencode/test/server/httpapi-exercise/types.ts b/packages/opencode/test/server/httpapi-exercise/types.ts index e1fe93ba7e..49830686fb 100644 --- a/packages/opencode/test/server/httpapi-exercise/types.ts +++ b/packages/opencode/test/server/httpapi-exercise/types.ts @@ -1,4 +1,5 @@ import type { Duration, Effect } from "effect" +import { SessionLegacy } from "@opencode-ai/core/session/legacy" import type { Config } from "../../../src/config/config" import type { Project } from "../../../src/project/project" import type { Worktree } from "../../../src/worktree" @@ -57,7 +58,7 @@ export type ScenarioContext = { sessionGet: (sessionID: SessionID) => Effect.Effect project: () => Effect.Effect message: (sessionID: SessionID, input?: { text?: string }) => Effect.Effect - messages: (sessionID: SessionID) => 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 @@ -118,4 +119,4 @@ export type Result = export type SessionInfo = { id: SessionID; title: string; parentID?: SessionID } export type TodoInfo = { content: string; status: string; priority: string } -export type MessageSeed = { info: MessageV2.User; part: MessageV2.TextPart } +export type MessageSeed = { info: SessionLegacy.User; part: SessionLegacy.TextPart } diff --git a/packages/opencode/test/server/httpapi-experimental.test.ts b/packages/opencode/test/server/httpapi-experimental.test.ts index aa7e4946da..694956f460 100644 --- a/packages/opencode/test/server/httpapi-experimental.test.ts +++ b/packages/opencode/test/server/httpapi-experimental.test.ts @@ -1,41 +1,36 @@ import { afterEach, describe, expect } from "bun:test" import { Deferred, Effect, Fiber, Layer } from "effect" +import { HttpClient, HttpClientResponse } from "effect/unstable/http" import { eq } from "drizzle-orm" import { GlobalBus, type GlobalEvent } from "@/bus/global" -import { Server } from "../../src/server/server" import { ExperimentalPaths } from "../../src/server/routes/instance/httpapi/groups/experimental" import { Session } from "@/session/session" -import { SessionTable } from "@/session/session.sql" -import { Database } from "@/storage/db" +import { SessionTable } from "@opencode-ai/core/session/sql" +import { Database } from "@opencode-ai/core/database/database" +import { AccountV2 } from "@opencode-ai/core/account" +import { AccountTable } from "@opencode-ai/core/account/sql" import * as Log from "@opencode-ai/core/util/log" import { Worktree } from "../../src/worktree" import { resetDatabase } from "../fixture/db" import { disposeAllInstances, TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" +import { httpApiLayer, requestInDirectory } from "./httpapi-layer" void Log.init({ print: false }) -const it = testEffect(Layer.mergeAll(Session.defaultLayer)) +const it = testEffect(Layer.mergeAll(Session.defaultLayer, Database.defaultLayer, httpApiLayer)) const testWorktreeMutations = process.platform === "win32" ? it.instance.skip : it.instance -function app() { - return Server.Default().app -} - function request(path: string, directory: string, init: RequestInit = {}) { - return Effect.promise(() => { - const headers = new Headers(init.headers) - headers.set("x-opencode-directory", directory) - return Promise.resolve(app().request(path, { ...init, headers })) - }) + return requestInDirectory(path, directory, init) } function createSession(input?: Session.CreateInput) { return Session.use.create(input) } -function json(response: Response) { - return Effect.promise(() => response.json() as Promise) +function json(response: HttpClientResponse.HttpClientResponse) { + return response.json.pipe(Effect.map((value) => value as T)) } function waitReady(input: { directory?: string; name?: string }) { @@ -62,38 +57,50 @@ function waitReady(input: { directory?: string; name?: string }) { function insertAccount() { return Effect.acquireRelease( - Effect.sync(() => { - Database.Client() - .$client.prepare( - "INSERT INTO account (id, email, url, access_token, refresh_token, time_created, time_updated) VALUES (?, ?, ?, ?, ?, ?, ?)", - ) - .run( - "account-test", - "test@example.com", - "https://console.example.com", - "access", - "refresh", - Date.now(), - Date.now(), - ) + Effect.gen(function* () { + const { db } = yield* Database.Service + yield* db + .insert(AccountTable) + .values({ + id: AccountV2.ID.make("account-test"), + email: "test@example.com", + url: "https://console.example.com", + access_token: AccountV2.AccessToken.make("access"), + refresh_token: AccountV2.RefreshToken.make("refresh"), + time_created: Date.now(), + time_updated: Date.now(), + }) + .run() + .pipe(Effect.orDie) return "account-test" }), (id) => - Effect.sync(() => { - Database.Client().$client.prepare("DELETE FROM account WHERE id = ?").run(id) - }), + Database.Service.use(({ db }) => + db + .delete(AccountTable) + .where(eq(AccountTable.id, AccountV2.ID.make(id))) + .run() + .pipe(Effect.orDie), + ), ) } function setSessionUpdated(session: Session.Info, updated: number) { - return Effect.sync(() => { - Database.use((db) => - db.update(SessionTable).set({ time_updated: updated }).where(eq(SessionTable.id, session.id)).run(), - ) + return Effect.gen(function* () { + const { db } = yield* Database.Service + yield* db + .update(SessionTable) + .set({ time_updated: updated }) + .where(eq(SessionTable.id, session.id)) + .run() + .pipe(Effect.orDie) }) } -function withCreatedWorktree(directory: string, use: (info: Worktree.Info) => Effect.Effect) { +function withCreatedWorktree( + directory: string, + use: (info: Worktree.Info) => Effect.Effect, +) { const name = "api-test" const headers = { "content-type": "application/json" } return Effect.acquireUseRelease( @@ -242,7 +249,7 @@ describe("experimental HttpApi", () => { tmp.directory, ) expect(page.status).toBe(200) - expect(page.headers.get("x-next-cursor")).toBeTruthy() + expect(page.headers["x-next-cursor"]).toBeTruthy() const body = yield* json(page) expect(body.map((session) => session.id)).toEqual([second.id]) diff --git a/packages/opencode/test/server/httpapi-instance-context.test.ts b/packages/opencode/test/server/httpapi-instance-context.test.ts index c464c42db1..eec2f9fbc9 100644 --- a/packages/opencode/test/server/httpapi-instance-context.test.ts +++ b/packages/opencode/test/server/httpapi-instance-context.test.ts @@ -7,7 +7,7 @@ import * as Socket from "effect/unstable/socket/Socket" import { mkdir } from "node:fs/promises" import path from "node:path" import { registerAdapter } from "../../src/control-plane/adapters" -import { WorkspaceID } from "../../src/control-plane/schema" +import { WorkspaceV2 } from "@opencode-ai/core/workspace" import type { WorkspaceAdapter } from "../../src/control-plane/types" import { Workspace } from "../../src/control-plane/workspace" import { InstanceRef, WorkspaceRef } from "../../src/effect/instance-ref" @@ -236,7 +236,7 @@ describe("HttpApi instance context middleware", () => { it.live("uses configured workspace id instead of routing to the requested workspace", () => Effect.gen(function* () { - const fixedWorkspaceID = WorkspaceID.ascending() + const fixedWorkspaceID = WorkspaceV2.ID.ascending() yield* withFixedWorkspaceID(fixedWorkspaceID) const dir = yield* tmpdirScoped({ git: true }) @@ -264,7 +264,7 @@ describe("HttpApi instance context middleware", () => { it.live("falls through to local instead of MissingWorkspace when configured workspace id is set", () => Effect.gen(function* () { - const fixedWorkspaceID = WorkspaceID.ascending() + const fixedWorkspaceID = WorkspaceV2.ID.ascending() yield* withFixedWorkspaceID(fixedWorkspaceID) const dir = yield* tmpdirScoped({ git: true }) @@ -276,7 +276,7 @@ describe("HttpApi instance context middleware", () => { // MissingWorkspace response. With the env set, planRequest must skip the // MissingWorkspace branch and fall through to Local with the configured // workspace id. - const unknownWorkspaceID = WorkspaceID.ascending() + const unknownWorkspaceID = WorkspaceV2.ID.ascending() const response = yield* HttpClientRequest.get(`/probe?workspace=${unknownWorkspaceID}`).pipe( HttpClientRequest.setHeader("x-opencode-directory", dir), HttpClient.execute, @@ -292,7 +292,7 @@ describe("HttpApi instance context middleware", () => { it.live("keeps configured workspace id on control-plane routes without remote routing", () => Effect.gen(function* () { - const fixedWorkspaceID = WorkspaceID.ascending() + const fixedWorkspaceID = WorkspaceV2.ID.ascending() yield* withFixedWorkspaceID(fixedWorkspaceID) const dir = yield* tmpdirScoped({ git: true }) diff --git a/packages/opencode/test/server/httpapi-instance.test.ts b/packages/opencode/test/server/httpapi-instance.test.ts index 2087ad830f..65bdfa7c5c 100644 --- a/packages/opencode/test/server/httpapi-instance.test.ts +++ b/packages/opencode/test/server/httpapi-instance.test.ts @@ -4,12 +4,12 @@ import { describe, expect } from "bun:test" import { Config, Context, Effect, FileSystem, Layer, Path } from "effect" import { HttpClient, HttpClientRequest, HttpRouter, HttpServer } from "effect/unstable/http" import * as Socket from "effect/unstable/socket/Socket" -import { WorkspaceID } from "../../src/control-plane/schema" +import { WorkspaceV2 } from "@opencode-ai/core/workspace" import { ControlPaths } from "../../src/server/routes/instance/httpapi/groups/control" import { InstancePaths } from "../../src/server/routes/instance/httpapi/groups/instance" import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session" import { PermissionID } from "../../src/permission/schema" -import { ProjectID } from "../../src/project/schema" +import { ProjectV2 } from "@opencode-ai/core/project" import { QuestionID } from "../../src/question/schema" import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server" import { HEADER as FenceHeader } from "../../src/server/shared/fence" @@ -17,7 +17,7 @@ import { resetDatabase } from "../fixture/db" import { tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" -// Flip the experimental workspaces flag so SyncEvent.run actually writes to +// Flip the experimental workspaces flag so EventV2.run actually writes to // EventSequenceTable (the source of truth the fence middleware reads). Reset // the database around the test so per-instance state does not leak between // runs. resetDatabase() already calls disposeAllInstances(), so we don't @@ -76,7 +76,7 @@ describe("instance HttpApi", () => { it.live("emits a sync fence header for fixed-workspace mutations", () => Effect.gen(function* () { const originalWorkspaceID = Flag.OPENCODE_WORKSPACE_ID - Flag.OPENCODE_WORKSPACE_ID = WorkspaceID.ascending() + Flag.OPENCODE_WORKSPACE_ID = WorkspaceV2.ID.ascending() yield* Effect.addFinalizer(() => Effect.sync(() => { Flag.OPENCODE_WORKSPACE_ID = originalWorkspaceID @@ -98,7 +98,7 @@ describe("instance HttpApi", () => { it.live("does not emit sync fence headers for fixed-workspace reads or no-op mutations", () => Effect.gen(function* () { const originalWorkspaceID = Flag.OPENCODE_WORKSPACE_ID - Flag.OPENCODE_WORKSPACE_ID = WorkspaceID.ascending() + Flag.OPENCODE_WORKSPACE_ID = WorkspaceV2.ID.ascending() yield* Effect.addFinalizer(() => Effect.sync(() => { Flag.OPENCODE_WORKSPACE_ID = originalWorkspaceID @@ -209,7 +209,7 @@ describe("instance HttpApi", () => { it.live("returns typed not found bodies for missing projects", () => Effect.gen(function* () { const dir = yield* tmpdirScoped({ git: true }) - const projectID = ProjectID.make("project_missing") + const projectID = ProjectV2.ID.make("project_missing") const response = yield* Effect.promise(() => HttpApiApp.webHandler().handler( new Request(`http://localhost/project/${projectID}`, { diff --git a/packages/opencode/test/server/httpapi-layer.ts b/packages/opencode/test/server/httpapi-layer.ts new file mode 100644 index 0000000000..a780391ae9 --- /dev/null +++ b/packages/opencode/test/server/httpapi-layer.ts @@ -0,0 +1,33 @@ +import { NodeHttpServer, NodeServices } from "@effect/platform-node" +import { Config, Layer } from "effect" +import { HttpClient, HttpClientRequest, HttpRouter, HttpServer } from "effect/unstable/http" +import { layerWebSocketConstructorGlobal } from "effect/unstable/socket/Socket" +import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server" + +const servedRoutes: Layer.Layer = HttpRouter.serve( + HttpApiApp.routes, + { + disableListenLog: true, + disableLogger: true, + }, +) + +export const httpApiLayer = servedRoutes.pipe( + Layer.provide(layerWebSocketConstructorGlobal), + Layer.provideMerge(NodeHttpServer.layerTest), + Layer.provideMerge(NodeServices.layer), +) + +export function request(path: string, init?: RequestInit) { + const url = new URL(path, "http://localhost") + return HttpClientRequest.fromWeb(new Request(url, init)).pipe( + HttpClientRequest.setUrl(url.pathname), + HttpClient.execute, + ) +} + +export function requestInDirectory(path: string, directory: string, init: RequestInit = {}) { + const headers = new Headers(init.headers) + headers.set("x-opencode-directory", directory) + return request(path, { ...init, headers }) +} diff --git a/packages/opencode/test/server/httpapi-provider.test.ts b/packages/opencode/test/server/httpapi-provider.test.ts index 25181f3b2d..52b5087057 100644 --- a/packages/opencode/test/server/httpapi-provider.test.ts +++ b/packages/opencode/test/server/httpapi-provider.test.ts @@ -2,12 +2,12 @@ import { describe, expect } from "bun:test" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Effect, Layer } from "effect" import path from "path" -import { Server } from "../../src/server/server" import * as Log from "@opencode-ai/core/util/log" import { resetDatabase } from "../fixture/db" import { TestInstance } from "../fixture/fixture" import { markPluginDependenciesReady } from "../fixture/plugin" import { testEffect } from "../lib/effect" +import { httpApiLayer, request } from "./httpapi-layer" void Log.init({ print: false }) @@ -18,16 +18,12 @@ const testStateLayer = Layer.effectDiscard( ), ) -const it = testEffect(Layer.mergeAll(testStateLayer, AppFileSystem.defaultLayer)) +const it = testEffect(Layer.mergeAll(testStateLayer, AppFileSystem.defaultLayer, httpApiLayer)) const projectOptions = { config: { formatter: false, lsp: false } } const providerID = "test-oauth-parity" const oauthURL = "https://example.com/oauth" const oauthInstructions = "Finish OAuth" -function app() { - return Server.Default().app -} - function providerListHasFetch(list: unknown) { if (!Array.isArray(list)) return false return list.some((item: unknown) => { @@ -77,41 +73,34 @@ function hasProviderMutationMarker(input: unknown, key: "all" | "providers", id: } function requestAuthorize(input: { - app: ReturnType providerID: string method: number headers: HeadersInit inputs?: Record }) { - return Effect.promise(async () => { - const response = await input.app.request(`/provider/${input.providerID}/oauth/authorize`, { + return Effect.gen(function* () { + const response = yield* request(`/provider/${input.providerID}/oauth/authorize`, { method: "POST", headers: input.headers, body: JSON.stringify({ method: input.method, ...(input.inputs ? { inputs: input.inputs } : {}) }), }) return { status: response.status, - body: await response.text(), + body: yield* response.text, } }) } -function requestCallback(input: { - app: ReturnType - providerID: string - method: number - headers: HeadersInit - code?: string -}) { - return Effect.promise(async () => { - const response = await input.app.request(`/provider/${input.providerID}/oauth/callback`, { +function requestCallback(input: { providerID: string; method: number; headers: HeadersInit; code?: string }) { + return Effect.gen(function* () { + const response = yield* request(`/provider/${input.providerID}/oauth/callback`, { method: "POST", headers: input.headers, body: JSON.stringify({ method: input.method, ...(input.code ? { code: input.code } : {}) }), }) return { status: response.status, - body: await response.text(), + body: yield* response.text, } }) } @@ -277,15 +266,13 @@ describe("provider HttpApi", () => { it.instance.skip( "returns public v2 provider not found errors", Effect.gen(function* () { - const instance = yield* TestInstance - const response = yield* Effect.promise(() => - Promise.resolve( - app().request("/api/provider/missing", { headers: { "x-opencode-directory": instance.directory } }), - ), - ) + const directory = (yield* TestInstance).directory + const response = yield* request("/api/provider/missing", { + headers: { "x-opencode-directory": directory }, + }) expect(response.status).toBe(404) - expect(yield* Effect.promise(() => response.json())).toEqual({ + expect(yield* response.json).toEqual({ _tag: "ProviderNotFoundError", providerID: "missing", message: "Provider not found: missing", @@ -297,13 +284,9 @@ describe("provider HttpApi", () => { it.instance( "serves OAuth authorize response shapes", Effect.gen(function* () { - const instance = yield* TestInstance - yield* writeProviderAuthPlugin(instance.directory) - const headers = { "x-opencode-directory": instance.directory, "content-type": "application/json" } - const server = app() - + const directory = (yield* TestInstance).directory + const headers = { "x-opencode-directory": directory, "content-type": "application/json" } const api = yield* requestAuthorize({ - app: server, providerID, method: 0, headers, @@ -315,7 +298,6 @@ describe("provider HttpApi", () => { expect(api).toEqual({ status: 200, body: "null" }) const oauth = yield* requestAuthorize({ - app: server, providerID, method: 1, headers, @@ -326,21 +308,19 @@ describe("provider HttpApi", () => { instructions: oauthInstructions, }) }), - projectOptions, + { ...projectOptions, init: writeProviderAuthPlugin }, 30000, ) it.instance( "returns declared provider auth validation errors", Effect.gen(function* () { - const instance = yield* TestInstance - yield* writeProviderAuthValidationPlugin(instance.directory) + const directory = (yield* TestInstance).directory const response = yield* requestAuthorize({ - app: app(), providerID: "test-oauth-validation", method: 0, inputs: { token: "nope" }, - headers: { "x-opencode-directory": instance.directory, "content-type": "application/json" }, + headers: { "x-opencode-directory": directory, "content-type": "application/json" }, }) expect(response.status).toBe(400) @@ -349,19 +329,18 @@ describe("provider HttpApi", () => { data: { field: "token", message: "Token must be ok" }, }) }), - projectOptions, + { ...projectOptions, init: writeProviderAuthValidationPlugin }, 30000, ) it.instance( "returns declared provider auth callback errors", Effect.gen(function* () { - const instance = yield* TestInstance + const directory = (yield* TestInstance).directory const response = yield* requestCallback({ - app: app(), providerID, method: 0, - headers: { "x-opencode-directory": instance.directory, "content-type": "application/json" }, + headers: { "x-opencode-directory": directory, "content-type": "application/json" }, }) expect(response.status).toBe(400) @@ -377,54 +356,48 @@ describe("provider HttpApi", () => { it.instance( "serves provider lists when auth loaders add runtime fetch options", Effect.gen(function* () { - const instance = yield* TestInstance - yield* writeFunctionOptionsPlugin(instance.directory) + const directory = (yield* TestInstance).directory yield* setEnvScoped( "OPENCODE_AUTH_CONTENT", JSON.stringify({ google: { type: "oauth", refresh: "dummy", access: "dummy", expires: 9999999999999 }, }), ) - const headers = { "x-opencode-directory": instance.directory } - const providerResponse = yield* Effect.promise(() => Promise.resolve(app().request("/provider", { headers }))) - const configResponse = yield* Effect.promise(() => - Promise.resolve(app().request("/config/providers", { headers })), - ) + const headers = { "x-opencode-directory": directory } + const providerResponse = yield* request("/provider", { headers }) + const configResponse = yield* request("/config/providers", { headers }) expect(providerResponse.status).toBe(200) expect(configResponse.status).toBe(200) - const providerBody = yield* Effect.promise(() => providerResponse.json()) - const configBody = yield* Effect.promise(() => configResponse.json()) + const providerBody = yield* providerResponse.json + const configBody = yield* configResponse.json expect(hasProviderWithFetch(providerBody, "all")).toBe(false) expect(hasProviderWithFetch(configBody, "providers")).toBe(false) expect(hasNonZeroModelCost(providerBody, "all", "google")).toBe(true) expect(hasNonZeroModelCost(configBody, "providers", "google")).toBe(true) }), - projectOptions, + { ...projectOptions, init: writeFunctionOptionsPlugin }, ) it.instance( "keeps provider.models hook input mutations out of provider state", Effect.gen(function* () { - const instance = yield* TestInstance - yield* writeProviderModelsMutationPlugin(instance.directory) + const directory = (yield* TestInstance).directory - const headers = { "x-opencode-directory": instance.directory } - const providerResponse = yield* Effect.promise(() => Promise.resolve(app().request("/provider", { headers }))) - const configResponse = yield* Effect.promise(() => - Promise.resolve(app().request("/config/providers", { headers })), - ) + const headers = { "x-opencode-directory": directory } + const providerResponse = yield* request("/provider", { headers }) + const configResponse = yield* request("/config/providers", { headers }) expect(providerResponse.status).toBe(200) expect(configResponse.status).toBe(200) - const providerBody = yield* Effect.promise(() => providerResponse.json()) - const configBody = yield* Effect.promise(() => configResponse.json()) + const providerBody = yield* providerResponse.json + const configBody = yield* configResponse.json expect(hasProviderMutationMarker(providerBody, "all", "google")).toBe(false) expect(hasProviderMutationMarker(configBody, "providers", "google")).toBe(false) expect(hasNonZeroModelCost(providerBody, "all", "google")).toBe(true) }), - projectOptions, + { ...projectOptions, init: writeProviderModelsMutationPlugin }, ) }) diff --git a/packages/opencode/test/server/httpapi-schema-error-body.test.ts b/packages/opencode/test/server/httpapi-schema-error-body.test.ts index c221bdd19b..f217bf844d 100644 --- a/packages/opencode/test/server/httpapi-schema-error-body.test.ts +++ b/packages/opencode/test/server/httpapi-schema-error-body.test.ts @@ -1,19 +1,23 @@ import { afterEach, describe, expect } from "bun:test" -import { Effect } from "effect" +import { Effect, Layer } from "effect" +import { HttpClientResponse } from "effect/unstable/http" import { eq } from "drizzle-orm" -import * as Database from "@/storage/db" -import { ModelID, ProviderID } from "../../src/provider/schema" -import { Server } from "../../src/server/server" +import { Database } from "@opencode-ai/core/database/database" + import { Session } from "@/session/session" import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session" import { SyncPaths } from "../../src/server/routes/instance/httpapi/groups/sync" import { MessageID, PartID } from "../../src/session/schema" -import { PartTable } from "@/session/session.sql" +import { PartTable } from "@opencode-ai/core/session/sql" import { resetDatabase } from "../fixture/db" import { disposeAllInstances, TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { httpApiLayer, requestInDirectory } from "./httpapi-layer" -const it = testEffect(Session.defaultLayer) +const it = testEffect(Layer.mergeAll(Session.defaultLayer, Database.defaultLayer, httpApiLayer)) + +const text = (response: HttpClientResponse.HttpClientResponse) => response.text afterEach(async () => { await disposeAllInstances() @@ -28,7 +32,7 @@ const seedCorruptStepFinishPart = Effect.gen(function* () { role: "user", sessionID: info.id, agent: "build", - model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") }, + model: { providerID: ProviderV2.ID.make("test"), modelID: ProviderV2.ModelID.make("test") }, time: { created: Date.now() }, }) const partID = PartID.ascending() @@ -43,22 +47,20 @@ const seedCorruptStepFinishPart = Effect.gen(function* () { }) // Schema.Finite still rejects NaN at encode: exact mirror of the corrupt row // that broke the user's session in the OMO/Windows bug. - yield* Effect.sync(() => - Database.use((db) => - db - .update(PartTable) - .set({ - data: { - type: "step-finish", - reason: "stop", - cost: 0, - tokens: { input: 0, output: NaN, reasoning: 0, cache: { read: 0, write: 0 } }, - } as never, // drizzle's .set() can't narrow the discriminated union - }) - .where(eq(PartTable.id, partID)) - .run(), - ), - ) + const { db } = yield* Database.Service + yield* db + .update(PartTable) + .set({ + data: { + type: "step-finish", + reason: "stop", + cost: 0, + tokens: { input: 0, output: NaN, reasoning: 0, cache: { read: 0, write: 0 } }, + } as never, // drizzle's .set() can't narrow the discriminated union + }) + .where(eq(PartTable.id, partID)) + .run() + .pipe(Effect.orDie) return info.id }) @@ -68,16 +70,14 @@ describe("schema-rejection wire shape", () => { () => Effect.gen(function* () { const test = yield* TestInstance - const res = yield* Effect.promise(async () => - Server.Default().app.request(SyncPaths.history, { - method: "POST", - headers: { "x-opencode-directory": test.directory, "content-type": "application/json" }, - body: JSON.stringify({ aggregate: -1 }), - }), - ) - const body = yield* Effect.promise(async () => res.text()) + const res = yield* requestInDirectory(SyncPaths.history, test.directory, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ aggregate: -1 }), + }) + const body = yield* text(res) expect(res.status).toBe(400) - expect(res.headers.get("content-type") ?? "").toContain("application/json") + expect(res.headers["content-type"] ?? "").toContain("application/json") const parsed = JSON.parse(body) expect(parsed).toMatchObject({ name: "BadRequest", @@ -96,8 +96,8 @@ describe("schema-rejection wire shape", () => { const test = yield* TestInstance // /find/file?limit=999999 violates the limit constraint check. const url = `/find/file?query=foo&limit=999999&directory=${encodeURIComponent(test.directory)}` - const res = yield* Effect.promise(async () => Server.Default().app.request(url)) - const body = yield* Effect.promise(async () => res.text()) + const res = yield* requestInDirectory(url, test.directory) + const body = yield* text(res) expect(res.status).toBe(400) const parsed = JSON.parse(body) expect(parsed).toMatchObject({ name: "BadRequest", data: { kind: "Query" } }) @@ -110,12 +110,8 @@ describe("schema-rejection wire shape", () => { () => Effect.gen(function* () { const test = yield* TestInstance - const res = yield* Effect.promise(async () => - Server.Default().app.request("/api/session?limit=0", { - headers: { "x-opencode-directory": test.directory }, - }), - ) - const parsed = JSON.parse(yield* Effect.promise(async () => res.text())) + const res = yield* requestInDirectory("/api/session?limit=0", test.directory) + const parsed = JSON.parse(yield* text(res)) expect(res.status).toBe(400) expect(parsed).toMatchObject({ _tag: "InvalidRequestError", kind: "Query" }) expect(parsed.message).toEqual(expect.any(String)) @@ -132,14 +128,12 @@ describe("schema-rejection wire shape", () => { Effect.gen(function* () { const test = yield* TestInstance const huge = "X".repeat(50_000) - const res = yield* Effect.promise(async () => - Server.Default().app.request(SyncPaths.history, { - method: "POST", - headers: { "x-opencode-directory": test.directory, "content-type": "application/json" }, - body: JSON.stringify({ aggregate: huge }), - }), - ) - const body = yield* Effect.promise(async () => res.text()) + const res = yield* requestInDirectory(SyncPaths.history, test.directory, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ aggregate: huge }), + }) + const body = yield* text(res) expect(res.status).toBe(400) // 1 KB cap + small JSON envelope ≈ <2 KB — never tens of KB. expect(body.length).toBeLessThan(2 * 1024) @@ -156,10 +150,10 @@ describe("schema-rejection wire shape", () => { const test = yield* TestInstance const sessionID = yield* seedCorruptStepFinishPart const url = `${SessionPaths.messages.replace(":sessionID", sessionID)}?limit=80&directory=${encodeURIComponent(test.directory)}` - const res = yield* Effect.promise(async () => Server.Default().app.request(url)) - const body = yield* Effect.promise(async () => res.text()) + const res = yield* requestInDirectory(url, test.directory) + const body = yield* text(res) expect(res.status).toBe(400) - expect(res.headers.get("content-type") ?? "").toContain("application/json") + expect(res.headers["content-type"] ?? "").toContain("application/json") const parsed = JSON.parse(body) expect(parsed).toMatchObject({ name: "BadRequest", data: { kind: "Body" } }) // Field path in data.message — what made this PR worth shipping. diff --git a/packages/opencode/test/server/httpapi-sdk.test.ts b/packages/opencode/test/server/httpapi-sdk.test.ts index 6e99fa7b12..972891f9a4 100644 --- a/packages/opencode/test/server/httpapi-sdk.test.ts +++ b/packages/opencode/test/server/httpapi-sdk.test.ts @@ -1,7 +1,8 @@ import { afterEach, describe, expect } from "bun:test" -import { ConfigProvider, Deferred, Effect, Layer } from "effect" +import { SessionLegacy } from "@opencode-ai/core/session/legacy" +import { Deferred, Effect, Layer } from "effect" import type * as Scope from "effect/Scope" -import { HttpRouter } from "effect/unstable/http" +import { HttpServer } from "effect/unstable/http" import { ChildProcessSpawner } from "effect/unstable/process" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" @@ -10,11 +11,9 @@ import { createOpencodeClient } from "@opencode-ai/sdk/v2" import { validateSession } from "../../src/cli/cmd/tui/validate-session" import { InstanceBootstrap } from "../../src/project/bootstrap-service" import { InstanceStore } from "../../src/project/instance-store" -import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server" -import { Server } from "../../src/server/server" import { MessageID, PartID, SessionID } from "../../src/session/schema" import { MessageV2 } from "../../src/session/message-v2" -import { ModelID, ProviderID } from "../../src/provider/schema" + import type { Config } from "@/config/config" import { Session as SessionNs } from "@/session/session" import { errorMessage } from "../../src/util/error" @@ -24,6 +23,9 @@ import { resetDatabase } from "../fixture/db" import { disposeAllInstances, TestInstance, tmpdirScoped } from "../fixture/fixture" import { awaitWithTimeout, testEffect } from "../lib/effect" import { testProviderConfig } from "../lib/test-provider" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { Database } from "@opencode-ai/core/database/database" +import { httpApiLayer } from "./httpapi-layer" const noopBootstrap = Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void })) const it = testEffect( @@ -31,6 +33,8 @@ const it = testEffect( AppFileSystem.defaultLayer, CrossSpawnSpawner.defaultLayer, InstanceStore.defaultLayer.pipe(Layer.provide(noopBootstrap)), + Database.defaultLayer, + httpApiLayer, ), ) @@ -45,55 +49,47 @@ type SdkResult = { response: Response; data?: unknown; error?: unknown } type Captured = { status: number; data?: unknown; error?: unknown } type ProjectFixture = { sdk: Sdk; directory: string } type LlmProjectFixture = ProjectFixture & { llm: TestLLMServer["Service"] } -type TestServices = AppFileSystem.Service | ChildProcessSpawner.ChildProcessSpawner | InstanceStore.Service +type TestServices = + | AppFileSystem.Service + | ChildProcessSpawner.ChildProcessSpawner + | InstanceStore.Service + | HttpServer.HttpServer type TestScope = Scope.Scope | TestServices -function app(serverPath: ServerPath, input?: { password?: string; username?: string }) { - Flag.OPENCODE_SERVER_PASSWORD = input?.password - Flag.OPENCODE_SERVER_USERNAME = input?.username - if (serverPath === "default") return Server.Default().app - - const handler = HttpRouter.toWebHandler( - HttpApiApp.routes.pipe( - Layer.provide( - ConfigProvider.layer( - ConfigProvider.fromUnknown({ - OPENCODE_SERVER_PASSWORD: input?.password, - OPENCODE_SERVER_USERNAME: input?.username, - }), - ), - ), - ), - { disableLogger: true }, - ).handler - return { - fetch: (request: Request) => handler(request, HttpApiApp.context), - request(input: string | URL | Request, init?: RequestInit) { - return this.fetch(input instanceof Request ? input : new Request(new URL(input, "http://localhost"), init)) - }, - } -} - function client( serverPath: ServerPath, directory?: string, input?: { password?: string; username?: string; headers?: Record }, ) { - return createOpencodeClient({ - baseUrl: "http://localhost", - directory, - headers: input?.headers, - fetch: serverFetch(serverPath, input), - }) + return serverFetch(serverPath, input).pipe( + Effect.map((fetch) => + createOpencodeClient({ + baseUrl: "http://localhost", + directory, + headers: input?.headers, + fetch, + }), + ), + ) } function serverFetch(serverPath: ServerPath, input?: { password?: string; username?: string }) { - const serverApp = app(serverPath, input) - return Object.assign( - async (request: RequestInfo | URL, init?: RequestInit) => - await serverApp.fetch(request instanceof Request ? request : new Request(request, init)), - { preconnect: globalThis.fetch.preconnect }, - ) satisfies typeof globalThis.fetch + return HttpServer.HttpServer.use((server) => + Effect.sync(() => { + void serverPath + Flag.OPENCODE_SERVER_PASSWORD = input?.password + Flag.OPENCODE_SERVER_USERNAME = input?.username + const baseUrl = HttpServer.formatAddress(server.address) + return Object.assign( + async (request: RequestInfo | URL, init?: RequestInit) => { + const source = request instanceof Request ? request : new Request(request, init) + const url = new URL(source.url) + return globalThis.fetch(new Request(new URL(`${url.pathname}${url.search}`, baseUrl), source)) + }, + { preconnect: globalThis.fetch.preconnect }, + ) satisfies typeof globalThis.fetch + }), + ) } function authorization(username: string, password: string) { @@ -204,22 +200,14 @@ function httpapiInstance( Effect.gen(function* () { const instance = yield* TestInstance yield* options.setup?.(instance.directory) ?? Effect.void - return yield* run({ sdk: client(options.serverPath, instance.directory), directory: instance.directory }) + return yield* run({ sdk: yield* client(options.serverPath, instance.directory), directory: instance.directory }) }), { git: options.git ?? true, config: { formatter: false, lsp: false, ...options.config } }, ) } function serverPathParity(name: string, scenario: (serverPath: ServerPath) => Effect.Effect) { - it.live( - name, - Effect.gen(function* () { - const standard = yield* scenario("default") - yield* resetState() - const raw = yield* scenario("raw") - expect(raw).toEqual(standard) - }), - ) + it.live(name, scenario("raw")) } function withProject( @@ -237,7 +225,7 @@ function withProject( config: { formatter: false, lsp: false, ...options.config }, }) yield* options.setup?.(directory) ?? Effect.void - return yield* run({ sdk: client(serverPath, directory), directory }) + return yield* run({ sdk: yield* client(serverPath, directory), directory }) }) } @@ -310,9 +298,9 @@ function seedMessage(directory: string, sessionID: string) { role: "user", time: { created: Date.now() }, agent: "test", - model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") }, + model: { providerID: ProviderV2.ID.make("test"), modelID: ProviderV2.ModelID.make("test") }, tools: {}, - } satisfies MessageV2.User) + } satisfies SessionLegacy.User) const part = yield* svc.updatePart({ id: PartID.ascending(), sessionID: id, @@ -338,7 +326,7 @@ describe("HttpApi SDK", () => { httpapi( "uses the generated SDK for global and control routes", Effect.gen(function* () { - const sdk = client("raw") + const sdk = yield* client("raw") const health = yield* call(() => sdk.global.health()) const log = yield* call(() => sdk.app.log({ service: "httpapi-sdk-test", level: "info", message: "hello" })) @@ -380,7 +368,7 @@ describe("HttpApi SDK", () => { serverPathParity("matches generated SDK global and control behavior", (serverPath) => Effect.gen(function* () { - const sdk = client(serverPath) + const sdk = yield* client(serverPath) const health = yield* capture(() => sdk.global.health()) const log = yield* capture(() => sdk.app.log({ service: "sdk-parity", level: "info", message: "hello" })) const invalidAuth = yield* capture(() => sdk.auth.set({ providerID: "test" })) @@ -394,9 +382,11 @@ describe("HttpApi SDK", () => { ) serverPathParity("matches generated SDK global event stream", (serverPath) => - firstEvent((signal) => client(serverPath).global.event({ signal })).pipe( - Effect.map((event) => ({ type: record(record(event).payload).type })), - ), + Effect.gen(function* () { + const sdk = yield* client(serverPath) + const event = yield* firstEvent((signal) => sdk.global.event({ signal })) + return { type: record(record(event).payload).type } + }), ) serverPathParity("matches generated SDK instance event stream", (serverPath) => @@ -441,12 +431,13 @@ describe("HttpApi SDK", () => { withStandardProject(serverPath, ({ directory }) => Effect.gen(function* () { const sessionID = "ses_206f84f18ffeZ6hhD7pFYAiW5T" + const fetch = yield* serverFetch(serverPath) const thrown = yield* captureThrown(() => validateSession({ url: "http://localhost", directory, sessionID, - fetch: serverFetch(serverPath), + fetch, }), ) expect(errorMessage(thrown)).toBe(`Session not found: ${sessionID}`) @@ -460,21 +451,18 @@ describe("HttpApi SDK", () => { { serverPath: "raw", setup: writeStandardFiles }, ({ directory }) => Effect.gen(function* () { - const missing = yield* capture(() => - client("raw", directory, { password: "secret" }).file.read({ path: "hello.txt" }), - ) - const bad = yield* capture(() => - client("raw", directory, { - password: "secret", - headers: { authorization: authorization("opencode", "wrong") }, - }).file.read({ path: "hello.txt" }), - ) - const good = yield* capture(() => - client("raw", directory, { - password: "secret", - headers: { authorization: authorization("opencode", "secret") }, - }).file.read({ path: "hello.txt" }), - ) + const missingSdk = yield* client("raw", directory, { password: "secret" }) + const missing = yield* capture(() => missingSdk.file.read({ path: "hello.txt" })) + const badSdk = yield* client("raw", directory, { + password: "secret", + headers: { authorization: authorization("opencode", "wrong") }, + }) + const bad = yield* capture(() => badSdk.file.read({ path: "hello.txt" })) + const goodSdk = yield* client("raw", directory, { + password: "secret", + headers: { authorization: authorization("opencode", "secret") }, + }) + const good = yield* capture(() => goodSdk.file.read({ path: "hello.txt" })) return { statuses: statuses({ missing, bad, good }), @@ -640,7 +628,7 @@ describe("HttpApi SDK", () => { ), ) - // Regression: SyncEvent must publish on the same ProjectBus the /event handler + // Regression: EventV2 must publish on the same ProjectBus the /event handler // subscribes to, AND the /event stream must forward handler ALS/context into the // body-pump fiber. Drives the full SDK → /event → Session.updatePart → sync.run → // bus.publish → SDK subscriber path. Goes red if either the publisher uses a diff --git a/packages/opencode/test/server/httpapi-session.test.ts b/packages/opencode/test/server/httpapi-session.test.ts index db06a7a77c..7eac7d4f35 100644 --- a/packages/opencode/test/server/httpapi-session.test.ts +++ b/packages/opencode/test/server/httpapi-session.test.ts @@ -1,27 +1,31 @@ import { afterEach, describe, expect } from "bun:test" +import { NodeHttpServer, NodeServices } from "@effect/platform-node" +import { SessionLegacy } from "@opencode-ai/core/session/legacy" import { mkdir } from "node:fs/promises" import path from "node:path" -import { Cause, Effect, Exit, Layer } from "effect" +import { Cause, Config, Effect, Exit, Layer } from "effect" +import { HttpClient, HttpClientRequest, HttpClientResponse, HttpRouter, HttpServer } from "effect/unstable/http" +import { layerWebSocketConstructorGlobal } from "effect/unstable/socket/Socket" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Flag } from "@opencode-ai/core/flag/flag" import { registerAdapter } from "../../src/control-plane/adapters" import type { WorkspaceAdapter } from "../../src/control-plane/types" import { Workspace } from "../../src/control-plane/workspace" import { PermissionID } from "../../src/permission/schema" -import { ModelID, ProviderID } from "../../src/provider/schema" + import { InstanceBootstrap } from "../../src/project/bootstrap" import { InstanceBootstrap as InstanceBootstrapService } from "../../src/project/bootstrap-service" import { InstanceStore } from "../../src/project/instance-store" import { Project } from "../../src/project/project" -import { Server } from "../../src/server/server" +import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server" import * as HttpSessionError from "../../src/server/routes/instance/httpapi/handlers/session-errors" import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session" import { Session } from "@/session/session" import { MessageID, PartID, SessionID, type SessionID as SessionIDType } from "../../src/session/schema" import { MessageV2 } from "../../src/session/message-v2" -import { Database } from "@/storage/db" -import { SessionMessageTable, SessionTable } from "@/session/session.sql" -import { SessionMessage } from "@opencode-ai/core/session-message" +import { Database } from "@opencode-ai/core/database/database" +import { SessionMessageTable, SessionTable } from "@opencode-ai/core/session/sql" +import { SessionMessage } from "@opencode-ai/core/session/message" import { ModelV2 } from "@opencode-ai/core/model" import { ProviderV2 } from "@opencode-ai/core/provider" import * as DateTime from "effect/DateTime" @@ -45,11 +49,28 @@ const instanceStoreLayer = InstanceStore.defaultLayer.pipe( Layer.succeed(InstanceBootstrapService.Service, InstanceBootstrapService.Service.of({ run: Effect.void })), ), ) -const it = testEffect(Layer.mergeAll(instanceStoreLayer, Project.defaultLayer, Session.defaultLayer, workspaceLayer)) - -function app() { - return Server.Default().app -} +const servedRoutes: Layer.Layer = HttpRouter.serve( + HttpApiApp.routes, + { + disableListenLog: true, + disableLogger: true, + }, +) +const httpApiLayer = servedRoutes.pipe( + Layer.provide(layerWebSocketConstructorGlobal), + Layer.provideMerge(NodeHttpServer.layerTest), + Layer.provideMerge(NodeServices.layer), +) +const it = testEffect( + Layer.mergeAll( + instanceStoreLayer, + Project.defaultLayer, + Session.defaultLayer, + workspaceLayer, + Database.defaultLayer, + httpApiLayer, + ), +) function pathFor(path: string, params: Record) { return Object.entries(params).reduce((result, [key, value]) => result.replace(`:${key}`, value), path) @@ -67,7 +88,7 @@ function createTextMessage(sessionID: SessionIDType, text: string) { role: "user", sessionID, agent: "build", - model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") }, + model: { providerID: ProviderV2.ID.make("test"), modelID: ProviderV2.ModelID.make("test") }, time: { created: Date.now() }, }) const part = yield* svc.updatePart({ @@ -109,7 +130,7 @@ const createLocalWorkspace = (input: { projectID: Project.Info["id"]; type: stri ) const insertLegacyAssistantMessage = (sessionID: SessionIDType, time = 1) => - Effect.sync(() => { + Effect.gen(function* () { const message = new SessionMessage.Assistant({ id: SessionMessage.ID.create(), type: "assistant", @@ -122,90 +143,93 @@ const insertLegacyAssistantMessage = (sessionID: SessionIDType, time = 1) => time: { created: DateTime.makeUnsafe(time) }, content: [], }) - Database.use((db) => - db - .insert(SessionMessageTable) - .values([ - { - id: message.id, - session_id: sessionID, - type: message.type, - time_created: time, - data: { - time: { created: time }, - agent: message.agent, - model: message.model, - content: message.content, - } as NonNullable<(typeof SessionMessageTable.$inferInsert)["data"]>, - }, - ]) - .run(), - ) + const { db } = yield* Database.Service + yield* db + .insert(SessionMessageTable) + .values([ + { + id: message.id, + session_id: sessionID, + type: message.type, + time_created: time, + data: { + time: { created: time }, + agent: message.agent, + model: message.model, + content: message.content, + } as NonNullable<(typeof SessionMessageTable.$inferInsert)["data"]>, + }, + ]) + .run() + .pipe(Effect.orDie) }) const insertCorruptV2Message = (sessionID: SessionIDType, time = 1) => - Effect.sync(() => - Database.use((db) => - db - .insert(SessionMessageTable) - .values([ - { - id: SessionMessage.ID.create(), - session_id: sessionID, - type: "assistant", - time_created: time, - data: {} as NonNullable<(typeof SessionMessageTable.$inferInsert)["data"]>, - }, - ]) - .run(), - ), - ) + Effect.gen(function* () { + const { db } = yield* Database.Service + yield* db + .insert(SessionMessageTable) + .values([ + { + id: SessionMessage.ID.create(), + session_id: sessionID, + type: "assistant", + time_created: time, + data: {} as NonNullable<(typeof SessionMessageTable.$inferInsert)["data"]>, + }, + ]) + .run() + .pipe(Effect.orDie) + }) const setLegacySummaryDiff = (sessionID: SessionIDType) => - Effect.sync(() => - Database.use((db) => - db - .update(SessionTable) - .set({ - summary_additions: 1, - summary_deletions: 0, - summary_files: 1, - summary_diffs: [{ additions: 1, deletions: 0 }], - }) - .where(eq(SessionTable.id, sessionID)) - .run(), - ), - ) + Effect.gen(function* () { + const { db } = yield* Database.Service + yield* db + .update(SessionTable) + .set({ + summary_additions: 1, + summary_deletions: 0, + summary_files: 1, + summary_diffs: [{ additions: 1, deletions: 0 }], + }) + .where(eq(SessionTable.id, sessionID)) + .run() + .pipe(Effect.orDie) + }) const getWorkspaceID = (sessionID: SessionIDType) => - Effect.sync(() => - Database.use((db) => - db - .select({ workspaceID: SessionTable.workspace_id }) - .from(SessionTable) - .where(eq(SessionTable.id, sessionID)) - .get(), - ), - ) + Effect.gen(function* () { + const { db } = yield* Database.Service + return yield* db + .select({ workspaceID: SessionTable.workspace_id }) + .from(SessionTable) + .where(eq(SessionTable.id, sessionID)) + .get() + .pipe(Effect.orDie) + }) const clearSessionPath = (sessionID: SessionIDType) => - Effect.sync(() => - Database.use((db) => db.update(SessionTable).set({ path: null }).where(eq(SessionTable.id, sessionID)).run()), - ) + Effect.gen(function* () { + const { db } = yield* Database.Service + yield* db.update(SessionTable).set({ path: null }).where(eq(SessionTable.id, sessionID)).run().pipe(Effect.orDie) + }) function request(path: string, init?: RequestInit) { - return Effect.promise(async () => app().request(path, init)) + const url = new URL(path, "http://localhost") + return HttpClientRequest.fromWeb(new Request(url, init)).pipe( + HttpClientRequest.setUrl(url.pathname), + HttpClient.execute, + ) } -function json(response: Response) { - return Effect.promise(async () => { - if (response.status !== 200) throw new Error(await response.text()) - return (await response.json()) as T - }) +function json(response: HttpClientResponse.HttpClientResponse) { + if (response.status !== 200) return response.text.pipe(Effect.flatMap((text) => Effect.die(new Error(text)))) + return response.json.pipe(Effect.map((value) => value as T)) } -function responseJson(response: Response) { - return Effect.promise(() => response.json()) +function responseJson(response: HttpClientResponse.HttpClientResponse) { + return response.json } function requestJson(path: string, init?: RequestInit) { @@ -338,8 +362,8 @@ describe("session HttpApi", () => { const messages = yield* request(`${pathFor(SessionPaths.messages, { sessionID: parent.id })}?limit=1`, { headers, }) - const messagePage = yield* json(messages) - const nextCursor = messages.headers.get("x-next-cursor") + const messagePage = yield* json(messages) + const nextCursor = messages.headers["x-next-cursor"] expect(nextCursor).toBeTruthy() expect(messagePage[0]?.parts[0]).toMatchObject({ type: "text" }) @@ -355,7 +379,7 @@ describe("session HttpApi", () => { ).toBe(400) expect( - yield* requestJson( + yield* requestJson( pathFor(SessionPaths.message, { sessionID: parent.id, messageID: message.info.id }), { headers }, ), @@ -788,9 +812,9 @@ describe("session HttpApi", () => { const response = yield* request(route, { headers }) - expect(response.headers.get("x-next-cursor")).toBeTruthy() - expect(response.headers.get("link")).toContain("limit=1") - expect(response.headers.get("access-control-expose-headers")?.toLowerCase()).toContain("x-next-cursor") + expect(response.headers["x-next-cursor"]).toBeTruthy() + expect(response.headers["link"]).toContain("limit=1") + expect(response.headers["access-control-expose-headers"]?.toLowerCase()).toContain("x-next-cursor") }), { git: true, config: { formatter: false, lsp: false } }, ) @@ -805,7 +829,7 @@ describe("session HttpApi", () => { const first = yield* createTextMessage(session.id, "first") const second = yield* createTextMessage(session.id, "second") - const updated = yield* requestJson( + const updated = yield* requestJson( pathFor(SessionPaths.updatePart, { sessionID: session.id, messageID: first.info.id, diff --git a/packages/opencode/test/server/httpapi-sync.test.ts b/packages/opencode/test/server/httpapi-sync.test.ts index 6a1c1624cc..0db044a86a 100644 --- a/packages/opencode/test/server/httpapi-sync.test.ts +++ b/packages/opencode/test/server/httpapi-sync.test.ts @@ -1,7 +1,6 @@ import { afterEach, describe, expect, mock, spyOn } from "bun:test" -import { Context, Effect } from "effect" +import { Context, Effect, Layer } from "effect" import { Flag } from "@opencode-ai/core/flag/flag" -import { Server } from "../../src/server/server" import { SyncPaths } from "../../src/server/routes/instance/httpapi/groups/sync" import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server" import { Session } from "@/session/session" @@ -9,16 +8,13 @@ import * as Log from "@opencode-ai/core/util/log" import { resetDatabase } from "../fixture/db" import { disposeAllInstances, TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" +import { httpApiLayer, requestInDirectory } from "./httpapi-layer" void Log.init({ print: false }) const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES const context = Context.empty() as Context.Context -const it = testEffect(Session.defaultLayer) - -function app() { - return Server.Default().app -} +const it = testEffect(Layer.mergeAll(Session.defaultLayer, httpApiLayer)) afterEach(async () => { mock.restore() @@ -38,23 +34,17 @@ describe("sync HttpApi", () => { const info = spyOn(Log.create({ service: "server.sync" }), "info") const session = yield* Session.use.create({ title: "sync" }) - const started = yield* Effect.promise(() => - Promise.resolve(app().request(SyncPaths.start, { method: "POST", headers })), - ) + const started = yield* requestInDirectory(SyncPaths.start, tmp.directory, { method: "POST", headers }) expect(started.status).toBe(200) - expect(yield* Effect.promise(() => started.json())).toBe(true) + expect(yield* started.json).toBe(true) - const history = yield* Effect.promise(() => - Promise.resolve( - app().request(SyncPaths.history, { - method: "POST", - headers, - body: JSON.stringify({}), - }), - ), - ) + const history = yield* requestInDirectory(SyncPaths.history, tmp.directory, { + method: "POST", + headers, + body: JSON.stringify({}), + }) expect(history.status).toBe(200) - const rows = (yield* Effect.promise(() => history.json())) as Array<{ + const rows = (yield* history.json) as Array<{ id: string aggregate_id: string seq: number @@ -63,28 +53,24 @@ describe("sync HttpApi", () => { }> expect(rows.map((row) => row.aggregate_id)).toContain(session.id) - const replayed = yield* Effect.promise(() => - Promise.resolve( - app().request(SyncPaths.replay, { - method: "POST", - headers, - body: JSON.stringify({ - directory: tmp.directory, - events: rows - .filter((row) => row.aggregate_id === session.id) - .map((row) => ({ - id: row.id, - aggregateID: row.aggregate_id, - seq: row.seq, - type: row.type, - data: row.data, - })), - }), - }), - ), - ) + const replayed = yield* requestInDirectory(SyncPaths.replay, tmp.directory, { + method: "POST", + headers, + body: JSON.stringify({ + directory: tmp.directory, + events: rows + .filter((row) => row.aggregate_id === session.id) + .map((row) => ({ + id: row.id, + aggregateID: row.aggregate_id, + seq: row.seq, + type: row.type, + data: row.data, + })), + }), + }) expect(replayed.status).toBe(200) - expect(yield* Effect.promise(() => replayed.json())).toEqual({ sessionID: session.id }) + expect(yield* replayed.json).toEqual({ sessionID: session.id }) expect(info.mock.calls.some(([message]) => message === "sync replay requested")).toBe(true) expect(info.mock.calls.some(([message]) => message === "sync replay complete")).toBe(true) }), @@ -123,15 +109,11 @@ describe("sync HttpApi", () => { ] for (const item of cases) { - const response = yield* Effect.promise(() => - Promise.resolve( - app().request(item.path, { - method: "POST", - headers, - body: JSON.stringify(item.body), - }), - ), - ) + const response = yield* requestInDirectory(item.path, tmp.directory, { + method: "POST", + headers, + body: JSON.stringify(item.body), + }) expect(response.status).toBe(400) } }), diff --git a/packages/opencode/test/server/httpapi-workspace-routing.test.ts b/packages/opencode/test/server/httpapi-workspace-routing.test.ts index 50a400674a..f2f72dc5fc 100644 --- a/packages/opencode/test/server/httpapi-workspace-routing.test.ts +++ b/packages/opencode/test/server/httpapi-workspace-routing.test.ts @@ -16,10 +16,11 @@ import Http from "node:http" import { mkdir } from "node:fs/promises" import path from "node:path" import { registerAdapter } from "../../src/control-plane/adapters" -import { WorkspaceID } from "../../src/control-plane/schema" +import { WorkspaceV2 } from "@opencode-ai/core/workspace" import type { WorkspaceAdapter } from "../../src/control-plane/types" import { Workspace } from "../../src/control-plane/workspace" -import { WorkspaceTable } from "../../src/control-plane/workspace.sql" +import { WorkspaceTable } from "@opencode-ai/core/control-plane/workspace.sql" +import { Database } from "@opencode-ai/core/database/database" import { Project } from "../../src/project/project" import { Session } from "../../src/session/session" import { WorkspacePaths } from "../../src/server/routes/instance/httpapi/groups/workspace" @@ -30,7 +31,6 @@ import { workspaceRoutingLayer, } from "../../src/server/routes/instance/httpapi/middleware/workspace-routing" import { HEADER as FenceHeader } from "../../src/server/shared/fence" -import { Database } from "../../src/storage/db" import { resetDatabase } from "../fixture/db" import { workspaceLayerWithRuntimeFlags } from "../fixture/workspace" import { tmpdirScoped } from "../fixture/fixture" @@ -54,6 +54,7 @@ const it = testEffect( testStateLayer, NodeHttpServer.layerTest, NodeServices.layer, + Database.defaultLayer, Project.defaultLayer, workspaceLayer, Socket.layerWebSocketConstructorGlobal, @@ -165,10 +166,11 @@ const insertRemoteWorkspaceWithoutSync = (input: { type: string url: string }) => - Effect.sync(() => { - const id = WorkspaceID.ascending() + Effect.gen(function* () { + const id = WorkspaceV2.ID.ascending() registerAdapter(input.projectID, input.type, remoteAdapter(path.join(input.dir, `.${input.type}`), input.url)) - Database.use((db) => db.insert(WorkspaceTable).values({ id, type: input.type, project_id: input.projectID }).run()) + const { db } = yield* Database.Service + yield* db.insert(WorkspaceTable).values({ id, type: input.type, project_id: input.projectID }).run().pipe(Effect.orDie) return id }) @@ -327,9 +329,9 @@ describe("HttpApi workspace routing middleware", () => { Effect.gen(function* () { const dir = yield* tmpdirScoped({ git: true }) const project = yield* Project.use.fromDirectory(dir) - const workspaceID = WorkspaceID.ascending() + const workspaceID = WorkspaceV2.ID.ascending() const type = "remote-http-fence-target" - const waited = yield* Ref.make<{ workspaceID: WorkspaceID; state: Record } | undefined>(undefined) + const waited = yield* Ref.make<{ workspaceID: WorkspaceV2.ID; state: Record } | undefined>(undefined) const remoteUrl = yield* startRemoteWorkspaceHttpServer(() => HttpServerResponse.json( @@ -438,7 +440,7 @@ describe("HttpApi workspace routing middleware", () => { it.live("returns a missing workspace response for unknown workspace ids", () => Effect.gen(function* () { - const workspaceID = WorkspaceID.ascending("wrk_missing") + const workspaceID = WorkspaceV2.ID.ascending("wrk_missing") // If the middleware resolves the workspace first, this handler is never // reached and the response should be the middleware error response. yield* serveProbe diff --git a/packages/opencode/test/server/httpapi-workspace.test.ts b/packages/opencode/test/server/httpapi-workspace.test.ts index 35d6cb313e..15bfe42797 100644 --- a/packages/opencode/test/server/httpapi-workspace.test.ts +++ b/packages/opencode/test/server/httpapi-workspace.test.ts @@ -1,16 +1,16 @@ import { afterEach, describe, expect, mock } from "bun:test" -import { NodeServices } from "@effect/platform-node" import { mkdir } from "node:fs/promises" import path from "node:path" -import { Effect, Layer } from "effect" +import { Effect, Layer, Stream } from "effect" import { Flag } from "@opencode-ai/core/flag/flag" import { registerAdapter } from "../../src/control-plane/adapters" -import { WorkspaceID } from "../../src/control-plane/schema" +import { WorkspaceV2 } from "@opencode-ai/core/workspace" import type { WorkspaceAdapter } from "../../src/control-plane/types" import { Workspace } from "../../src/control-plane/workspace" import { WorkspacePaths } from "../../src/server/routes/instance/httpapi/groups/workspace" import { EventPaths } from "../../src/server/routes/instance/httpapi/groups/event" import { Session } from "@/session/session" +import { Database } from "@opencode-ai/core/database/database" import * as Log from "@opencode-ai/core/util/log" import { Server } from "../../src/server/server" import { resetDatabase } from "../fixture/db" @@ -19,8 +19,8 @@ import { InstanceBootstrap } from "../../src/project/bootstrap" import { InstanceStore } from "../../src/project/instance-store" import { Project } from "../../src/project/project" import { InstancePaths } from "../../src/server/routes/instance/httpapi/groups/instance" -import { WorkspaceRef } from "../../src/effect/instance-ref" import { testEffect } from "../lib/effect" +import { httpApiLayer, requestInDirectory } from "./httpapi-layer" void Log.init({ print: false }) @@ -29,14 +29,29 @@ const workspaceLayer = Workspace.defaultLayer.pipe( Layer.provide(InstanceStore.defaultLayer), Layer.provide(InstanceBootstrap.defaultLayer), ) -const it = testEffect(Layer.mergeAll(NodeServices.layer, Project.defaultLayer, Session.defaultLayer, workspaceLayer)) +const it = testEffect( + Layer.mergeAll( + Project.defaultLayer, + Session.defaultLayer, + workspaceLayer, + InstanceStore.defaultLayer.pipe(Layer.provide(InstanceBootstrap.defaultLayer)), + Database.defaultLayer, + httpApiLayer, + ), +) function request(path: string, directory: string, init: RequestInit = {}) { - return Effect.promise(() => { - const headers = new Headers(init.headers) - headers.set("x-opencode-directory", directory) - return Promise.resolve(Server.Default().app.request(path, { ...init, headers })) - }) + return requestInDirectory(path, directory, init) +} + +function requestDefault(path: string, directory: string, init: RequestInit = {}) { + return requestInDirectory(path, directory, init) +} + +function requestServer(path: string, directory: string, init: RequestInit = {}) { + const headers = new Headers(init.headers) + headers.set("x-opencode-directory", directory) + return Effect.promise(() => Promise.resolve(Server.Default().app.request(path, { ...init, headers }))) } function localAdapter(directory: string): WorkspaceAdapter { @@ -180,17 +195,17 @@ describe("workspace HttpApi", () => { ]) expect(adapters.status).toBe(200) - expect(yield* Effect.promise(() => adapters.json())).toContainEqual({ + expect(yield* adapters.json).toContainEqual({ type: "worktree", name: "Worktree", description: "Create a git worktree", }) expect(workspaces.status).toBe(200) - expect(yield* Effect.promise(() => workspaces.json())).toEqual([]) + expect(yield* workspaces.json).toEqual([]) expect(status.status).toBe(200) - expect(yield* Effect.promise(() => status.json())).toEqual([]) + expect(yield* status.json).toEqual([]) }), ) @@ -207,7 +222,7 @@ describe("workspace HttpApi", () => { body: JSON.stringify({ type: "local-test", branch: null }), }) expect(created.status).toBe(200) - const workspace = (yield* Effect.promise(() => created.json())) as Workspace.Info + const workspace = (yield* created.json) as Workspace.Info expect(workspace).toMatchObject({ type: "local-test", name: "local-test" }) const session = yield* Session.use.create({}).pipe(provideInstance(dir)) @@ -220,11 +235,11 @@ describe("workspace HttpApi", () => { const removed = yield* request(WorkspacePaths.remove.replace(":id", workspace.id), dir, { method: "DELETE" }) expect(removed.status).toBe(200) - expect(yield* Effect.promise(() => removed.json())).toMatchObject({ id: workspace.id }) + expect(yield* removed.json).toMatchObject({ id: workspace.id }) const listed = yield* request(WorkspacePaths.list, dir) expect(listed.status).toBe(200) - expect(yield* Effect.promise(() => listed.json())).toEqual([]) + expect(yield* listed.json).toEqual([]) }), ) @@ -240,7 +255,7 @@ describe("workspace HttpApi", () => { expect(response.status).toBe(204) const listed = yield* request(WorkspacePaths.list, dir) - expect(yield* Effect.promise(() => listed.json())).toMatchObject([ + expect(yield* listed.json).toMatchObject([ { type, name: "listed-test", @@ -256,7 +271,7 @@ describe("workspace HttpApi", () => { Effect.gen(function* () { const dir = yield* tmpdirScoped({ git: true }) const session = yield* Session.use.create({}).pipe(provideInstance(dir)) - const workspaceID = WorkspaceID.ascending("wrk_missing_warp") + const workspaceID = WorkspaceV2.ID.ascending("wrk_missing_warp") const response = yield* request(WorkspacePaths.warp, dir, { method: "POST", @@ -265,7 +280,7 @@ describe("workspace HttpApi", () => { }) expect(response.status).toBe(404) - expect(yield* Effect.promise(() => response.json())).toEqual({ + expect(yield* response.json).toEqual({ name: "NotFoundError", data: { message: `Workspace not found: ${workspaceID}` }, }) @@ -286,7 +301,7 @@ describe("workspace HttpApi", () => { }) expect(created.status).toBe(200) - expect((yield* Effect.promise(() => created.json())) as Workspace.Info).toMatchObject({ + expect((yield* created.json) as Workspace.Info).toMatchObject({ type: "local-test", name: "local-test", }) @@ -298,7 +313,7 @@ describe("workspace HttpApi", () => { Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true const dir = yield* tmpdirScoped({ git: true }) - const created = yield* request(WorkspacePaths.list, dir, { + const created = yield* requestServer(WorkspacePaths.list, dir, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ type: "worktree", branch: null }), @@ -323,7 +338,7 @@ describe("workspace HttpApi", () => { headers: { "content-type": "application/json" }, body: JSON.stringify({ type: "local-target", branch: null }), }) - const workspace = (yield* Effect.promise(() => created.json())) as Workspace.Info + const workspace = (yield* created.json) as Workspace.Info const url = new URL(`http://localhost${InstancePaths.path}`) url.searchParams.set("workspace", workspace.id) @@ -331,7 +346,7 @@ describe("workspace HttpApi", () => { const response = yield* request(url.toString(), dir) expect(response.status).toBe(200) - expect(yield* Effect.promise(() => response.json())).toMatchObject({ directory: workspaceDir }) + expect(yield* response.json).toMatchObject({ directory: workspaceDir }) yield* request(WorkspacePaths.remove.replace(":id", workspace.id), dir, { method: "DELETE" }) }), ) @@ -374,19 +389,19 @@ describe("workspace HttpApi", () => { "x-target-auth": "secret", }), ) - const created = yield* request(WorkspacePaths.list, dir, { + const created = yield* requestDefault(WorkspacePaths.list, dir, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ type: "remote-target", branch: null }), }) - const workspace = (yield* Effect.promise(() => created.json())) as Workspace.Info + const workspace = (yield* created.json) as Workspace.Info const url = new URL("http://localhost/config") url.searchParams.set("workspace", workspace.id) url.searchParams.set("keep", "yes") try { - const response = yield* request(url.toString(), dir, { + const response = yield* requestDefault(url.toString(), dir, { method: "PATCH", headers: { "accept-encoding": "br", @@ -396,10 +411,10 @@ describe("workspace HttpApi", () => { body: JSON.stringify({ $schema: "https://opencode.ai/config.json" }), }) - const responseBody = yield* Effect.promise(() => response.text()) + const responseBody = yield* response.text expect({ status: response.status, body: responseBody }).toMatchObject({ status: 201 }) - expect(response.headers.get("content-length")).toBeNull() - expect(response.headers.get("x-remote")).toBe("yes") + expect(response.headers["content-length"]).toBeUndefined() + expect(response.headers["x-remote"]).toBe("yes") expect(JSON.parse(responseBody)).toEqual({ proxied: true, path: "/base/config", keep: "yes", workspace: null }) const forwarded = proxied.filter((item) => new URL(item.url).pathname === "/base/config") expect(forwarded).toEqual([ @@ -420,16 +435,13 @@ describe("workspace HttpApi", () => { eventURL.searchParams.set("workspace", workspace.id) const eventResponse = yield* request(eventURL.toString(), dir) expect(eventResponse.status).toBe(200) - expect(eventResponse.headers.get("content-type")).toContain("text/event-stream") - if (!eventResponse.body) throw new Error("missing proxied event response body") - const eventReader = eventResponse.body.getReader() - const event = yield* Effect.promise(() => eventReader.read()) - yield* Effect.promise(() => eventReader.cancel()) - expect(new TextDecoder().decode(event.value)).toContain("server.connected") + expect(eventResponse.headers["content-type"]).toContain("text/event-stream") + const event = Array.from(yield* eventResponse.stream.pipe(Stream.take(1), Stream.runCollect))[0] + expect(new TextDecoder().decode(event)).toContain("server.connected") expect(proxied.some((item) => new URL(item.url).pathname === "/base/event")).toBe(true) } finally { void remote.stop(true) - yield* request(WorkspacePaths.remove.replace(":id", workspace.id), dir, { method: "DELETE" }) + yield* requestDefault(WorkspacePaths.remove.replace(":id", workspace.id), dir, { method: "DELETE" }) } }), ) @@ -453,24 +465,29 @@ describe("workspace HttpApi", () => { "remote-session-target", remoteAdapter(path.join(dir, ".remote-session"), `http://127.0.0.1:${remote.port}/base`), ) - const created = yield* request(WorkspacePaths.list, dir, { + const created = yield* requestDefault(WorkspacePaths.list, dir, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ type: "remote-session-target", branch: null }), }) - const workspace = (yield* Effect.promise(() => created.json())) as Workspace.Info - const session = yield* Session.use - .create() - .pipe(Effect.provideService(WorkspaceRef, workspace.id), provideInstance(dir)) + const workspace = (yield* created.json) as Workspace.Info + const sessionResponse = yield* requestDefault("/session", dir, { method: "POST" }) + const session = (yield* sessionResponse.json) as Session.Info + const warped = yield* requestDefault(WorkspacePaths.warp, dir, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ id: workspace.id, sessionID: session.id }), + }) + expect(warped.status).toBe(204) try { - const response = yield* request(`http://localhost/session/${session.id}/message`, dir, { + const response = yield* requestDefault(`http://localhost/session/${session.id}/message`, dir, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ parts: [{ type: "text", text: "hello" }] }), }) - const responseBody = yield* Effect.promise(() => response.text()) + const responseBody = yield* response.text expect({ status: response.status, body: responseBody }).toMatchObject({ status: 200 }) expect(JSON.parse(responseBody)).toEqual({ proxied: true, path: `/base/session/${session.id}/message` }) expect(proxied.filter((item) => new URL(item.url).pathname === `/base/session/${session.id}/message`)).toEqual([ @@ -491,7 +508,7 @@ describe("workspace HttpApi", () => { ]) } finally { void remote.stop(true) - yield* request(WorkspacePaths.remove.replace(":id", workspace.id), dir, { method: "DELETE" }) + yield* requestDefault(WorkspacePaths.remove.replace(":id", workspace.id), dir, { method: "DELETE" }) } }), ) diff --git a/packages/opencode/test/server/negative-tokens-regression.test.ts b/packages/opencode/test/server/negative-tokens-regression.test.ts index 290023ead7..e79f655fb8 100644 --- a/packages/opencode/test/server/negative-tokens-regression.test.ts +++ b/packages/opencode/test/server/negative-tokens-regression.test.ts @@ -6,20 +6,21 @@ // strict `NonNegativeInt` schema then made every load of the message list // fail to encode, killing Desktop boot for every user with such a row. import { describe, expect } from "bun:test" -import { Effect } from "effect" +import { Effect, Layer } from "effect" import { eq } from "drizzle-orm" -import { ModelID, ProviderID } from "../../src/provider/schema" -import { Server } from "../../src/server/server" + import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session" import { Session } from "@/session/session" import { MessageID, PartID } from "../../src/session/schema" -import * as Database from "@/storage/db" -import { PartTable } from "@/session/session.sql" +import { Database } from "@opencode-ai/core/database/database" +import { PartTable } from "@opencode-ai/core/session/sql" import { resetDatabase } from "../fixture/db" import { TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { httpApiLayer, requestInDirectory } from "./httpapi-layer" -const it = testEffect(Session.defaultLayer) +const it = testEffect(Layer.mergeAll(Session.defaultLayer, Database.defaultLayer, httpApiLayer)) function seedNegativeTokenSession() { return Effect.gen(function* () { @@ -30,7 +31,7 @@ function seedNegativeTokenSession() { role: "user", sessionID: info.id, agent: "build", - model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") }, + model: { providerID: ProviderV2.ID.make("test"), modelID: ProviderV2.ModelID.make("test") }, time: { created: Date.now() }, }) const partID = PartID.ascending() @@ -46,20 +47,20 @@ function seedNegativeTokenSession() { // Bypass the schema with a direct SQL update to install the // negative `output` value we want to test loading. - Database.use((db) => - db - .update(PartTable) - .set({ - data: { - type: "step-finish", - reason: "stop", - cost: 0, - tokens: { input: 0, output: -42, reasoning: 0, cache: { read: 0, write: 0 } }, - } as never, - }) - .where(eq(PartTable.id, partID)) - .run(), - ) + const { db } = yield* Database.Service + yield* db + .update(PartTable) + .set({ + data: { + type: "step-finish", + reason: "stop", + cost: 0, + tokens: { input: 0, output: -42, reasoning: 0, cache: { read: 0, write: 0 } }, + } as never, + }) + .where(eq(PartTable.id, partID)) + .run() + .pipe(Effect.orDie) return info.id }) @@ -73,7 +74,7 @@ describe("messages endpoint tolerates legacy negative token counts", () => { const test = yield* TestInstance const sessionID = yield* seedNegativeTokenSession() const url = `${SessionPaths.messages.replace(":sessionID", sessionID)}?limit=80&directory=${encodeURIComponent(test.directory)}` - const res = yield* Effect.promise(async () => Server.Default().app.request(url)) + const res = yield* requestInDirectory(url, test.directory) expect(res.status, "messages endpoint 400'd on legacy negative tokens").not.toBe(400) }), { git: true, config: { formatter: false, lsp: false } }, diff --git a/packages/opencode/test/server/project-init-git.test.ts b/packages/opencode/test/server/project-init-git.test.ts index b22777861b..fb9118a2fb 100644 --- a/packages/opencode/test/server/project-init-git.test.ts +++ b/packages/opencode/test/server/project-init-git.test.ts @@ -1,17 +1,18 @@ import { afterEach, describe, expect } from "bun:test" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Effect, Layer } from "effect" +import { HttpClientResponse } from "effect/unstable/http" import path from "path" import { InstanceRef } from "../../src/effect/instance-ref" import { InstanceBootstrap } from "../../src/project/bootstrap-service" import { InstanceStore } from "../../src/project/instance-store" import { GlobalBus, type GlobalEvent } from "../../src/bus/global" import { Snapshot } from "../../src/snapshot" -import { Server } from "../../src/server/server" import * as Log from "@opencode-ai/core/util/log" import { resetDatabase } from "../fixture/db" import { disposeAllInstances, TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" +import { httpApiLayer, requestInDirectory } from "./httpapi-layer" void Log.init({ print: false }) @@ -23,18 +24,16 @@ afterEach(async () => { const noopBootstrap = Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void })) const testInstanceStore = InstanceStore.defaultLayer.pipe(Layer.provide(noopBootstrap)) -const it = testEffect(Layer.mergeAll(AppFileSystem.defaultLayer, Snapshot.defaultLayer, testInstanceStore)) +const it = testEffect( + Layer.mergeAll(AppFileSystem.defaultLayer, Snapshot.defaultLayer, testInstanceStore, httpApiLayer), +) function request(directory: string, url: string, init: RequestInit = {}) { - return Effect.promise(() => { - const headers = new Headers(init.headers) - headers.set("x-opencode-directory", directory) - return Promise.resolve(Server.Default().app.request(url, { ...init, headers })) - }) + return requestInDirectory(url, directory, init) } -function json(response: Response) { - return Effect.promise(() => response.json() as Promise) +function json(response: HttpClientResponse.HttpClientResponse) { + return response.json.pipe(Effect.map((value) => value as T)) } function collectGlobalEvents() { diff --git a/packages/opencode/test/server/session-actions.test.ts b/packages/opencode/test/server/session-actions.test.ts index 1a16016a56..093ec43c03 100644 --- a/packages/opencode/test/server/session-actions.test.ts +++ b/packages/opencode/test/server/session-actions.test.ts @@ -1,14 +1,14 @@ import { afterEach, describe, expect, mock } from "bun:test" -import { Effect } from "effect" -import { Server } from "../../src/server/server" +import { Effect, Layer } from "effect" import { Session as SessionNs } from "@/session/session" import * as Log from "@opencode-ai/core/util/log" import { disposeAllInstances, TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" +import { httpApiLayer, requestInDirectory } from "./httpapi-layer" void Log.init({ print: false }) -const it = testEffect(SessionNs.defaultLayer) +const it = testEffect(Layer.mergeAll(SessionNs.defaultLayer, httpApiLayer)) afterEach(async () => { mock.restore() @@ -21,73 +21,52 @@ describe("session action routes", () => { () => Effect.gen(function* () { const test = yield* TestInstance - const app = Server.Default().app - const headers = { "Content-Type": "application/json", "x-opencode-directory": test.directory } + const headers = { "Content-Type": "application/json" } - const created = yield* Effect.promise(() => - Promise.resolve( - app.request("/session", { - method: "POST", - headers, - body: JSON.stringify({ - title: "meta-session", - metadata: { source: "sdk", trace: { id: "abc" } }, - }), - }), - ), - ) + const created = yield* requestInDirectory("/session", test.directory, { + method: "POST", + headers, + body: JSON.stringify({ + title: "meta-session", + metadata: { source: "sdk", trace: { id: "abc" } }, + }), + }) expect(created.status).toBe(200) - const session = (yield* Effect.promise(() => created.json())) as SessionNs.Info + const session = (yield* created.json) as SessionNs.Info expect(session.metadata).toEqual({ source: "sdk", trace: { id: "abc" } }) - const updated = yield* Effect.promise(() => - Promise.resolve( - app.request(`/session/${session.id}`, { - method: "PATCH", - headers, - body: JSON.stringify({ metadata: { source: "sdk", trace: { id: "def" }, tags: ["one"] } }), - }), - ), - ) + const updated = yield* requestInDirectory(`/session/${session.id}`, test.directory, { + method: "PATCH", + headers, + body: JSON.stringify({ metadata: { source: "sdk", trace: { id: "def" }, tags: ["one"] } }), + }) expect(updated.status).toBe(200) - const next = (yield* Effect.promise(() => updated.json())) as SessionNs.Info + const next = (yield* updated.json) as SessionNs.Info expect(next.metadata).toEqual({ source: "sdk", trace: { id: "def" }, tags: ["one"] }) - const fetched = yield* Effect.promise(() => - Promise.resolve( - app.request(`/session/${session.id}`, { headers: { "x-opencode-directory": test.directory } }), - ), - ) + const fetched = yield* requestInDirectory(`/session/${session.id}`, test.directory) expect(fetched.status).toBe(200) - expect(((yield* Effect.promise(() => fetched.json())) as SessionNs.Info).metadata).toEqual(next.metadata) + expect(((yield* fetched.json) as SessionNs.Info).metadata).toEqual(next.metadata) - const forked = yield* Effect.promise(() => - Promise.resolve( - app.request(`/session/${session.id}/fork`, { - method: "POST", - headers, - body: JSON.stringify({}), - }), - ), - ) + const forked = yield* requestInDirectory(`/session/${session.id}/fork`, test.directory, { + method: "POST", + headers, + body: JSON.stringify({}), + }) expect(forked.status).toBe(200) - const fork = (yield* Effect.promise(() => forked.json())) as SessionNs.Info + const fork = (yield* forked.json) as SessionNs.Info expect(fork.metadata).toEqual(next.metadata) - const reset = yield* Effect.promise(() => - Promise.resolve( - app.request(`/session/${session.id}`, { - method: "PATCH", - headers, - body: JSON.stringify({ metadata: {} }), - }), - ), - ) + const reset = yield* requestInDirectory(`/session/${session.id}`, test.directory, { + method: "PATCH", + headers, + body: JSON.stringify({ metadata: {} }), + }) expect(reset.status).toBe(200) - expect(((yield* Effect.promise(() => reset.json())) as SessionNs.Info).metadata).toEqual({}) + expect(((yield* reset.json) as SessionNs.Info).metadata).toEqual({}) yield* SessionNs.Service.use((svc) => svc.remove(fork.id).pipe(Effect.ignore)) yield* SessionNs.Service.use((svc) => svc.remove(session.id).pipe(Effect.ignore)) @@ -104,17 +83,10 @@ describe("session action routes", () => { SessionNs.use.remove(created.id).pipe(Effect.ignore), ) - const res = yield* Effect.promise(() => - Promise.resolve( - Server.Default().app.request(`/session/${session.id}/abort`, { - method: "POST", - headers: { "x-opencode-directory": test.directory }, - }), - ), - ) + const res = yield* requestInDirectory(`/session/${session.id}/abort`, test.directory, { method: "POST" }) expect(res.status).toBe(200) - expect(yield* Effect.promise(() => res.json())).toBe(true) + expect(yield* res.json).toBe(true) }), { git: true }, ) diff --git a/packages/opencode/test/server/session-diff-missing-patch.test.ts b/packages/opencode/test/server/session-diff-missing-patch.test.ts index cec1dbcd9c..f7f22b4327 100644 --- a/packages/opencode/test/server/session-diff-missing-patch.test.ts +++ b/packages/opencode/test/server/session-diff-missing-patch.test.ts @@ -11,7 +11,6 @@ */ import { afterEach, describe, expect } from "bun:test" import { Effect, Layer } from "effect" -import { Server } from "@/server/server" import { SessionPaths } from "@/server/routes/instance/httpapi/groups/session" import { Session } from "@/session/session" import { Storage } from "@/storage/storage" @@ -19,10 +18,11 @@ import { resetDatabase } from "../fixture/db" import { disposeAllInstances, TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" import * as Log from "@opencode-ai/core/util/log" +import { httpApiLayer, requestInDirectory } from "./httpapi-layer" void Log.init({ print: false }) -const it = testEffect(Layer.mergeAll(Session.defaultLayer, Storage.defaultLayer)) +const it = testEffect(Layer.mergeAll(Session.defaultLayer, Storage.defaultLayer, httpApiLayer)) afterEach(async () => { await disposeAllInstances() @@ -51,16 +51,13 @@ describe("session diff with missing patch (#26574)", () => { storage.write(["session_diff", session.id], [{ file: "legacy.txt", additions: 1, deletions: 0 }]), ) - const response = yield* Effect.promise(() => - Promise.resolve( - Server.Default().app.request(pathFor(SessionPaths.diff, { sessionID: session.id }), { - headers: { "x-opencode-directory": test.directory }, - }), - ), + const response = yield* requestInDirectory( + pathFor(SessionPaths.diff, { sessionID: session.id }), + test.directory, ) expect(response.status).toBe(200) - const body = (yield* Effect.promise(() => response.json())) as Array<{ + const body = (yield* response.json) as Array<{ file: string patch?: string additions: number diff --git a/packages/opencode/test/server/session-list.test.ts b/packages/opencode/test/server/session-list.test.ts index 363e89337b..3ae4c2f13e 100644 --- a/packages/opencode/test/server/session-list.test.ts +++ b/packages/opencode/test/server/session-list.test.ts @@ -1,28 +1,33 @@ import { afterEach, describe, expect } from "bun:test" import { Effect, Layer } from "effect" +import { Database } from "@opencode-ai/core/database/database" +import { SessionProjector } from "@opencode-ai/core/session/projector" import { Session as SessionNs } from "@/session/session" import * as Log from "@opencode-ai/core/util/log" import { disposeAllInstances, provideInstance, TestInstance } from "../fixture/fixture" import { mkdir } from "fs/promises" import path from "path" -import { Database } from "@/storage/db" -import { SessionTable } from "@/session/session.sql" +import { SessionTable } from "@opencode-ai/core/session/sql" import { eq } from "drizzle-orm" import { testEffect } from "../lib/effect" -import { Bus } from "@/bus" +import { EventV2Bridge } from "@/event-v2-bridge" import { Storage } from "@/storage/storage" -import { SyncEvent } from "@/sync" import { RuntimeFlags } from "@/effect/runtime-flags" import { BackgroundJob } from "@/background/job" void Log.init({ print: false }) const it = testEffect( - SessionNs.layer.pipe( - Layer.provide(Bus.layer), - Layer.provide(Storage.defaultLayer), - Layer.provide(SyncEvent.defaultLayer), - Layer.provide(RuntimeFlags.layer({ experimentalWorkspaces: false })), - Layer.provide(BackgroundJob.defaultLayer), + Layer.mergeAll( + Database.defaultLayer, + SessionNs.layer.pipe( + Layer.provide(EventV2Bridge.defaultLayer), + Layer.provide(Storage.defaultLayer), + Layer.provide(Database.defaultLayer), + Layer.provide(EventV2Bridge.defaultLayer), + Layer.provide(SessionProjector.defaultLayer), + Layer.provide(RuntimeFlags.layer({ experimentalWorkspaces: false })), + Layer.provide(BackgroundJob.defaultLayer), + ), ), ) @@ -148,16 +153,9 @@ describe("session.list", () => { provideInstance(path.join(test.directory, "packages", "app")), ) - yield* Effect.sync(() => - Database.use((db) => - db.update(SessionTable).set({ path: null }).where(eq(SessionTable.id, current.id)).run(), - ), - ) - yield* Effect.sync(() => - Database.use((db) => - db.update(SessionTable).set({ path: null }).where(eq(SessionTable.id, sibling.id)).run(), - ), - ) + const { db } = yield* Database.Service + yield* db.update(SessionTable).set({ path: null }).where(eq(SessionTable.id, current.id)).run().pipe(Effect.orDie) + yield* db.update(SessionTable).set({ path: null }).where(eq(SessionTable.id, sibling.id)).run().pipe(Effect.orDie) const pathIDs = (yield* SessionNs.Service.use((session) => session.list({ diff --git a/packages/opencode/test/server/session-messages.test.ts b/packages/opencode/test/server/session-messages.test.ts index 6cd17d2555..c176c8e039 100644 --- a/packages/opencode/test/server/session-messages.test.ts +++ b/packages/opencode/test/server/session-messages.test.ts @@ -1,21 +1,24 @@ import { afterEach, describe, expect } from "bun:test" -import { Effect } from "effect" -import { Server } from "../../src/server/server" +import { SessionLegacy } from "@opencode-ai/core/session/legacy" +import { Effect, Layer } from "effect" +import { HttpClientResponse } from "effect/unstable/http" import { Session as SessionNs } from "@/session/session" import { MessageV2 } from "../../src/session/message-v2" -import { ModelID, ProviderID } from "../../src/provider/schema" + import { MessageID, PartID, type SessionID } from "../../src/session/schema" import * as Log from "@opencode-ai/core/util/log" import { disposeAllInstances, TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { httpApiLayer, requestInDirectory } from "./httpapi-layer" void Log.init({ print: false }) -const it = testEffect(SessionNs.defaultLayer) +const it = testEffect(Layer.mergeAll(SessionNs.defaultLayer, httpApiLayer)) const model = { - providerID: ProviderID.make("test"), - modelID: ModelID.make("test"), + providerID: ProviderV2.ID.make("test"), + modelID: ProviderV2.ModelID.make("test"), } afterEach(async () => { @@ -62,25 +65,25 @@ const fill = Effect.fn("SessionMessagesTest.fill")(function* ( agent: "test", model, tools: {}, - } satisfies MessageV2.User) + } satisfies SessionLegacy.User) yield* session.updatePart({ id: PartID.ascending(), sessionID, messageID: id, type: "text", text: `m${i}`, - } satisfies MessageV2.TextPart) + } satisfies SessionLegacy.TextPart) return id }), ) }) function request(path: string) { - return Effect.promise(() => Promise.resolve(Server.Default().app.request(path))) + return TestInstance.pipe(Effect.flatMap((test) => requestInDirectory(path, test.directory))) } -function json(response: Response) { - return Effect.promise(() => response.json() as Promise) +function json(response: HttpClientResponse.HttpClientResponse) { + return response.json.pipe(Effect.map((body) => body as T)) } describe("session messages endpoint", () => { @@ -93,15 +96,15 @@ describe("session messages endpoint", () => { const a = yield* request(`/session/${session.id}/message?limit=2`) expect(a.status).toBe(200) - const aBody = yield* json(a) + const aBody = yield* json(a) expect(aBody.map((item) => item.info.id)).toEqual(ids.slice(-2)) - const cursor = a.headers.get("x-next-cursor") + const cursor = a.headers["x-next-cursor"] expect(cursor).toBeTruthy() - expect(a.headers.get("link")).toContain('rel="next"') + expect(a.headers["link"]).toContain('rel="next"') const b = yield* request(`/session/${session.id}/message?limit=2&before=${encodeURIComponent(cursor!)}`) expect(b.status).toBe(200) - const bBody = yield* json(b) + const bBody = yield* json(b) expect(bBody.map((item) => item.info.id)).toEqual(ids.slice(-4, -2)) }), ), @@ -117,7 +120,7 @@ describe("session messages endpoint", () => { const res = yield* request(`/session/${session.id}/message`) expect(res.status).toBe(200) - const body = yield* json(res) + const body = yield* json(res) expect(body.map((item) => item.info.id)).toEqual(ids) }), ), @@ -149,7 +152,7 @@ describe("session messages endpoint", () => { const res = yield* request(`/session/${session.id}/message?limit=510`) expect(res.status).toBe(200) - const body = yield* json(res) + const body = yield* json(res) expect(body).toHaveLength(510) }), ), diff --git a/packages/opencode/test/server/session-select.test.ts b/packages/opencode/test/server/session-select.test.ts index 0f3875ae14..a54a77c3f2 100644 --- a/packages/opencode/test/server/session-select.test.ts +++ b/packages/opencode/test/server/session-select.test.ts @@ -1,14 +1,14 @@ import { describe, expect } from "bun:test" -import { Effect } from "effect" +import { Effect, Layer } from "effect" import { Session } from "@/session/session" import * as Log from "@opencode-ai/core/util/log" -import { Server } from "../../src/server/server" import { TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" +import { httpApiLayer, requestInDirectory } from "./httpapi-layer" void Log.init({ print: false }) -const it = testEffect(Session.defaultLayer) +const it = testEffect(Layer.mergeAll(Session.defaultLayer, httpApiLayer)) describe("tui.selectSession endpoint", () => { it.instance( @@ -18,22 +18,14 @@ describe("tui.selectSession endpoint", () => { const tmp = yield* TestInstance const session = yield* Session.use.create({}) - const app = Server.Default().app - const response = yield* Effect.promise(() => - Promise.resolve( - app.request("/tui/select-session", { - method: "POST", - headers: { - "Content-Type": "application/json", - "x-opencode-directory": tmp.directory, - }, - body: JSON.stringify({ sessionID: session.id }), - }), - ), - ) + const response = yield* requestInDirectory("/tui/select-session", tmp.directory, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sessionID: session.id }), + }) expect(response.status).toBe(200) - const body = yield* Effect.promise(() => response.json()) + const body = yield* response.json expect(body).toBe(true) }), { git: true }, @@ -46,19 +38,11 @@ describe("tui.selectSession endpoint", () => { const tmp = yield* TestInstance const nonExistentSessionID = "ses_nonexistent123" - const app = Server.Default().app - const response = yield* Effect.promise(() => - Promise.resolve( - app.request("/tui/select-session", { - method: "POST", - headers: { - "Content-Type": "application/json", - "x-opencode-directory": tmp.directory, - }, - body: JSON.stringify({ sessionID: nonExistentSessionID }), - }), - ), - ) + const response = yield* requestInDirectory("/tui/select-session", tmp.directory, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sessionID: nonExistentSessionID }), + }) expect(response.status).toBe(404) }), @@ -72,19 +56,11 @@ describe("tui.selectSession endpoint", () => { const tmp = yield* TestInstance const invalidSessionID = "invalid_session_id" - const app = Server.Default().app - const response = yield* Effect.promise(() => - Promise.resolve( - app.request("/tui/select-session", { - method: "POST", - headers: { - "Content-Type": "application/json", - "x-opencode-directory": tmp.directory, - }, - body: JSON.stringify({ sessionID: invalidSessionID }), - }), - ), - ) + const response = yield* requestInDirectory("/tui/select-session", tmp.directory, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sessionID: invalidSessionID }), + }) expect(response.status).toBe(400) }), diff --git a/packages/opencode/test/server/worktree-endpoint-repro.test.ts b/packages/opencode/test/server/worktree-endpoint-repro.test.ts index 9f73cd8a4f..62a6185886 100644 --- a/packages/opencode/test/server/worktree-endpoint-repro.test.ts +++ b/packages/opencode/test/server/worktree-endpoint-repro.test.ts @@ -1,10 +1,9 @@ import { describe, expect } from "bun:test" import { Effect, Layer, Queue } from "effect" -import { HttpRouter } from "effect/unstable/http" import { Flag } from "@opencode-ai/core/flag/flag" import { GlobalBus, type GlobalEvent } from "@/bus/global" import { Worktree } from "@/worktree" -import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server" +import { Server } from "../../src/server/server" import { ExperimentalPaths } from "../../src/server/routes/instance/httpapi/groups/experimental" import { WorkspacePaths } from "../../src/server/routes/instance/httpapi/groups/workspace" import { resetDatabase } from "../fixture/db" @@ -30,19 +29,16 @@ const stateLayer = Layer.effectDiscard( const it = testEffect(stateLayer) const worktreeTest = process.platform === "win32" ? it.instance.skip : it.instance -type TestServer = ReturnType +type TestServer = ReturnType["app"] type CreatedWorktree = { directory: string } type ScopedWorktree = { directory: string; body: CreatedWorktree; ready: Effect.Effect } function serverScoped() { - return Effect.acquireRelease( - Effect.sync(() => HttpRouter.toWebHandler(HttpApiApp.routes, { disableLogger: true })), - (server) => Effect.promise(() => server.dispose()).pipe(Effect.ignore), - ) + return Effect.sync(() => Server.Default().app) } function request(server: TestServer, input: string, init?: RequestInit) { - return Effect.promise(() => server.handler(new Request(new URL(input, "http://localhost"), init), HttpApiApp.context)) + return Effect.promise(() => Promise.resolve(server.request(input, init))) } function withRequestTimeout(effect: Effect.Effect, label: string, ms = 5_000) { diff --git a/packages/opencode/test/session/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts index 55ddc621ca..c4e6510950 100644 --- a/packages/opencode/test/session/compaction.test.ts +++ b/packages/opencode/test/session/compaction.test.ts @@ -1,8 +1,10 @@ import { afterEach, describe, expect, mock, test } from "bun:test" +import { SessionLegacy } from "@opencode-ai/core/session/legacy" +import { Database } from "@opencode-ai/core/database/database" +import { EventV2Bridge } from "@/event-v2-bridge" import { APICallError } from "ai" import { Cause, Deferred, Effect, Exit, Fiber, Layer, Schema } from "effect" import * as Stream from "effect/Stream" -import { Bus } from "../../src/bus" import { Config } from "@/config/config" import { Image } from "@/image/image" import { Agent } from "../../src/agent/agent" @@ -18,8 +20,8 @@ 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 { SessionV2 } from "@opencode-ai/core/session" + import type { Provider } from "@/provider/provider" import * as SessionProcessorModule from "../../src/session/processor" import { Snapshot } from "../../src/snapshot" @@ -27,10 +29,9 @@ import { ProviderTest } from "../fake/provider" import { testEffect } from "../lib/effect" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { TestConfig } from "../fixture/config" -import { SyncEvent } from "@/sync" import { RuntimeFlags } from "@/effect/runtime-flags" -import { EventV2Bridge } from "@/event-v2-bridge" import { LLMEvent, Usage } from "@opencode-ai/llm" +import { ProviderV2 } from "@opencode-ai/core/provider" void Log.init({ print: false }) @@ -44,8 +45,8 @@ const summary = Layer.succeed( ) const ref = { - providerID: ProviderID.make("test"), - modelID: ModelID.make("test-model"), + providerID: ProviderV2.ID.make("test"), + modelID: ProviderV2.ModelID.make("test-model"), } const usage = (input: ConstructorParameters[0]) => new Usage(input) @@ -229,22 +230,24 @@ const deps = Layer.mergeAll( layer("continue"), Agent.defaultLayer, Plugin.defaultLayer, - Bus.layer, + EventV2Bridge.defaultLayer, Config.defaultLayer, - SyncEvent.defaultLayer, RuntimeFlags.layer({ experimentalEventSystem: true }), + Database.defaultLayer, EventV2Bridge.defaultLayer, ) const env = Layer.mergeAll( SessionNs.defaultLayer, + Database.defaultLayer, + EventV2Bridge.defaultLayer, CrossSpawnSpawner.defaultLayer, SessionCompaction.layer.pipe(Layer.provide(SessionNs.defaultLayer), Layer.provideMerge(deps)), ) const it = testEffect(env) -const compactionEnv = Layer.mergeAll(SessionNs.defaultLayer, CrossSpawnSpawner.defaultLayer) +const compactionEnv = Layer.mergeAll(SessionNs.defaultLayer, Database.defaultLayer, EventV2Bridge.defaultLayer, CrossSpawnSpawner.defaultLayer) const itCompaction = testEffect(compactionEnv) type CompactionProcessOptions = { @@ -260,8 +263,8 @@ function withCompaction(options?: CompactionProcessOptions) { } function compactionProcessLayer(options?: CompactionProcessOptions) { - const bus = Bus.layer - const status = SessionStatus.layer.pipe(Layer.provide(bus)) + const events = EventV2Bridge.defaultLayer + const status = SessionStatus.layer.pipe(Layer.provide(events)) const processor = options?.llm ? SessionProcessorModule.SessionProcessor.layer.pipe( Layer.provide(summary), @@ -270,7 +273,7 @@ function compactionProcessLayer(options?: CompactionProcessOptions) { Layer.provide(status), ) : layer(options?.result ?? "continue") - return Layer.mergeAll(SessionCompaction.layer.pipe(Layer.provide(processor)), processor, bus, status).pipe( + return Layer.mergeAll(SessionCompaction.layer.pipe(Layer.provide(processor)), processor, events, status).pipe( Layer.provide(SessionNs.defaultLayer), Layer.provide((options?.provider ?? wide()).layer), Layer.provide(Snapshot.defaultLayer), @@ -279,9 +282,8 @@ function compactionProcessLayer(options?: CompactionProcessOptions) { Layer.provide(Agent.defaultLayer), Layer.provide(options?.plugin ?? Plugin.defaultLayer), Layer.provide(status), - Layer.provide(bus), + Layer.provide(events), Layer.provide(options?.config ?? Config.defaultLayer), - Layer.provide(SyncEvent.defaultLayer), Layer.provide(RuntimeFlags.layer({ experimentalEventSystem: true })), Layer.provide(EventV2Bridge.defaultLayer), ) @@ -296,7 +298,7 @@ function readCompactionPart(sessionID: SessionID) { .messages({ sessionID }) .pipe( Effect.map((messages) => - messages.at(-2)?.parts.find((item): item is MessageV2.CompactionPart => item.type === "compaction"), + messages.at(-2)?.parts.find((item): item is SessionLegacy.CompactionPart => item.type === "compaction"), ), ) } @@ -586,6 +588,26 @@ describe("session.compaction.create", () => { overflow: true, }) + }), + ), + ) + + it.live.skip( + "projects a compaction message to v2 (v2 projector disabled)", + provideTmpdirInstance(() => + Effect.gen(function* () { + const compact = yield* SessionCompaction.Service + const ssn = yield* SessionNs.Service + const info = yield* ssn.create({}) + + yield* compact.create({ + sessionID: info.id, + agent: "build", + model: ref, + auto: true, + overflow: true, + }) + const v2 = yield* SessionV2.Service.use((svc) => svc.messages({ sessionID: info.id })).pipe( Effect.provide(SessionV2.defaultLayer), ) @@ -623,7 +645,7 @@ describe("session.compaction.prune", () => { type: "text", text: "first", }) - const b: MessageV2.Assistant = { + const b: SessionLegacy.Assistant = { id: MessageID.ascending(), role: "assistant", sessionID: info.id, @@ -719,7 +741,7 @@ describe("session.compaction.prune", () => { type: "text", text: "first", }) - const b: MessageV2.Assistant = { + const b: SessionLegacy.Assistant = { id: MessageID.ascending(), role: "assistant", sessionID: info.id, @@ -821,19 +843,21 @@ describe("session.compaction.process", () => { it.instance( "publishes compacted event on continue", Effect.gen(function* () { - const bus = yield* Bus.Service + const events = yield* EventV2Bridge.Service const ssn = yield* SessionNs.Service const session = yield* ssn.create({}) const msg = yield* createUserMessage(session.id, "hello") const msgs = yield* ssn.messages({ sessionID: session.id }) const done = yield* Deferred.make() let seen = false - const unsub = yield* bus.subscribeCallback(SessionCompaction.Event.Compacted, (evt) => { - if (evt.properties.sessionID !== session.id) return + const unsub = yield* events.listen((evt) => { + if (evt.type !== SessionCompaction.Event.Compacted.type) return Effect.void + if ((evt.data as typeof SessionCompaction.Event.Compacted.data.Type).sessionID !== session.id) return Effect.void seen = true Deferred.doneUnsafe(done, Effect.void) + return Effect.void }) - yield* Effect.addFinalizer(() => Effect.sync(unsub)) + yield* Effect.addFinalizer(() => unsub) const result = yield* SessionCompaction.use.process({ parentID: msg.id, @@ -1064,7 +1088,7 @@ describe("session.compaction.process", () => { expect(captured).toContain("zzzz") expect(captured).not.toContain("keep tail") - const filtered = MessageV2.filterCompacted(MessageV2.stream(session.id)) + const filtered = MessageV2.filterCompacted(yield* MessageV2.stream(session.id)) expect(filtered.map((msg) => msg.info.id).slice(0, 3)).toEqual([parent!, expect.any(String), keep.id]) expect(filtered[1]?.info.role).toBe("assistant") expect(filtered[1]?.info.role === "assistant" ? filtered[1].info.summary : false).toBe(true) @@ -1197,17 +1221,19 @@ describe("session.compaction.process", () => { return Effect.gen(function* () { const ssn = yield* SessionNs.Service - const bus = yield* Bus.Service + const events = yield* EventV2Bridge.Service const ready = yield* Deferred.make() const session = yield* ssn.create({}) const msg = yield* createUserMessage(session.id, "hello") const msgs = yield* ssn.messages({ sessionID: session.id }) - const off = yield* bus.subscribeCallback(SessionStatus.Event.Status, (evt) => { - if (evt.properties.sessionID !== session.id) return - if (evt.properties.status.type !== "retry") return + const off = yield* events.listen((evt) => { + if (evt.type !== SessionStatus.Event.Status.type) return Effect.void + const data = evt.data as typeof SessionStatus.Event.Status.data.Type + if (data.sessionID !== session.id || data.status.type !== "retry") return Effect.void Deferred.doneUnsafe(ready, Effect.void) + return Effect.void }) - yield* Effect.addFinalizer(() => Effect.sync(off)) + yield* Effect.addFinalizer(() => off) const fiber = yield* SessionCompaction.use .process({ @@ -1405,7 +1431,7 @@ describe("session.compaction.process", () => { yield* createUserMessage(session.id, "latest turn") yield* createCompactionMarker(session.id) - msgs = MessageV2.filterCompacted(MessageV2.stream(session.id)) + msgs = MessageV2.filterCompacted(yield* MessageV2.stream(session.id)) parent = msgs.at(-1)?.info.id expect(parent).toBeTruthy() yield* SessionCompaction.use.process({ parentID: parent!, messages: msgs, sessionID: session.id, auto: false }) @@ -1441,12 +1467,12 @@ describe("session.compaction.process", () => { const u4 = yield* createUserMessage(session.id, "four") yield* createCompactionMarker(session.id) - msgs = MessageV2.filterCompacted(MessageV2.stream(session.id)) + msgs = MessageV2.filterCompacted(yield* MessageV2.stream(session.id)) parent = msgs.at(-1)?.info.id expect(parent).toBeTruthy() yield* SessionCompaction.use.process({ parentID: parent!, messages: msgs, sessionID: session.id, auto: false }) - const filtered = MessageV2.filterCompacted(MessageV2.stream(session.id)) + const filtered = MessageV2.filterCompacted(yield* MessageV2.stream(session.id)) const ids = filtered.map((msg) => msg.info.id) expect(ids).not.toContain(u1.id) diff --git a/packages/opencode/test/session/instruction.test.ts b/packages/opencode/test/session/instruction.test.ts index 0f9c340dd4..3855e9c3a7 100644 --- a/packages/opencode/test/session/instruction.test.ts +++ b/packages/opencode/test/session/instruction.test.ts @@ -1,21 +1,23 @@ import { describe, expect, test } from "bun:test" +import { SessionLegacy } from "@opencode-ai/core/session/legacy" import path from "path" import { Effect, FileSystem, Layer } from "effect" import { FetchHttpClient } from "effect/unstable/http" import { NodeFileSystem } from "@effect/platform-node" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { AppFileSystem } from "@opencode-ai/core/filesystem" -import { ModelID, ProviderID } from "../../src/provider/schema" + import { Instruction } from "../../src/session/instruction" import type { MessageV2 } from "../../src/session/message-v2" import { MessageID, PartID, SessionID } from "../../src/session/schema" import { Global } from "@opencode-ai/core/global" import { RuntimeFlags } from "../../src/effect/runtime-flags" -import { provideInstance, provideTmpdirInstance, tmpdirScoped } from "../fixture/fixture" +import { provideInstance, provideTmpdirInstance, testInstanceStoreLayer, tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" import { TestConfig } from "../fixture/config" +import { ProviderV2 } from "@opencode-ai/core/provider" -const it = testEffect(Layer.mergeAll(CrossSpawnSpawner.defaultLayer, NodeFileSystem.layer)) +const it = testEffect(Layer.mergeAll(CrossSpawnSpawner.defaultLayer, NodeFileSystem.layer, testInstanceStoreLayer)) const configLayer = TestConfig.layer() @@ -61,7 +63,7 @@ const tmpWithFiles = (files: Record) => return dir }) -function loaded(filepath: string): MessageV2.WithParts[] { +function loaded(filepath: string): SessionLegacy.WithParts[] { const sessionID = SessionID.make("session-loaded-1") const messageID = MessageID.make("msg_message-loaded-1") @@ -74,8 +76,8 @@ function loaded(filepath: string): MessageV2.WithParts[] { time: { created: 0 }, agent: "build", model: { - providerID: ProviderID.make("anthropic"), - modelID: ModelID.make("claude-sonnet-4-20250514"), + providerID: ProviderV2.ID.make("anthropic"), + modelID: ProviderV2.ModelID.make("claude-sonnet-4-20250514"), }, }, parts: [ diff --git a/packages/opencode/test/session/llm-native-recorded.test.ts b/packages/opencode/test/session/llm-native-recorded.test.ts index 33e96d1217..9aa8ce95d6 100644 --- a/packages/opencode/test/session/llm-native-recorded.test.ts +++ b/packages/opencode/test/session/llm-native-recorded.test.ts @@ -1,4 +1,5 @@ import { NodeFileSystem } from "@effect/platform-node" +import { SessionLegacy } from "@opencode-ai/core/session/legacy" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { ModelsDev } from "@opencode-ai/core/models-dev" import { LocationServiceMap } from "@opencode-ai/core/location-layer" @@ -13,7 +14,7 @@ import { Auth } from "@/auth" import { Config } from "@/config/config" import { Plugin } from "@/plugin" import { Provider } from "@/provider/provider" -import { ModelID, ProviderID } from "@/provider/schema" + import { Filesystem } from "@/util/filesystem" import { LLMEvent, LLMResponse } from "@opencode-ai/llm" import { LLMClient, RequestExecutor, WebSocketExecutor } from "@opencode-ai/llm/route" @@ -25,6 +26,7 @@ import { MessageV2 } from "../../src/session/message-v2" import { MessageID, SessionID } from "../../src/session/schema" import { TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" +import { ProviderV2 } from "@opencode-ai/core/provider" const FIXTURES_DIR = path.join(import.meta.dir, "../fixtures/recordings") @@ -41,7 +43,7 @@ const replayOpenAIOAuth = { type RecordedScenario = { readonly id: string readonly name: string - readonly providerID: ProviderID + readonly providerID: ProviderV2.ID readonly modelID: string readonly cassette: string readonly protocol: string @@ -88,7 +90,7 @@ function decodeRecordOpenAIOAuth() { } const providerConfig = (input: { - readonly providerID: ProviderID + readonly providerID: ProviderV2.ID readonly name: string readonly env: string[] readonly npm: string @@ -113,7 +115,7 @@ const RECORDED_SCENARIOS = [ { id: "openai-api-key", name: "OpenAI API key", - providerID: ProviderID.openai, + providerID: ProviderV2.ID.openai, modelID: "gpt-4.1-mini", cassette: "session/native-openai-tool-loop", protocol: "openai-responses", @@ -121,7 +123,7 @@ const RECORDED_SCENARIOS = [ canRecord: () => Boolean(envValue("OPENCODE_RECORD_OPENAI_API_KEY", "OPENAI_API_KEY")), config: (model) => providerConfig({ - providerID: ProviderID.openai, + providerID: ProviderV2.ID.openai, name: "OpenAI", env: ["OPENAI_API_KEY"], npm: "@ai-sdk/openai", @@ -136,7 +138,7 @@ const RECORDED_SCENARIOS = [ { id: "openai-oauth", name: "OpenAI OAuth", - providerID: ProviderID.openai, + providerID: ProviderV2.ID.openai, modelID: "gpt-5.5", cassette: "session/native-openai-oauth-tool-loop", protocol: "openai-responses", @@ -147,7 +149,7 @@ const RECORDED_SCENARIOS = [ stableID: "openai-oauth", config: (model) => providerConfig({ - providerID: ProviderID.openai, + providerID: ProviderV2.ID.openai, name: "OpenAI", env: ["OPENAI_API_KEY"], npm: "@ai-sdk/openai", @@ -159,7 +161,7 @@ const RECORDED_SCENARIOS = [ { id: "opencode-proxy", name: "OpenCode proxy", - providerID: ProviderID.opencode, + providerID: ProviderV2.ID.opencode, modelID: "gpt-5.2-codex", cassette: "session/native-zen-tool-loop", protocol: "openai-responses", @@ -167,7 +169,7 @@ const RECORDED_SCENARIOS = [ canRecord: () => Boolean(process.env.OPENCODE_RECORD_CONSOLE_TOKEN && process.env.OPENCODE_RECORD_ZEN_ORG_ID), config: (model) => providerConfig({ - providerID: ProviderID.opencode, + providerID: ProviderV2.ID.opencode, name: "OpenCode Zen", env: ["OPENCODE_CONSOLE_TOKEN"], npm: "@ai-sdk/openai-compatible", @@ -182,7 +184,7 @@ const RECORDED_SCENARIOS = [ { id: "anthropic-api-key", name: "Anthropic API key", - providerID: ProviderID.anthropic, + providerID: ProviderV2.ID.anthropic, modelID: "claude-haiku-4-5-20251001", cassette: "session/native-anthropic-tool-loop", protocol: "anthropic-messages", @@ -190,7 +192,7 @@ const RECORDED_SCENARIOS = [ canRecord: () => Boolean(envValue("OPENCODE_RECORD_ANTHROPIC_API_KEY", "ANTHROPIC_API_KEY")), config: (model) => providerConfig({ - providerID: ProviderID.anthropic, + providerID: ProviderV2.ID.anthropic, name: "Anthropic", env: ["ANTHROPIC_API_KEY"], npm: "@ai-sdk/anthropic", @@ -373,7 +375,7 @@ const driveToolLoop = (scenario: RecordedScenario) => const stableID = scenario.stableID ?? scenario.providerID const sessionID = SessionID.make(`session-recorded-${stableID}-loop`) - const modelID = ModelID.make(model.id) + const modelID = ProviderV2.ModelID.make(model.id) const agent = { name: "test", mode: "primary", @@ -394,7 +396,7 @@ const driveToolLoop = (scenario: RecordedScenario) => time: { created: 0 }, agent: agent.name, model: { providerID: scenario.providerID, modelID }, - } satisfies MessageV2.User, + } satisfies SessionLegacy.User, sessionID, model: resolved, agent, diff --git a/packages/opencode/test/session/llm-native.test.ts b/packages/opencode/test/session/llm-native.test.ts index 076d4c9f78..29c25d1ad0 100644 --- a/packages/opencode/test/session/llm-native.test.ts +++ b/packages/opencode/test/session/llm-native.test.ts @@ -6,13 +6,14 @@ import { Effect, Layer, Stream } from "effect" import { LLMNative } from "@/session/llm/native-request" import { LLMNativeRuntime } from "@/session/llm/native-runtime" import type { Provider } from "@/provider/provider" -import { ModelID, ProviderID } from "@/provider/schema" + import { OAUTH_DUMMY_KEY } from "@/auth" import { testEffect } from "../lib/effect" +import { ProviderV2 } from "@opencode-ai/core/provider" const baseModel: Provider.Model = { - id: ModelID.make("gpt-5-mini"), - providerID: ProviderID.make("openai"), + id: ProviderV2.ModelID.make("gpt-5-mini"), + providerID: ProviderV2.ID.make("openai"), api: { id: "gpt-5-mini", url: "https://api.openai.com/v1", @@ -62,7 +63,7 @@ const baseModel: Provider.Model = { } const providerInfo: Provider.Info = { - id: ProviderID.make("openai"), + id: ProviderV2.ID.make("openai"), name: "OpenAI", source: "config", env: ["OPENAI_API_KEY"], @@ -354,7 +355,7 @@ describe("session.llm-native.request", () => { const compatible = LLMNative.model({ model: { ...baseModel, - providerID: ProviderID.make("opencode"), + providerID: ProviderV2.ID.make("opencode"), api: { ...baseModel.api, url: "https://ai.example.test/v1", npm: "@ai-sdk/openai-compatible" }, }, apiKey: "test-key", @@ -388,8 +389,8 @@ describe("session.llm-native.request", () => { }) expect( LLMNativeRuntime.status({ - model: { ...baseModel, providerID: ProviderID.make("opencode") }, - provider: { ...providerInfo, id: ProviderID.make("opencode") }, + model: { ...baseModel, providerID: ProviderV2.ID.make("opencode") }, + provider: { ...providerInfo, id: ProviderV2.ID.make("opencode") }, auth: undefined, }), ).toMatchObject({ @@ -400,10 +401,10 @@ describe("session.llm-native.request", () => { LLMNativeRuntime.status({ model: { ...baseModel, - providerID: ProviderID.make("opencode"), + providerID: ProviderV2.ID.make("opencode"), api: { ...baseModel.api, npm: "@ai-sdk/openai-compatible" }, }, - provider: { ...providerInfo, id: ProviderID.make("opencode") }, + provider: { ...providerInfo, id: ProviderV2.ID.make("opencode") }, auth: undefined, }), ).toMatchObject({ @@ -412,8 +413,8 @@ describe("session.llm-native.request", () => { }) expect( LLMNativeRuntime.status({ - model: { ...baseModel, providerID: ProviderID.make("google") }, - provider: { ...providerInfo, id: ProviderID.make("google") }, + model: { ...baseModel, providerID: ProviderV2.ID.make("google") }, + provider: { ...providerInfo, id: ProviderV2.ID.make("google") }, auth: undefined, }), ).toEqual({ type: "unsupported", reason: "provider is not openai, opencode, or anthropic" }) @@ -454,12 +455,12 @@ describe("session.llm-native.request", () => { LLMNativeRuntime.status({ model: { ...baseModel, - providerID: ProviderID.make("anthropic"), + providerID: ProviderV2.ID.make("anthropic"), api: { ...baseModel.api, npm: "@ai-sdk/anthropic", url: "https://api.anthropic.com/v1" }, }, provider: { ...providerInfo, - id: ProviderID.make("anthropic"), + id: ProviderV2.ID.make("anthropic"), name: "Anthropic", env: ["ANTHROPIC_API_KEY"], options: { apiKey: "test-anthropic-key" }, @@ -472,10 +473,10 @@ describe("session.llm-native.request", () => { test("prefers console provider api key over stored opencode auth", () => { expect( LLMNativeRuntime.status({ - model: { ...baseModel, providerID: ProviderID.make("opencode") }, + model: { ...baseModel, providerID: ProviderV2.ID.make("opencode") }, provider: { ...providerInfo, - id: ProviderID.make("opencode"), + id: ProviderV2.ID.make("opencode"), options: { apiKey: "console-token" }, key: "zen-token", }, diff --git a/packages/opencode/test/session/llm.test.ts b/packages/opencode/test/session/llm.test.ts index cd381ecd01..8927003023 100644 --- a/packages/opencode/test/session/llm.test.ts +++ b/packages/opencode/test/session/llm.test.ts @@ -1,4 +1,5 @@ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test" +import { SessionLegacy } from "@opencode-ai/core/session/legacy" import path from "path" import { tool, type ModelMessage } from "ai" import { Cause, Effect, Exit, Fiber, Layer, Stream } from "effect" @@ -13,7 +14,7 @@ import { Provider } from "@/provider/provider" import { ProviderTransform } from "@/provider/transform" import { ModelsDev } from "@opencode-ai/core/models-dev" import { Plugin } from "@/plugin" -import { ProviderID, ModelID } from "../../src/provider/schema" + import { testEffect } from "../lib/effect" import type { Agent } from "../../src/agent/agent" import { MessageV2 } from "../../src/session/message-v2" @@ -22,6 +23,7 @@ import { RuntimeFlags } from "@/effect/runtime-flags" import { Permission } from "@/permission" import { LLMAISDK } from "@/session/llm/ai-sdk" import { Session as SessionNs } from "@/session/session" +import { ProviderV2 } from "@opencode-ai/core/provider" type ConfigModel = NonNullable[string]["models"]>[string] @@ -712,8 +714,8 @@ describe("session.llm.stream", () => { ) const resolved = yield* Provider.use.getModel( - ProviderID.make(vivgridFixture.providerID), - ModelID.make(fixture.model.id), + ProviderV2.ID.make(vivgridFixture.providerID), + ProviderV2.ModelID.make(fixture.model.id), ) const sessionID = SessionID.make("session-test-1") const agent = { @@ -731,8 +733,8 @@ describe("session.llm.stream", () => { role: "user", time: { created: Date.now() }, agent: agent.name, - model: { providerID: ProviderID.make(vivgridFixture.providerID), modelID: resolved.id, variant: "high" }, - } satisfies MessageV2.User + model: { providerID: ProviderV2.ID.make(vivgridFixture.providerID), modelID: resolved.id, variant: "high" }, + } satisfies SessionLegacy.User yield* drain({ user, @@ -786,8 +788,8 @@ describe("session.llm.stream", () => { const pending = waitStreamingRequest("/chat/completions") const resolved = yield* Provider.use.getModel( - ProviderID.make(alibabaQwenFixture.providerID), - ModelID.make(fixture.model.id), + ProviderV2.ID.make(alibabaQwenFixture.providerID), + ProviderV2.ModelID.make(fixture.model.id), ) const sessionID = SessionID.make("session-test-service-abort") const agent = { @@ -802,8 +804,8 @@ describe("session.llm.stream", () => { role: "user", time: { created: Date.now() }, agent: agent.name, - model: { providerID: ProviderID.make(alibabaQwenFixture.providerID), modelID: resolved.id }, - } satisfies MessageV2.User + model: { providerID: ProviderV2.ID.make(alibabaQwenFixture.providerID), modelID: resolved.id }, + } satisfies SessionLegacy.User const fiber = yield* drain({ user, @@ -854,8 +856,8 @@ describe("session.llm.stream", () => { ) const resolved = yield* Provider.use.getModel( - ProviderID.make(alibabaQwenFixture.providerID), - ModelID.make(fixture.model.id), + ProviderV2.ID.make(alibabaQwenFixture.providerID), + ProviderV2.ModelID.make(fixture.model.id), ) const sessionID = SessionID.make("session-test-tools") const agent = { @@ -871,9 +873,9 @@ describe("session.llm.stream", () => { role: "user", time: { created: Date.now() }, agent: agent.name, - model: { providerID: ProviderID.make(alibabaQwenFixture.providerID), modelID: resolved.id }, + model: { providerID: ProviderV2.ID.make(alibabaQwenFixture.providerID), modelID: resolved.id }, tools: { question: true }, - } satisfies MessageV2.User + } satisfies SessionLegacy.User yield* drain({ user, @@ -958,7 +960,7 @@ describe("session.llm.stream", () => { ] const request = waitRequest("/responses", createEventResponse(responseChunks, true)) - const resolved = yield* Provider.use.getModel(ProviderID.openai, ModelID.make(model.id)) + const resolved = yield* Provider.use.getModel(ProviderV2.ID.openai, ProviderV2.ModelID.make(model.id)) const sessionID = SessionID.make("session-test-2") const agent = { name: "test", @@ -974,8 +976,8 @@ describe("session.llm.stream", () => { role: "user", time: { created: Date.now() }, agent: agent.name, - model: { providerID: ProviderID.make("openai"), modelID: resolved.id, variant: "high" }, - } satisfies MessageV2.User + model: { providerID: ProviderV2.ID.make("openai"), modelID: resolved.id, variant: "high" }, + } satisfies SessionLegacy.User yield* drain({ user, @@ -1063,7 +1065,7 @@ describe("session.llm.stream", () => { }), ) - const resolved = yield* Provider.use.getModel(ProviderID.openai, ModelID.make(model.id)) + const resolved = yield* Provider.use.getModel(ProviderV2.ID.openai, ProviderV2.ModelID.make(model.id)) const sessionID = SessionID.make("session-test-native-flag-off") const agent = { name: "test", @@ -1088,8 +1090,8 @@ describe("session.llm.stream", () => { role: "user", time: { created: Date.now() }, agent: agent.name, - model: { providerID: ProviderID.make("openai"), modelID: resolved.id, variant: "high" }, - } satisfies MessageV2.User, + model: { providerID: ProviderV2.ID.make("openai"), modelID: resolved.id, variant: "high" }, + } satisfies SessionLegacy.User, sessionID, model: resolved, agent, @@ -1133,7 +1135,7 @@ describe("session.llm.stream", () => { ] const request = waitRequest("/responses", createEventResponse(chunks, true)) - const resolved = yield* Provider.use.getModel(ProviderID.openai, ModelID.make(model.id)) + const resolved = yield* Provider.use.getModel(ProviderV2.ID.openai, ProviderV2.ModelID.make(model.id)) const sessionID = SessionID.make("session-test-native") const agent = { name: "test", @@ -1150,8 +1152,8 @@ describe("session.llm.stream", () => { role: "user", time: { created: Date.now() }, agent: agent.name, - model: { providerID: ProviderID.make("openai"), modelID: resolved.id, variant: "high" }, - } satisfies MessageV2.User, + model: { providerID: ProviderV2.ID.make("openai"), modelID: resolved.id, variant: "high" }, + } satisfies SessionLegacy.User, sessionID, model: resolved, agent, @@ -1217,7 +1219,7 @@ describe("session.llm.stream", () => { }), ) - const resolved = yield* Provider.use.getModel(ProviderID.openai, ModelID.make(model.id)) + const resolved = yield* Provider.use.getModel(ProviderV2.ID.openai, ProviderV2.ModelID.make(model.id)) const sessionID = SessionID.make("session-test-native-injected-tool") const agent = { name: "test", @@ -1233,8 +1235,8 @@ describe("session.llm.stream", () => { role: "user", time: { created: Date.now() }, agent: agent.name, - model: { providerID: ProviderID.make("openai"), modelID: resolved.id }, - } satisfies MessageV2.User, + model: { providerID: ProviderV2.ID.make("openai"), modelID: resolved.id }, + } satisfies SessionLegacy.User, sessionID, model: resolved, agent, @@ -1305,7 +1307,7 @@ describe("session.llm.stream", () => { const request = waitRequest("/responses", createEventResponse(chunks, true)) let executed: unknown - const resolved = yield* Provider.use.getModel(ProviderID.openai, ModelID.make(model.id)) + const resolved = yield* Provider.use.getModel(ProviderV2.ID.openai, ProviderV2.ModelID.make(model.id)) const sessionID = SessionID.make("session-test-native-tool") const agent = { name: "test", @@ -1321,8 +1323,8 @@ describe("session.llm.stream", () => { role: "user", time: { created: Date.now() }, agent: agent.name, - model: { providerID: ProviderID.make("openai"), modelID: resolved.id }, - } satisfies MessageV2.User, + model: { providerID: ProviderV2.ID.make("openai"), modelID: resolved.id }, + } satisfies SessionLegacy.User, sessionID, model: resolved, agent, @@ -1431,7 +1433,7 @@ describe("session.llm.stream", () => { ), ).toString("base64")}` - const resolved = yield* Provider.use.getModel(ProviderID.openai, ModelID.make(model.id)) + const resolved = yield* Provider.use.getModel(ProviderV2.ID.openai, ProviderV2.ModelID.make(model.id)) const sessionID = SessionID.make("session-test-data-url") const agent = { name: "test", @@ -1446,8 +1448,8 @@ describe("session.llm.stream", () => { role: "user", time: { created: Date.now() }, agent: agent.name, - model: { providerID: ProviderID.make("openai"), modelID: resolved.id }, - } satisfies MessageV2.User + model: { providerID: ProviderV2.ID.make("openai"), modelID: resolved.id }, + } satisfies SessionLegacy.User yield* drain({ user, @@ -1519,8 +1521,8 @@ describe("session.llm.stream", () => { const request = waitRequest("/messages", createEventResponse(chunks)) const resolved = yield* Provider.use.getModel( - ProviderID.make(minimaxFixture.providerID), - ModelID.make(model.id), + ProviderV2.ID.make(minimaxFixture.providerID), + ProviderV2.ModelID.make(model.id), ) const sessionID = SessionID.make("session-test-3") const agent = { @@ -1538,8 +1540,8 @@ describe("session.llm.stream", () => { role: "user", time: { created: Date.now() }, agent: agent.name, - model: { providerID: ProviderID.make("minimax"), modelID: ModelID.make("MiniMax-M2.5") }, - } satisfies MessageV2.User + model: { providerID: ProviderV2.ID.make("minimax"), modelID: ProviderV2.ModelID.make("MiniMax-M2.5") }, + } satisfies SessionLegacy.User yield* drain({ user, @@ -1615,7 +1617,7 @@ describe("session.llm.stream", () => { ] const request = waitRequest("/messages", createEventResponse(chunks)) - const resolved = yield* Provider.use.getModel(ProviderID.make("anthropic"), ModelID.make(model.id)) + const resolved = yield* Provider.use.getModel(ProviderV2.ID.make("anthropic"), ProviderV2.ModelID.make(model.id)) const sessionID = SessionID.make("session-test-anthropic-tools") const agent = { name: "test", @@ -1629,8 +1631,8 @@ describe("session.llm.stream", () => { role: "user", time: { created: Date.now() }, agent: agent.name, - model: { providerID: ProviderID.make("anthropic"), modelID: resolved.id, variant: "max" }, - } satisfies MessageV2.User + model: { providerID: ProviderV2.ID.make("anthropic"), modelID: resolved.id, variant: "max" }, + } satisfies SessionLegacy.User const input = [ { @@ -1814,7 +1816,7 @@ describe("session.llm.stream", () => { ] const request = waitRequest(pathSuffix, createEventResponse(chunks)) - const resolved = yield* Provider.use.getModel(ProviderID.make(geminiFixture.providerID), ModelID.make(model.id)) + const resolved = yield* Provider.use.getModel(ProviderV2.ID.make(geminiFixture.providerID), ProviderV2.ModelID.make(model.id)) const sessionID = SessionID.make("session-test-4") const agent = { name: "test", @@ -1831,8 +1833,8 @@ describe("session.llm.stream", () => { role: "user", time: { created: Date.now() }, agent: agent.name, - model: { providerID: ProviderID.make(geminiFixture.providerID), modelID: resolved.id }, - } satisfies MessageV2.User + model: { providerID: ProviderV2.ID.make(geminiFixture.providerID), modelID: resolved.id }, + } satisfies SessionLegacy.User yield* drain({ user, diff --git a/packages/opencode/test/session/message-v2.test.ts b/packages/opencode/test/session/message-v2.test.ts index 82bed0e9cc..09d19cda3d 100644 --- a/packages/opencode/test/session/message-v2.test.ts +++ b/packages/opencode/test/session/message-v2.test.ts @@ -1,16 +1,18 @@ import { describe, expect, test } from "bun:test" +import { SessionLegacy } from "@opencode-ai/core/session/legacy" import { APICallError } from "ai" import { MessageV2 } from "../../src/session/message-v2" import { ProviderTransform } from "@/provider/transform" import type { Provider } from "@/provider/provider" -import { ModelID, ProviderID } from "../../src/provider/schema" + import { SessionID, MessageID, PartID } from "../../src/session/schema" import { Question } from "../../src/question" +import { ProviderV2 } from "@opencode-ai/core/provider" const sessionID = SessionID.make("session") -const providerID = ProviderID.make("test") +const providerID = ProviderV2.ID.make("test") const model: Provider.Model = { - id: ModelID.make("test-model"), + id: ProviderV2.ModelID.make("test-model"), providerID, api: { id: "test-model", @@ -58,25 +60,25 @@ const model: Provider.Model = { release_date: "2026-01-01", } -function userInfo(id: string): MessageV2.User { +function userInfo(id: string): SessionLegacy.User { return { id, sessionID, role: "user", time: { created: 0 }, agent: "user", - model: { providerID, modelID: ModelID.make("test") }, + model: { providerID, modelID: ProviderV2.ModelID.make("test") }, tools: {}, mode: "", - } as unknown as MessageV2.User + } as unknown as SessionLegacy.User } function assistantInfo( id: string, parentID: string, - error?: MessageV2.Assistant["error"], + error?: SessionLegacy.Assistant["error"], meta?: { providerID: string; modelID: string }, -): MessageV2.Assistant { +): SessionLegacy.Assistant { const infoModel = meta ?? { providerID: model.providerID, modelID: model.api.id } return { id, @@ -97,7 +99,7 @@ function assistantInfo( reasoning: 0, cache: { read: 0, write: 0 }, }, - } as unknown as MessageV2.Assistant + } as unknown as SessionLegacy.Assistant } function basePart(messageID: string, id: string) { @@ -110,7 +112,7 @@ function basePart(messageID: string, id: string) { describe("session.message-v2.toModelMessage", () => { test("filters out messages with no parts", async () => { - const input: MessageV2.WithParts[] = [ + const input: SessionLegacy.WithParts[] = [ { info: userInfo("m-empty"), parts: [], @@ -123,7 +125,7 @@ describe("session.message-v2.toModelMessage", () => { type: "text", text: "hello", }, - ] as MessageV2.Part[], + ] as SessionLegacy.Part[], }, ] @@ -138,7 +140,7 @@ describe("session.message-v2.toModelMessage", () => { test("filters out messages with only ignored parts", async () => { const messageID = "m-user" - const input: MessageV2.WithParts[] = [ + const input: SessionLegacy.WithParts[] = [ { info: userInfo(messageID), parts: [ @@ -148,7 +150,7 @@ describe("session.message-v2.toModelMessage", () => { text: "ignored", ignored: true, }, - ] as MessageV2.Part[], + ] as SessionLegacy.Part[], }, ] @@ -158,7 +160,7 @@ describe("session.message-v2.toModelMessage", () => { test("filters out user messages with only empty text parts", async () => { const messageID = "m-user" - const input: MessageV2.WithParts[] = [ + const input: SessionLegacy.WithParts[] = [ { info: userInfo(messageID), parts: [ @@ -167,7 +169,7 @@ describe("session.message-v2.toModelMessage", () => { type: "text", text: "", }, - ] as MessageV2.Part[], + ] as SessionLegacy.Part[], }, ] @@ -177,7 +179,7 @@ describe("session.message-v2.toModelMessage", () => { test("filters empty user text parts while keeping non-empty parts", async () => { const messageID = "m-user" - const input: MessageV2.WithParts[] = [ + const input: SessionLegacy.WithParts[] = [ { info: userInfo(messageID), parts: [ @@ -191,7 +193,7 @@ describe("session.message-v2.toModelMessage", () => { type: "text", text: "hello", }, - ] as MessageV2.Part[], + ] as SessionLegacy.Part[], }, ] @@ -206,7 +208,7 @@ describe("session.message-v2.toModelMessage", () => { test("includes synthetic text parts", async () => { const messageID = "m-user" - const input: MessageV2.WithParts[] = [ + const input: SessionLegacy.WithParts[] = [ { info: userInfo(messageID), parts: [ @@ -216,7 +218,7 @@ describe("session.message-v2.toModelMessage", () => { text: "hello", synthetic: true, }, - ] as MessageV2.Part[], + ] as SessionLegacy.Part[], }, { info: assistantInfo("m-assistant", messageID), @@ -227,7 +229,7 @@ describe("session.message-v2.toModelMessage", () => { text: "assistant", synthetic: true, }, - ] as MessageV2.Part[], + ] as SessionLegacy.Part[], }, ] @@ -246,7 +248,7 @@ describe("session.message-v2.toModelMessage", () => { test("converts user text/file parts and injects compaction/subtask prompts", async () => { const messageID = "m-user" - const input: MessageV2.WithParts[] = [ + const input: SessionLegacy.WithParts[] = [ { info: userInfo(messageID), parts: [ @@ -294,7 +296,7 @@ describe("session.message-v2.toModelMessage", () => { description: "desc", agent: "agent", }, - ] as MessageV2.Part[], + ] as SessionLegacy.Part[], }, ] @@ -320,7 +322,7 @@ describe("session.message-v2.toModelMessage", () => { const userID = "m-user" const assistantID = "m-assistant" - const input: MessageV2.WithParts[] = [ + const input: SessionLegacy.WithParts[] = [ { info: userInfo(userID), parts: [ @@ -329,7 +331,7 @@ describe("session.message-v2.toModelMessage", () => { type: "text", text: "run tool", }, - ] as MessageV2.Part[], + ] as SessionLegacy.Part[], }, { info: assistantInfo(assistantID, userID), @@ -364,7 +366,7 @@ describe("session.message-v2.toModelMessage", () => { }, metadata: { openai: { tool: "meta" } }, }, - ] as MessageV2.Part[], + ] as SessionLegacy.Part[], }, ] @@ -411,8 +413,8 @@ describe("session.message-v2.toModelMessage", () => { test("preserves jpeg tool-result media for anthropic models", async () => { const anthropicModel: Provider.Model = { ...model, - id: ModelID.make("anthropic/claude-opus-4-7"), - providerID: ProviderID.make("anthropic"), + id: ProviderV2.ModelID.make("anthropic/claude-opus-4-7"), + providerID: ProviderV2.ID.make("anthropic"), api: { id: "claude-opus-4-7-20250805", url: "https://api.anthropic.com", @@ -433,7 +435,7 @@ describe("session.message-v2.toModelMessage", () => { ) const userID = "m-user-anthropic" const assistantID = "m-assistant-anthropic" - const input: MessageV2.WithParts[] = [ + const input: SessionLegacy.WithParts[] = [ { info: userInfo(userID), parts: [ @@ -442,7 +444,7 @@ describe("session.message-v2.toModelMessage", () => { type: "text", text: "run tool", }, - ] as MessageV2.Part[], + ] as SessionLegacy.Part[], }, { info: assistantInfo(assistantID, userID), @@ -470,7 +472,7 @@ describe("session.message-v2.toModelMessage", () => { ], }, }, - ] as MessageV2.Part[], + ] as SessionLegacy.Part[], }, ] @@ -494,8 +496,8 @@ describe("session.message-v2.toModelMessage", () => { test("moves bedrock pdf tool-result media into a separate user message", async () => { const bedrockModel: Provider.Model = { ...model, - id: ModelID.make("amazon-bedrock/anthropic.claude-sonnet-4-6"), - providerID: ProviderID.make("amazon-bedrock"), + id: ProviderV2.ModelID.make("amazon-bedrock/anthropic.claude-sonnet-4-6"), + providerID: ProviderV2.ID.make("amazon-bedrock"), api: { id: "anthropic.claude-sonnet-4-6", url: "https://bedrock-runtime.us-east-1.amazonaws.com", @@ -514,7 +516,7 @@ describe("session.message-v2.toModelMessage", () => { const pdf = Buffer.from("%PDF-1.4\n").toString("base64") const userID = "m-user-bedrock-pdf" const assistantID = "m-assistant-bedrock-pdf" - const input: MessageV2.WithParts[] = [ + const input: SessionLegacy.WithParts[] = [ { info: userInfo(userID), parts: [ @@ -523,7 +525,7 @@ describe("session.message-v2.toModelMessage", () => { type: "text", text: "run tool", }, - ] as MessageV2.Part[], + ] as SessionLegacy.Part[], }, { info: assistantInfo(assistantID, userID), @@ -551,7 +553,7 @@ describe("session.message-v2.toModelMessage", () => { ], }, }, - ] as MessageV2.Part[], + ] as SessionLegacy.Part[], }, ] @@ -602,7 +604,7 @@ describe("session.message-v2.toModelMessage", () => { const userID = "m-user" const assistantID = "m-assistant" - const input: MessageV2.WithParts[] = [ + const input: SessionLegacy.WithParts[] = [ { info: userInfo(userID), parts: [ @@ -611,7 +613,7 @@ describe("session.message-v2.toModelMessage", () => { type: "text", text: "run tool", }, - ] as MessageV2.Part[], + ] as SessionLegacy.Part[], }, { info: assistantInfo(assistantID, userID, undefined, { providerID: "other", modelID: "other" }), @@ -644,7 +646,7 @@ describe("session.message-v2.toModelMessage", () => { }, metadata: { openai: { tool: "meta" } }, }, - ] as MessageV2.Part[], + ] as SessionLegacy.Part[], }, ] @@ -685,7 +687,7 @@ describe("session.message-v2.toModelMessage", () => { const userID = "m-user" const assistantID = "m-assistant" - const input: MessageV2.WithParts[] = [ + const input: SessionLegacy.WithParts[] = [ { info: userInfo(userID), parts: [ @@ -694,7 +696,7 @@ describe("session.message-v2.toModelMessage", () => { type: "text", text: "run tool", }, - ] as MessageV2.Part[], + ] as SessionLegacy.Part[], }, { info: assistantInfo(assistantID, userID), @@ -713,7 +715,7 @@ describe("session.message-v2.toModelMessage", () => { time: { start: 0, end: 1, compacted: 1 }, }, }, - ] as MessageV2.Part[], + ] as SessionLegacy.Part[], }, ] @@ -752,7 +754,7 @@ describe("session.message-v2.toModelMessage", () => { const userID = "m-user" const assistantID = "m-assistant" - const input: MessageV2.WithParts[] = [ + const input: SessionLegacy.WithParts[] = [ { info: userInfo(userID), parts: [ @@ -761,7 +763,7 @@ describe("session.message-v2.toModelMessage", () => { type: "text", text: "run tool", }, - ] as MessageV2.Part[], + ] as SessionLegacy.Part[], }, { info: assistantInfo(assistantID, userID), @@ -780,7 +782,7 @@ describe("session.message-v2.toModelMessage", () => { time: { start: 0, end: 1 }, }, }, - ] as MessageV2.Part[], + ] as SessionLegacy.Part[], }, ] @@ -822,7 +824,7 @@ describe("session.message-v2.toModelMessage", () => { const userID = "m-user" const assistantID = "m-assistant" - const input: MessageV2.WithParts[] = [ + const input: SessionLegacy.WithParts[] = [ { info: userInfo(userID), parts: [ @@ -831,7 +833,7 @@ describe("session.message-v2.toModelMessage", () => { type: "text", text: "run tool", }, - ] as MessageV2.Part[], + ] as SessionLegacy.Part[], }, { info: assistantInfo(assistantID, userID), @@ -850,7 +852,7 @@ describe("session.message-v2.toModelMessage", () => { }, metadata: { openai: { tool: "meta" } }, }, - ] as MessageV2.Part[], + ] as SessionLegacy.Part[], }, ] @@ -900,7 +902,7 @@ describe("session.message-v2.toModelMessage", () => { "", ].join("\n") - const input: MessageV2.WithParts[] = [ + const input: SessionLegacy.WithParts[] = [ { info: userInfo(userID), parts: [ @@ -909,7 +911,7 @@ describe("session.message-v2.toModelMessage", () => { type: "text", text: "run tool", }, - ] as MessageV2.Part[], + ] as SessionLegacy.Part[], }, { info: assistantInfo(assistantID, userID), @@ -927,7 +929,7 @@ describe("session.message-v2.toModelMessage", () => { time: { start: 0, end: 1 }, }, }, - ] as MessageV2.Part[], + ] as SessionLegacy.Part[], }, ] @@ -965,12 +967,12 @@ describe("session.message-v2.toModelMessage", () => { test("filters assistant messages with non-abort errors", async () => { const assistantID = "m-assistant" - const input: MessageV2.WithParts[] = [ + const input: SessionLegacy.WithParts[] = [ { info: assistantInfo( assistantID, "m-parent", - new MessageV2.APIError({ message: "boom", isRetryable: true }).toObject() as MessageV2.APIError, + new SessionLegacy.APIError({ message: "boom", isRetryable: true }).toObject() as SessionLegacy.APIError, ), parts: [ { @@ -978,7 +980,7 @@ describe("session.message-v2.toModelMessage", () => { type: "text", text: "should not render", }, - ] as MessageV2.Part[], + ] as SessionLegacy.Part[], }, ] @@ -989,9 +991,9 @@ describe("session.message-v2.toModelMessage", () => { const assistantID1 = "m-assistant-1" const assistantID2 = "m-assistant-2" - const aborted = new MessageV2.AbortedError({ message: "aborted" }).toObject() as MessageV2.Assistant["error"] + const aborted = new SessionLegacy.AbortedError({ message: "aborted" }).toObject() as SessionLegacy.Assistant["error"] - const input: MessageV2.WithParts[] = [ + const input: SessionLegacy.WithParts[] = [ { info: assistantInfo(assistantID1, "m-parent", aborted), parts: [ @@ -1006,7 +1008,7 @@ describe("session.message-v2.toModelMessage", () => { type: "text", text: "partial answer", }, - ] as MessageV2.Part[], + ] as SessionLegacy.Part[], }, { info: assistantInfo(assistantID2, "m-parent", aborted), @@ -1021,7 +1023,7 @@ describe("session.message-v2.toModelMessage", () => { text: "thinking", time: { start: 0 }, }, - ] as MessageV2.Part[], + ] as SessionLegacy.Part[], }, ] @@ -1040,8 +1042,8 @@ describe("session.message-v2.toModelMessage", () => { const assistantID = "m-assistant" const openrouterModel: Provider.Model = { ...model, - id: ModelID.make("deepseek/deepseek-v4-pro"), - providerID: ProviderID.make("openrouter"), + id: ProviderV2.ModelID.make("deepseek/deepseek-v4-pro"), + providerID: ProviderV2.ID.make("openrouter"), api: { id: "deepseek/deepseek-v4-pro", url: "https://openrouter.ai/api/v1", @@ -1061,7 +1063,7 @@ describe("session.message-v2.toModelMessage", () => { index: 0, }, ] - const input: MessageV2.WithParts[] = [ + const input: SessionLegacy.WithParts[] = [ { info: assistantInfo(assistantID, "m-parent", undefined, { providerID: openrouterModel.providerID, @@ -1084,7 +1086,7 @@ describe("session.message-v2.toModelMessage", () => { type: "text", text: "answer", }, - ] as MessageV2.Part[], + ] as SessionLegacy.Part[], }, ] @@ -1112,7 +1114,7 @@ describe("session.message-v2.toModelMessage", () => { test("splits assistant messages on step-start boundaries", async () => { const assistantID = "m-assistant" - const input: MessageV2.WithParts[] = [ + const input: SessionLegacy.WithParts[] = [ { info: assistantInfo(assistantID, "m-parent"), parts: [ @@ -1130,7 +1132,7 @@ describe("session.message-v2.toModelMessage", () => { type: "text", text: "second", }, - ] as MessageV2.Part[], + ] as SessionLegacy.Part[], }, ] @@ -1149,7 +1151,7 @@ describe("session.message-v2.toModelMessage", () => { test("drops messages that only contain step-start parts", async () => { const assistantID = "m-assistant" - const input: MessageV2.WithParts[] = [ + const input: SessionLegacy.WithParts[] = [ { info: assistantInfo(assistantID, "m-parent"), parts: [ @@ -1157,7 +1159,7 @@ describe("session.message-v2.toModelMessage", () => { ...basePart(assistantID, "p1"), type: "step-start", }, - ] as MessageV2.Part[], + ] as SessionLegacy.Part[], }, ] @@ -1168,7 +1170,7 @@ describe("session.message-v2.toModelMessage", () => { const userID = "m-user" const assistantID = "m-assistant" - const input: MessageV2.WithParts[] = [ + const input: SessionLegacy.WithParts[] = [ { info: userInfo(userID), parts: [ @@ -1177,7 +1179,7 @@ describe("session.message-v2.toModelMessage", () => { type: "text", text: "run tool", }, - ] as MessageV2.Part[], + ] as SessionLegacy.Part[], }, { info: assistantInfo(assistantID, userID), @@ -1204,7 +1206,7 @@ describe("session.message-v2.toModelMessage", () => { time: { start: 0 }, }, }, - ] as MessageV2.Part[], + ] as SessionLegacy.Part[], }, ] @@ -1257,7 +1259,7 @@ describe("session.message-v2.toModelMessage", () => { test("substitutes space for empty text between signed reasoning blocks", async () => { // Reproduces the bug pattern: [reasoning(sig), text(""), reasoning(sig), text(full)] const assistantID = "m-assistant" - const input: MessageV2.WithParts[] = [ + const input: SessionLegacy.WithParts[] = [ { info: assistantInfo(assistantID, "m-parent"), parts: [ @@ -1277,7 +1279,7 @@ describe("session.message-v2.toModelMessage", () => { metadata: { anthropic: { signature: "sig2" } }, }, { ...basePart(assistantID, "p6"), type: "text", text: "the answer" }, - ] as MessageV2.Part[], + ] as SessionLegacy.Part[], }, ] @@ -1293,7 +1295,7 @@ describe("session.message-v2.toModelMessage", () => { // Bedrock signed reasoning is preserved as reasoning metadata, but unlike the // direct Anthropic path we do not preserve empty text separators for Bedrock. const assistantID = "m-assistant-bedrock" - const input: MessageV2.WithParts[] = [ + const input: SessionLegacy.WithParts[] = [ { info: assistantInfo(assistantID, "m-parent"), parts: [ @@ -1305,7 +1307,7 @@ describe("session.message-v2.toModelMessage", () => { }, { ...basePart(assistantID, "p2"), type: "text", text: "" }, { ...basePart(assistantID, "p3"), type: "text", text: "answer" }, - ] as MessageV2.Part[], + ] as SessionLegacy.Part[], }, ] @@ -1320,14 +1322,14 @@ describe("session.message-v2.toModelMessage", () => { // Non-Anthropic providers' reasoning doesn't position-validate, so empty text // should be filtered normally rather than substituted. const assistantID = "m-assistant-unsigned" - const input: MessageV2.WithParts[] = [ + const input: SessionLegacy.WithParts[] = [ { info: assistantInfo(assistantID, "m-parent"), parts: [ { ...basePart(assistantID, "p1"), type: "reasoning", text: "thinking" }, { ...basePart(assistantID, "p2"), type: "text", text: "" }, { ...basePart(assistantID, "p3"), type: "text", text: "answer" }, - ] as MessageV2.Part[], + ] as SessionLegacy.Part[], }, ] @@ -1340,13 +1342,13 @@ describe("session.message-v2.toModelMessage", () => { test("leaves empty text alone in assistant messages without reasoning", async () => { const assistantID = "m-assistant-no-reasoning" - const input: MessageV2.WithParts[] = [ + const input: SessionLegacy.WithParts[] = [ { info: assistantInfo(assistantID, "m-parent"), parts: [ { ...basePart(assistantID, "p1"), type: "text", text: "" }, { ...basePart(assistantID, "p2"), type: "text", text: "hello" }, - ] as MessageV2.Part[], + ] as SessionLegacy.Part[], }, ] @@ -1458,7 +1460,7 @@ describe("session.message-v2.fromError", () => { isRetryable: false, }) const result = MessageV2.fromError(error, { providerID }) - expect(MessageV2.ContextOverflowError.isInstance(result)).toBe(true) + expect(SessionLegacy.ContextOverflowError.isInstance(result)).toBe(true) }) }) @@ -1479,7 +1481,7 @@ describe("session.message-v2.fromError", () => { isRetryable: false, }) const result = MessageV2.fromError(error, { providerID }) - expect(MessageV2.ContextOverflowError.isInstance(result)).toBe(true) + expect(SessionLegacy.ContextOverflowError.isInstance(result)).toBe(true) }) test("does not classify 429 no body as context overflow", () => { @@ -1494,8 +1496,8 @@ describe("session.message-v2.fromError", () => { }), { providerID }, ) - expect(MessageV2.ContextOverflowError.isInstance(result)).toBe(false) - expect(MessageV2.APIError.isInstance(result)).toBe(true) + expect(SessionLegacy.ContextOverflowError.isInstance(result)).toBe(false) + expect(SessionLegacy.APIError.isInstance(result)).toBe(true) }) test("serializes unknown inputs", () => { @@ -1530,9 +1532,9 @@ describe("session.message-v2.fromError", () => { const result = MessageV2.fromError(zlibError, { providerID }) - expect(MessageV2.APIError.isInstance(result)).toBe(true) - expect((result as MessageV2.APIError).data.isRetryable).toBe(true) - expect((result as MessageV2.APIError).data.message).toInclude("decompression") + expect(SessionLegacy.APIError.isInstance(result)).toBe(true) + expect((result as SessionLegacy.APIError).data.isRetryable).toBe(true) + expect((result as SessionLegacy.APIError).data.message).toInclude("decompression") }) test("classifies ZlibError as AbortedError when abort context is provided", () => { @@ -1556,21 +1558,21 @@ describe("session.message-v2.latest", () => { const CONTINUE_USER = MessageID.make("msg_005") const NEW_COMPACTION_USER = MessageID.make("msg_006") - const tailUser: MessageV2.WithParts = { + const tailUser: SessionLegacy.WithParts = { info: userInfo(TAIL_USER), - parts: [{ ...basePart(TAIL_USER, "p1"), type: "text", text: "original prompt" }] as MessageV2.Part[], + parts: [{ ...basePart(TAIL_USER, "p1"), type: "text", text: "original prompt" }] as SessionLegacy.Part[], } - const overflowAssistant: MessageV2.WithParts = { + const overflowAssistant: SessionLegacy.WithParts = { info: { ...assistantInfo(OVERFLOW_ASSISTANT, TAIL_USER), finish: "tool-calls", tokens: { input: 280_000, output: 200, reasoning: 0, cache: { read: 0, write: 0 }, total: 280_200 }, - } as MessageV2.Assistant, + } as SessionLegacy.Assistant, parts: [], } - const compactionUser: MessageV2.WithParts = { + const compactionUser: SessionLegacy.WithParts = { info: userInfo(COMPACTION_USER), parts: [ { @@ -1579,20 +1581,20 @@ describe("session.message-v2.latest", () => { auto: true, tail_start_id: TAIL_USER, }, - ] as MessageV2.Part[], + ] as SessionLegacy.Part[], } - const summaryAssistant: MessageV2.WithParts = { + const summaryAssistant: SessionLegacy.WithParts = { info: { ...assistantInfo(SUMMARY_ASSISTANT, COMPACTION_USER), summary: true, finish: "stop", tokens: { input: 150_000, output: 1_500, reasoning: 0, cache: { read: 0, write: 0 }, total: 151_500 }, - } as MessageV2.Assistant, + } as SessionLegacy.Assistant, parts: [], } - const continueUser: MessageV2.WithParts = { + const continueUser: SessionLegacy.WithParts = { info: userInfo(CONTINUE_USER), parts: [ { @@ -1602,7 +1604,7 @@ describe("session.message-v2.latest", () => { synthetic: true, metadata: { compaction_continue: true }, }, - ] as MessageV2.Part[], + ] as SessionLegacy.Part[], } // Regression for double auto-compaction. The reorder in filterCompacted @@ -1628,7 +1630,7 @@ describe("session.message-v2.latest", () => { }) test("a fresh compaction-user newer than the latest summary surfaces in tasks", () => { - const newCompactionUser: MessageV2.WithParts = { + const newCompactionUser: SessionLegacy.WithParts = { info: userInfo(NEW_COMPACTION_USER), parts: [ { @@ -1636,7 +1638,7 @@ describe("session.message-v2.latest", () => { type: "compaction", auto: true, }, - ] as MessageV2.Part[], + ] as SessionLegacy.Part[], } const state = MessageV2.latest([ diff --git a/packages/opencode/test/session/messages-pagination.test.ts b/packages/opencode/test/session/messages-pagination.test.ts index e558d07b50..5da80ea3e4 100644 --- a/packages/opencode/test/session/messages-pagination.test.ts +++ b/packages/opencode/test/session/messages-pagination.test.ts @@ -1,16 +1,19 @@ import { describe, expect, test } from "bun:test" -import { Effect, Option } from "effect" +import { SessionLegacy } from "@opencode-ai/core/session/legacy" +import { Database } from "@opencode-ai/core/database/database" +import { Effect, Layer, Option } from "effect" import { Session as SessionNs } from "@/session/session" import { MessageV2 } from "../../src/session/message-v2" import { MessageID, PartID, type SessionID } from "../../src/session/schema" -import { ModelID, ProviderID } from "../../src/provider/schema" + import { NotFoundError } from "@/storage/storage" import * as Log from "@opencode-ai/core/util/log" import { testEffect } from "../lib/effect" +import { ProviderV2 } from "@opencode-ai/core/provider" void Log.init({ print: false }) -const it = testEffect(SessionNs.defaultLayer) +const it = testEffect(Layer.mergeAll(SessionNs.defaultLayer, Database.defaultLayer)) const withSession = ( fn: (input: { session: SessionNs.Interface; sessionID: SessionID }) => Effect.Effect, @@ -45,7 +48,7 @@ const fill = Effect.fn("Test.fill")(function* ( model: { providerID: "test", modelID: "test" }, tools: {}, mode: "", - } as unknown as MessageV2.Info) + } as unknown as SessionLegacy.Info) yield* session.updatePart({ id: PartID.ascending(), sessionID, @@ -69,7 +72,7 @@ const addUser = Effect.fn("Test.addUser")(function* (sessionID: SessionID, text? model: { providerID: "test", modelID: "test" }, tools: {}, mode: "", - } as unknown as MessageV2.Info) + } as unknown as SessionLegacy.Info) if (text) { yield* session.updatePart({ id: PartID.ascending(), @@ -85,7 +88,7 @@ const addUser = Effect.fn("Test.addUser")(function* (sessionID: SessionID, text? const addAssistant = Effect.fn("Test.addAssistant")(function* ( sessionID: SessionID, parentID: MessageID, - opts?: { summary?: boolean; finish?: string; error?: MessageV2.Assistant["error"] }, + opts?: { summary?: boolean; finish?: string; error?: SessionLegacy.Assistant["error"] }, ) { const session = yield* SessionNs.Service const id = MessageID.ascending() @@ -95,8 +98,8 @@ const addAssistant = Effect.fn("Test.addAssistant")(function* ( role: "assistant", time: { created: Date.now() }, parentID, - modelID: ModelID.make("test"), - providerID: ProviderID.make("test"), + modelID: ProviderV2.ModelID.make("test"), + providerID: ProviderV2.ID.make("test"), mode: "", agent: "default", path: { cwd: "/", root: "/" }, @@ -105,7 +108,7 @@ const addAssistant = Effect.fn("Test.addAssistant")(function* ( summary: opts?.summary, finish: opts?.finish, error: opts?.error, - } as unknown as MessageV2.Info) + } as unknown as SessionLegacy.Info) return id }) @@ -310,7 +313,7 @@ describe("MessageV2.stream", () => { Effect.gen(function* () { const ids = yield* fill(sessionID, 5) - const items = Array.from(MessageV2.stream(sessionID)) + const items = yield* MessageV2.stream(sessionID) expect(items.map((item) => item.info.id)).toEqual(ids.slice().reverse()) }), ), @@ -319,7 +322,7 @@ describe("MessageV2.stream", () => { it.instance("yields nothing for empty session", () => withSession(({ sessionID }) => Effect.gen(function* () { - const items = Array.from(MessageV2.stream(sessionID)) + const items = yield* MessageV2.stream(sessionID) expect(items).toHaveLength(0) }), ), @@ -330,7 +333,7 @@ describe("MessageV2.stream", () => { Effect.gen(function* () { const ids = yield* fill(sessionID, 1) - const items = Array.from(MessageV2.stream(sessionID)) + const items = yield* MessageV2.stream(sessionID) expect(items).toHaveLength(1) expect(items[0].info.id).toBe(ids[0]) }), @@ -342,7 +345,7 @@ describe("MessageV2.stream", () => { Effect.gen(function* () { yield* fill(sessionID, 3) - const items = Array.from(MessageV2.stream(sessionID)) + const items = yield* MessageV2.stream(sessionID) for (const item of items) { expect(item.parts).toHaveLength(1) expect(item.parts[0].type).toBe("text") @@ -356,7 +359,7 @@ describe("MessageV2.stream", () => { Effect.gen(function* () { const ids = yield* fill(sessionID, 60) - const items = Array.from(MessageV2.stream(sessionID)) + const items = yield* MessageV2.stream(sessionID) expect(items).toHaveLength(60) expect(items[0].info.id).toBe(ids[ids.length - 1]) expect(items[59].info.id).toBe(ids[0]) @@ -364,17 +367,13 @@ describe("MessageV2.stream", () => { ), ) - it.instance("is a sync generator", () => + it.instance("returns an Effect", () => withSession(({ sessionID }) => Effect.gen(function* () { yield* fill(sessionID, 1) - const gen = MessageV2.stream(sessionID) - const first = gen.next() - // sync generator returns { value, done } directly, not a Promise - expect(first).toHaveProperty("value") - expect(first).toHaveProperty("done") - expect(first.done).toBe(false) + const result = yield* MessageV2.stream(sessionID) + expect(result).toHaveLength(1) }), ), ) @@ -386,10 +385,10 @@ describe("MessageV2.parts", () => { Effect.gen(function* () { const [id] = yield* fill(sessionID, 1) - const result = MessageV2.parts(id) + const result = yield* MessageV2.parts(id) expect(result).toHaveLength(1) expect(result[0].type).toBe("text") - expect((result[0] as MessageV2.TextPart).text).toBe("m0") + expect((result[0] as SessionLegacy.TextPart).text).toBe("m0") }), ), ) @@ -399,7 +398,7 @@ describe("MessageV2.parts", () => { Effect.gen(function* () { const id = yield* addUser(sessionID) - const result = MessageV2.parts(id) + const result = yield* MessageV2.parts(id) expect(result).toEqual([]) }), ), @@ -425,11 +424,11 @@ describe("MessageV2.parts", () => { text: "third", }) - const result = MessageV2.parts(id) + const result = yield* MessageV2.parts(id) expect(result).toHaveLength(3) - expect((result[0] as MessageV2.TextPart).text).toBe("m0") - expect((result[1] as MessageV2.TextPart).text).toBe("second") - expect((result[2] as MessageV2.TextPart).text).toBe("third") + expect((result[0] as SessionLegacy.TextPart).text).toBe("m0") + expect((result[1] as SessionLegacy.TextPart).text).toBe("second") + expect((result[2] as SessionLegacy.TextPart).text).toBe("third") }), ), ) @@ -437,7 +436,7 @@ describe("MessageV2.parts", () => { it.instance("returns empty for non-existent message id", () => Effect.gen(function* () { yield* SessionNs.Service - const result = MessageV2.parts(MessageID.ascending()) + const result = yield* MessageV2.parts(MessageID.ascending()) expect(result).toEqual([]) }), ) @@ -447,7 +446,7 @@ describe("MessageV2.parts", () => { Effect.gen(function* () { const [id] = yield* fill(sessionID, 1) - const result = MessageV2.parts(id) + const result = yield* MessageV2.parts(id) expect(result[0].sessionID).toBe(sessionID) expect(result[0].messageID).toBe(id) }), @@ -466,7 +465,7 @@ describe("MessageV2.get", () => { expect(result.info.sessionID).toBe(sessionID) expect(result.info.role).toBe("user") expect(result.parts).toHaveLength(1) - expect((result.parts[0] as MessageV2.TextPart).text).toBe("m0") + expect((result.parts[0] as SessionLegacy.TextPart).text).toBe("m0") }), ), ) @@ -536,7 +535,7 @@ describe("MessageV2.get", () => { const result = yield* MessageV2.get({ sessionID, messageID: aid }) expect(result.info.role).toBe("assistant") expect(result.parts).toHaveLength(1) - expect((result.parts[0] as MessageV2.TextPart).text).toBe("response") + expect((result.parts[0] as SessionLegacy.TextPart).text).toBe("response") }), ), ) @@ -604,7 +603,7 @@ describe("MessageV2.filterCompacted", () => { Effect.gen(function* () { const ids = yield* fill(sessionID, 5) - const result = MessageV2.filterCompacted(MessageV2.stream(sessionID)) + const result = MessageV2.filterCompacted(yield* MessageV2.stream(sessionID)) expect(result).toHaveLength(5) // reversed from newest-first to chronological expect(result.map((item) => item.info.id)).toEqual(ids) @@ -638,7 +637,7 @@ describe("MessageV2.filterCompacted", () => { text: "new response", }) - const result = MessageV2.filterCompacted(MessageV2.stream(sessionID)) + const result = MessageV2.filterCompacted(yield* MessageV2.stream(sessionID)) // Includes compaction boundary: u1, a1, u2, a2 expect(result[0].info.id).toBe(u1) expect(result.length).toBe(4) @@ -660,7 +659,7 @@ describe("MessageV2.filterCompacted", () => { yield* addCompactionPart(sessionID, u1) yield* addUser(sessionID, "world") - const result = MessageV2.filterCompacted(MessageV2.stream(sessionID)) + const result = MessageV2.filterCompacted(yield* MessageV2.stream(sessionID)) expect(result).toHaveLength(2) }), ), @@ -672,14 +671,14 @@ describe("MessageV2.filterCompacted", () => { const u1 = yield* addUser(sessionID, "hello") yield* addCompactionPart(sessionID, u1) - const error = new MessageV2.APIError({ + const error = new SessionLegacy.APIError({ message: "boom", isRetryable: true, - }).toObject() as MessageV2.Assistant["error"] + }).toObject() as SessionLegacy.Assistant["error"] yield* addAssistant(sessionID, u1, { summary: true, finish: "end_turn", error }) yield* addUser(sessionID, "retry") - const result = MessageV2.filterCompacted(MessageV2.stream(sessionID)) + const result = MessageV2.filterCompacted(yield* MessageV2.stream(sessionID)) // Error assistant doesn't add to completed, so compaction boundary never triggers expect(result).toHaveLength(3) }), @@ -696,7 +695,7 @@ describe("MessageV2.filterCompacted", () => { yield* addAssistant(sessionID, u1, { summary: true }) yield* addUser(sessionID, "next") - const result = MessageV2.filterCompacted(MessageV2.stream(sessionID)) + const result = MessageV2.filterCompacted(yield* MessageV2.stream(sessionID)) expect(result).toHaveLength(3) }), ), @@ -746,7 +745,7 @@ describe("MessageV2.filterCompacted", () => { text: "third reply", }) - const result = MessageV2.filterCompacted(MessageV2.stream(sessionID)) + const result = MessageV2.filterCompacted(yield* MessageV2.stream(sessionID)) expect(result.map((item) => item.info.id)).toEqual([c1, s1, u2, a2, u3, a3]) }), @@ -799,11 +798,11 @@ describe("MessageV2.filterCompacted", () => { text: "third reply", }) - const parentFiltered = MessageV2.filterCompacted(MessageV2.stream(created.id)) + const parentFiltered = MessageV2.filterCompacted(yield* MessageV2.stream(created.id)) expect(parentFiltered.map((item) => item.info.id)).toEqual([c1, s1, u2, a2, u3, a3]) const forked = yield* session.fork({ sessionID: created.id }) - const childFiltered = MessageV2.filterCompacted(MessageV2.stream(forked.id)) + const childFiltered = MessageV2.filterCompacted(yield* MessageV2.stream(forked.id)) expect(childFiltered).toHaveLength(parentFiltered.length) const tailPart = childFiltered.flatMap((m) => m.parts).find((p) => p.type === "compaction") @@ -869,7 +868,7 @@ describe("MessageV2.filterCompacted", () => { text: "third reply", }) - const result = MessageV2.filterCompacted(MessageV2.stream(sessionID)) + const result = MessageV2.filterCompacted(yield* MessageV2.stream(sessionID)) expect(result.map((item) => item.info.id)).toEqual([c1, s1, a3, u3, a4]) }), @@ -941,7 +940,7 @@ describe("MessageV2.filterCompacted", () => { text: "fourth reply", }) - const result = MessageV2.filterCompacted(MessageV2.stream(sessionID)) + const result = MessageV2.filterCompacted(yield* MessageV2.stream(sessionID)) expect(result.map((item) => item.info.id)).toEqual([c2, s2, u3, a3, u4, a4]) }), @@ -951,7 +950,7 @@ describe("MessageV2.filterCompacted", () => { test("works with array input", () => { // filterCompacted accepts any Iterable, not just generators const id = MessageID.ascending() - const items: MessageV2.WithParts[] = [ + const items: SessionLegacy.WithParts[] = [ { info: { id, @@ -960,8 +959,8 @@ describe("MessageV2.filterCompacted", () => { time: { created: 1 }, agent: "test", model: { providerID: "test", modelID: "test" }, - } as unknown as MessageV2.Info, - parts: [{ type: "text", text: "hello" }] as unknown as MessageV2.Part[], + } as unknown as SessionLegacy.Info, + parts: [{ type: "text", text: "hello" }] as unknown as SessionLegacy.Part[], }, ] const result = MessageV2.filterCompacted(items) @@ -1014,7 +1013,7 @@ describe("MessageV2 consistency", () => { const [id] = yield* fill(sessionID, 1) const got = yield* MessageV2.get({ sessionID, messageID: id }) - const standalone = MessageV2.parts(id) + const standalone = yield* MessageV2.parts(id) expect(got.parts).toEqual(standalone) }), ), @@ -1025,9 +1024,9 @@ describe("MessageV2 consistency", () => { Effect.gen(function* () { yield* fill(sessionID, 7) - const streamed = Array.from(MessageV2.stream(sessionID)) + const streamed = yield* MessageV2.stream(sessionID) - const paged = [] as MessageV2.WithParts[] + const paged = [] as SessionLegacy.WithParts[] let cursor: string | undefined while (true) { const result = yield* MessageV2.page({ sessionID, limit: 3, before: cursor }) @@ -1048,8 +1047,9 @@ describe("MessageV2 consistency", () => { Effect.gen(function* () { yield* fill(sessionID, 4) - const filtered = MessageV2.filterCompacted(MessageV2.stream(sessionID)) - const all = Array.from(MessageV2.stream(sessionID)).reverse() + const stream = yield* MessageV2.stream(sessionID) + const filtered = MessageV2.filterCompacted(stream) + const all = stream.toReversed() expect(filtered.map((m) => m.info.id)).toEqual(all.map((m) => m.info.id)) }), diff --git a/packages/opencode/test/session/processor-effect.test.ts b/packages/opencode/test/session/processor-effect.test.ts index ede122297a..e68ad962fe 100644 --- a/packages/opencode/test/session/processor-effect.test.ts +++ b/packages/opencode/test/session/processor-effect.test.ts @@ -1,4 +1,7 @@ import { NodeFileSystem } from "@effect/platform-node" +import { SessionLegacy } from "@opencode-ai/core/session/legacy" +import { Database } from "@opencode-ai/core/database/database" +import { EventV2Bridge } from "@/event-v2-bridge" import { expect } from "bun:test" import { tool } from "ai" import { Cause, Effect, Exit, Fiber, Layer } from "effect" @@ -6,13 +9,12 @@ import path from "path" import z from "zod" import type { Agent } from "../../src/agent/agent" import { Agent as AgentSvc } from "../../src/agent/agent" -import { Bus } from "../../src/bus" import { Config } from "@/config/config" import { Image } from "@/image/image" import { Permission } from "../../src/permission" import { Plugin } from "../../src/plugin" import { Provider } from "@/provider/provider" -import { ModelID, ProviderID } from "../../src/provider/schema" + import { Session } from "@/session/session" import { LLM } from "../../src/session/llm" import { MessageV2 } from "../../src/session/message-v2" @@ -26,9 +28,8 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { provideTmpdirServer } from "../fixture/fixture" import { testEffect } from "../lib/effect" import { raw, reply, TestLLMServer } from "../lib/llm-server" -import { SyncEvent } from "@/sync" import { RuntimeFlags } from "@/effect/runtime-flags" -import { EventV2Bridge } from "@/event-v2-bridge" +import { ProviderV2 } from "@opencode-ai/core/provider" void Log.init({ print: false }) @@ -42,8 +43,8 @@ const summary = Layer.succeed( ) const ref = { - providerID: ProviderID.make("test"), - modelID: ModelID.make("test-model"), + providerID: ProviderV2.ID.make("test"), + modelID: ProviderV2.ModelID.make("test-model"), } const cfg = { @@ -145,7 +146,7 @@ const assistant = Effect.fn("TestSession.assistant")(function* ( root: string, ) { const session = yield* Session.Service - const msg: MessageV2.Assistant = { + const msg: SessionLegacy.Assistant = { id: MessageID.ascending(), role: "assistant", sessionID, @@ -170,7 +171,7 @@ const assistant = Effect.fn("TestSession.assistant")(function* ( return msg }) -const status = SessionStatus.layer.pipe(Layer.provideMerge(Bus.layer)) +const status = SessionStatus.layer.pipe(Layer.provideMerge(EventV2Bridge.defaultLayer)) const infra = Layer.mergeAll(NodeFileSystem.layer, CrossSpawnSpawner.defaultLayer) const deps = Layer.mergeAll( Session.defaultLayer, @@ -182,7 +183,7 @@ const deps = Layer.mergeAll( LLM.defaultLayer, Provider.defaultLayer, status, - SyncEvent.defaultLayer, + Database.defaultLayer, EventV2Bridge.defaultLayer, ).pipe(Layer.provideMerge(infra)) const env = Layer.mergeAll( @@ -212,6 +213,7 @@ it.live("session.processor effect tests capture llm input cleanly", () => provideTmpdirServer( ({ dir, llm }) => Effect.gen(function* () { + const database = yield* Database.Service const { processors, session, provider } = yield* boot() yield* llm.text("hello") @@ -234,7 +236,7 @@ it.live("session.processor effect tests capture llm input cleanly", () => time: parent.time, agent: parent.agent, model: { providerID: ref.providerID, modelID: ref.modelID }, - } satisfies MessageV2.User, + } satisfies SessionLegacy.User, sessionID: chat.id, model: mdl, agent: agent(), @@ -244,7 +246,7 @@ it.live("session.processor effect tests capture llm input cleanly", () => } satisfies LLM.StreamInput const value = yield* handle.process(input) - const parts = MessageV2.parts(msg.id) + const parts = yield* MessageV2.parts(msg.id) const calls = yield* llm.calls expect(value).toBe("continue") @@ -259,6 +261,7 @@ it.live("session.processor effect tests preserve text start time", () => provideTmpdirServer( ({ dir, llm }) => Effect.gen(function* () { + const database = yield* Database.Service const gate = defer() const { processors, session, provider } = yield* boot() @@ -306,7 +309,7 @@ it.live("session.processor effect tests preserve text start time", () => time: parent.time, agent: parent.agent, model: { providerID: ref.providerID, modelID: ref.modelID }, - } satisfies MessageV2.User, + } satisfies SessionLegacy.User, sessionID: chat.id, model: mdl, agent: agent(), @@ -317,14 +320,19 @@ it.live("session.processor effect tests preserve text start time", () => .pipe(Effect.forkChild) yield* waitFor( - Effect.sync(() => MessageV2.parts(msg.id).find((part): part is MessageV2.TextPart => part.type === "text")), + MessageV2.parts(msg.id).pipe( + Effect.map((parts) => parts.find((part): part is SessionLegacy.TextPart => part.type === "text")), + Effect.provideService(Database.Service, database), + ), "timed out waiting for text part", ) yield* Effect.sleep("20 millis") gate.resolve() const exit = yield* Fiber.await(run) - const text = MessageV2.parts(msg.id).find((part): part is MessageV2.TextPart => part.type === "text") + const text = (yield* MessageV2.parts(msg.id)).find( + (part): part is SessionLegacy.TextPart => part.type === "text", + ) expect(Exit.isSuccess(exit)).toBe(true) expect(text?.text).toBe("hello") @@ -341,6 +349,7 @@ it.live("session.processor effect tests stop after token overflow requests compa provideTmpdirServer( ({ dir, llm }) => Effect.gen(function* () { + const database = yield* Database.Service const { processors, session, provider } = yield* boot() yield* llm.text("after", { usage: { input: 100, output: 0 } }) @@ -364,7 +373,7 @@ it.live("session.processor effect tests stop after token overflow requests compa time: parent.time, agent: parent.agent, model: { providerID: ref.providerID, modelID: ref.modelID }, - } satisfies MessageV2.User, + } satisfies SessionLegacy.User, sessionID: chat.id, model: mdl, agent: agent(), @@ -373,7 +382,7 @@ it.live("session.processor effect tests stop after token overflow requests compa tools: {}, }) - const parts = MessageV2.parts(msg.id) + const parts = yield* MessageV2.parts(msg.id) expect(value).toBe("compact") expect(parts.some((part) => part.type === "text" && part.text === "after")).toBe(true) @@ -387,6 +396,7 @@ it.live("session.processor effect tests capture reasoning from http mock", () => provideTmpdirServer( ({ dir, llm }) => Effect.gen(function* () { + const database = yield* Database.Service const { processors, session, provider } = yield* boot() yield* llm.push(reply().reason("think").text("done").stop()) @@ -409,7 +419,7 @@ it.live("session.processor effect tests capture reasoning from http mock", () => time: parent.time, agent: parent.agent, model: { providerID: ref.providerID, modelID: ref.modelID }, - } satisfies MessageV2.User, + } satisfies SessionLegacy.User, sessionID: chat.id, model: mdl, agent: agent(), @@ -418,9 +428,9 @@ it.live("session.processor effect tests capture reasoning from http mock", () => tools: {}, }) - const parts = MessageV2.parts(msg.id) - const reasoning = parts.find((part): part is MessageV2.ReasoningPart => part.type === "reasoning") - const text = parts.find((part): part is MessageV2.TextPart => part.type === "text") + const parts = yield* MessageV2.parts(msg.id) + const reasoning = parts.find((part): part is SessionLegacy.ReasoningPart => part.type === "reasoning") + const text = parts.find((part): part is SessionLegacy.TextPart => part.type === "text") expect(value).toBe("continue") expect(yield* llm.calls).toBe(1) @@ -457,7 +467,7 @@ it.live("session.processor effect tests reset reasoning state across retries", ( time: parent.time, agent: parent.agent, model: { providerID: ref.providerID, modelID: ref.modelID }, - } satisfies MessageV2.User, + } satisfies SessionLegacy.User, sessionID: chat.id, model: mdl, agent: agent(), @@ -466,8 +476,8 @@ it.live("session.processor effect tests reset reasoning state across retries", ( tools: {}, }) - const parts = MessageV2.parts(msg.id) - const reasoning = parts.filter((part): part is MessageV2.ReasoningPart => part.type === "reasoning") + const parts = yield* MessageV2.parts(msg.id) + const reasoning = parts.filter((part): part is SessionLegacy.ReasoningPart => part.type === "reasoning") expect(value).toBe("continue") expect(yield* llm.calls).toBe(2) @@ -504,7 +514,7 @@ it.live("session.processor effect tests do not retry unknown json errors", () => time: parent.time, agent: parent.agent, model: { providerID: ref.providerID, modelID: ref.modelID }, - } satisfies MessageV2.User, + } satisfies SessionLegacy.User, sessionID: chat.id, model: mdl, agent: agent(), @@ -548,7 +558,7 @@ it.live("session.processor effect tests retry recognized structured json errors" time: parent.time, agent: parent.agent, model: { providerID: ref.providerID, modelID: ref.modelID }, - } satisfies MessageV2.User, + } satisfies SessionLegacy.User, sessionID: chat.id, model: mdl, agent: agent(), @@ -557,7 +567,7 @@ it.live("session.processor effect tests retry recognized structured json errors" tools: {}, }) - const parts = MessageV2.parts(msg.id) + const parts = yield* MessageV2.parts(msg.id) expect(value).toBe("continue") expect(yield* llm.calls).toBe(2) @@ -573,7 +583,7 @@ it.live("session.processor effect tests publish retry status updates", () => ({ dir, llm }) => Effect.gen(function* () { const { processors, session, provider } = yield* boot() - const bus = yield* Bus.Service + const events = yield* EventV2Bridge.Service yield* llm.error(503, { error: "boom" }) yield* llm.text("") @@ -583,9 +593,11 @@ it.live("session.processor effect tests publish retry status updates", () => const msg = yield* assistant(chat.id, parent.id, path.resolve(dir)) const mdl = yield* provider.getModel(ref.providerID, ref.modelID) const states: number[] = [] - const off = yield* bus.subscribeCallback(SessionStatus.Event.Status, (evt) => { - if (evt.properties.sessionID !== chat.id) return - if (evt.properties.status.type === "retry") states.push(evt.properties.status.attempt) + const off = yield* events.listen((evt) => { + if (evt.type !== SessionStatus.Event.Status.type) return Effect.void + const data = evt.data as typeof SessionStatus.Event.Status.data.Type + if (data.sessionID === chat.id && data.status.type === "retry") states.push(data.status.attempt) + return Effect.void }) const handle = yield* processors.create({ assistantMessage: msg, @@ -601,7 +613,7 @@ it.live("session.processor effect tests publish retry status updates", () => time: parent.time, agent: parent.agent, model: { providerID: ref.providerID, modelID: ref.modelID }, - } satisfies MessageV2.User, + } satisfies SessionLegacy.User, sessionID: chat.id, model: mdl, agent: agent(), @@ -610,7 +622,7 @@ it.live("session.processor effect tests publish retry status updates", () => tools: {}, }) - off() + yield* off expect(value).toBe("continue") expect(yield* llm.calls).toBe(2) @@ -646,7 +658,7 @@ it.live("session.processor effect tests compact on structured context overflow", time: parent.time, agent: parent.agent, model: { providerID: ref.providerID, modelID: ref.modelID }, - } satisfies MessageV2.User, + } satisfies SessionLegacy.User, sessionID: chat.id, model: mdl, agent: agent(), @@ -689,7 +701,7 @@ it.live("session.processor effect tests complete AI SDK tool calls when native f time: parent.time, agent: parent.agent, model: { providerID: ref.providerID, modelID: ref.modelID }, - } satisfies MessageV2.User, + } satisfies SessionLegacy.User, sessionID: chat.id, model: mdl, agent: agent(), @@ -708,8 +720,8 @@ it.live("session.processor effect tests complete AI SDK tool calls when native f }, }) - const parts = MessageV2.parts(msg.id) - const call = parts.find((part): part is MessageV2.ToolPart => part.type === "tool") + const parts = yield* MessageV2.parts(msg.id) + const call = parts.find((part): part is SessionLegacy.ToolPart => part.type === "tool") expect(value).toBe("continue") expect(yield* llm.calls).toBe(1) @@ -732,6 +744,7 @@ it.live("session.processor effect tests mark pending tools as aborted on cleanup provideTmpdirServer( ({ dir, llm }) => Effect.gen(function* () { + const database = yield* Database.Service const { processors, session, provider } = yield* boot() yield* llm.toolHang("bash", { cmd: "pwd" }) @@ -755,7 +768,7 @@ it.live("session.processor effect tests mark pending tools as aborted on cleanup time: parent.time, agent: parent.agent, model: { providerID: ref.providerID, modelID: ref.modelID }, - } satisfies MessageV2.User, + } satisfies SessionLegacy.User, sessionID: chat.id, model: mdl, agent: agent(), @@ -767,14 +780,17 @@ it.live("session.processor effect tests mark pending tools as aborted on cleanup yield* llm.wait(1) yield* waitFor( - Effect.sync(() => MessageV2.parts(msg.id).find((part): part is MessageV2.ToolPart => part.type === "tool")), + MessageV2.parts(msg.id).pipe( + Effect.map((parts) => parts.find((part): part is SessionLegacy.ToolPart => part.type === "tool")), + Effect.provideService(Database.Service, database), + ), "timed out waiting for tool part", ) yield* Fiber.interrupt(run) const exit = yield* Fiber.await(run) - const parts = MessageV2.parts(msg.id) - const call = parts.find((part): part is MessageV2.ToolPart => part.type === "tool") + const parts = yield* MessageV2.parts(msg.id) + const call = parts.find((part): part is SessionLegacy.ToolPart => part.type === "tool") expect(Exit.isFailure(exit)).toBe(true) if (Exit.isFailure(exit)) { @@ -798,7 +814,7 @@ it.live("session.processor effect tests record aborted errors and idle state", ( Effect.gen(function* () { const seen = defer() const { processors, session, provider } = yield* boot() - const bus = yield* Bus.Service + const events = yield* EventV2Bridge.Service const sts = yield* SessionStatus.Service yield* llm.hang @@ -808,11 +824,13 @@ it.live("session.processor effect tests record aborted errors and idle state", ( const msg = yield* assistant(chat.id, parent.id, path.resolve(dir)) const mdl = yield* provider.getModel(ref.providerID, ref.modelID) const errs: string[] = [] - const off = yield* bus.subscribeCallback(Session.Event.Error, (evt) => { - if (evt.properties.sessionID !== chat.id) return - if (!evt.properties.error) return - errs.push(evt.properties.error.name) + const off = yield* events.listen((evt) => { + if (evt.type !== Session.Event.Error.type) return Effect.void + const data = evt.data as typeof Session.Event.Error.data.Type + if (data.sessionID !== chat.id || !data.error) return Effect.void + errs.push(data.error.name) seen.resolve() + return Effect.void }) const handle = yield* processors.create({ assistantMessage: msg, @@ -829,7 +847,7 @@ it.live("session.processor effect tests record aborted errors and idle state", ( time: parent.time, agent: parent.agent, model: { providerID: ref.providerID, modelID: ref.modelID }, - } satisfies MessageV2.User, + } satisfies SessionLegacy.User, sessionID: chat.id, model: mdl, agent: agent(), @@ -846,7 +864,7 @@ it.live("session.processor effect tests record aborted errors and idle state", ( yield* Effect.promise(() => seen.promise) const stored = yield* MessageV2.get({ sessionID: chat.id, messageID: msg.id }) const state = yield* sts.get(chat.id) - off() + yield* off expect(Exit.isFailure(exit)).toBe(true) if (Exit.isFailure(exit)) { @@ -892,7 +910,7 @@ it.live("session.processor effect tests mark interruptions aborted without manua time: parent.time, agent: parent.agent, model: { providerID: ref.providerID, modelID: ref.modelID }, - } satisfies MessageV2.User, + } satisfies SessionLegacy.User, sessionID: chat.id, model: mdl, agent: agent(), diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 4c46474578..889d2a6d85 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -1,4 +1,8 @@ import { NodeFileSystem } from "@effect/platform-node" +import { SessionLegacy } from "@opencode-ai/core/session/legacy" +import { Database } from "@opencode-ai/core/database/database" +import { eq } from "drizzle-orm" +import { EventV2Bridge } from "@/event-v2-bridge" import { FetchHttpClient } from "effect/unstable/http" import { expect } from "bun:test" import { Cause, Deferred, Duration, Effect, Exit, Fiber, Layer } from "effect" @@ -7,7 +11,6 @@ import { fileURLToPath, pathToFileURL } from "url" import { NamedError } from "@opencode-ai/core/util/error" import { Agent as AgentSvc } from "../../src/agent/agent" import { BackgroundJob } from "@/background/job" -import { Bus } from "../../src/bus" import { Command } from "../../src/command" import { Config } from "@/config/config" import { LSP } from "@/lsp/lsp" @@ -18,11 +21,11 @@ import { Provider as ProviderSvc } from "@/provider/provider" import { Env } from "../../src/env" import { Git } from "../../src/git" import { Image } from "../../src/image/image" -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 { SessionMessageTable } from "@opencode-ai/core/session/sql" import { LLM } from "../../src/session/llm" import { MessageV2 } from "../../src/session/message-v2" import { AppFileSystem } from "@opencode-ai/core/filesystem" @@ -35,7 +38,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 { SessionV2 } from "@opencode-ai/core/session" import { Skill } from "../../src/skill" import { SystemPrompt } from "../../src/session/system" import { Shell } from "../../src/shell/shell" @@ -44,7 +47,6 @@ 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 { Reference } from "../../src/reference/reference" @@ -52,9 +54,8 @@ import { RepositoryCache } from "../../src/reference/repository-cache" import { TestInstance } from "../fixture/fixture" import { awaitWithTimeout, pollWithTimeout, testEffect } from "../lib/effect" import { reply, TestLLMServer } from "../lib/llm-server" -import { SyncEvent } from "@/sync" import { RuntimeFlags } from "@/effect/runtime-flags" -import { EventV2Bridge } from "@/event-v2-bridge" +import { ProviderV2 } from "@opencode-ai/core/provider" void Log.init({ print: false }) @@ -68,8 +69,8 @@ const summary = Layer.succeed( ) const ref = { - providerID: ProviderID.make("test"), - modelID: ModelID.make("test-model"), + providerID: ProviderV2.ID.make("test"), + modelID: ProviderV2.ModelID.make("test-model"), } function withSh(fx: () => Effect.Effect) { @@ -90,20 +91,20 @@ function withSh(fx: () => Effect.Effect) { ) } -function toolPart(parts: MessageV2.Part[]) { - return parts.find((part): part is MessageV2.ToolPart => part.type === "tool") +function toolPart(parts: SessionLegacy.Part[]) { + return parts.find((part): part is SessionLegacy.ToolPart => part.type === "tool") } -type CompletedToolPart = MessageV2.ToolPart & { state: MessageV2.ToolStateCompleted } -type ErrorToolPart = MessageV2.ToolPart & { state: MessageV2.ToolStateError } +type CompletedToolPart = SessionLegacy.ToolPart & { state: SessionLegacy.ToolStateCompleted } +type ErrorToolPart = SessionLegacy.ToolPart & { state: SessionLegacy.ToolStateError } -function completedTool(parts: MessageV2.Part[]) { +function completedTool(parts: SessionLegacy.Part[]) { const part = toolPart(parts) expect(part?.state.status).toBe("completed") return part?.state.status === "completed" ? (part as CompletedToolPart) : undefined } -function errorTool(parts: MessageV2.Part[]) { +function errorTool(parts: SessionLegacy.Part[]) { const part = toolPart(parts) expect(part?.state.status).toBe("error") return part?.state.status === "error" ? (part as ErrorToolPart) : undefined @@ -152,7 +153,7 @@ const lsp = Layer.succeed( }), ) -const status = SessionStatus.layer.pipe(Layer.provideMerge(Bus.layer)) +const status = SessionStatus.layer.pipe(Layer.provideMerge(EventV2Bridge.defaultLayer)) const run = SessionRunState.layer.pipe(Layer.provide(status)) const infra = Layer.mergeAll(NodeFileSystem.layer, CrossSpawnSpawner.defaultLayer) @@ -181,7 +182,7 @@ function makePrompt(input?: { processor?: "blocking" }) { AppFileSystem.defaultLayer, BackgroundJob.defaultLayer, status, - SyncEvent.defaultLayer, + Database.defaultLayer, EventV2Bridge.defaultLayer, ).pipe(Layer.provideMerge(infra)) const question = Question.layer.pipe(Layer.provideMerge(deps)) @@ -388,7 +389,7 @@ const user = Effect.fn("test.user")(function* (sessionID: SessionID, text: strin const seed = Effect.fn("test.seed")(function* (sessionID: SessionID, opts?: { finish?: string }) { const session = yield* Session.Service const msg = yield* user(sessionID, "hello") - const assistant: MessageV2.Assistant = { + const assistant: SessionLegacy.Assistant = { id: MessageID.ascending(), role: "assistant", parentID: msg.id, @@ -511,8 +512,8 @@ it.instance("loop calls LLM and returns assistant message", () => }), ) -noLLMServer.instance( - "prompt emits v2 prompted and synthetic events", +noLLMServer.instance.skip( + "prompt emits v2 prompted and synthetic events (v2 projector disabled)", () => Effect.gen(function* () { const prompt = yield* SessionPrompt.Service @@ -535,11 +536,10 @@ noLLMServer.instance( }) 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(), + Effect.provide(SessionV2.defaultLayer), ) + const { db } = yield* Database.Service + const row = yield* db.select().from(SessionMessageTable).where(eq(SessionMessageTable.session_id, chat.id)).get().pipe(Effect.orDie) expect(messages.find((message) => message.type === "user")).toMatchObject({ type: "user", text: "hello v2" }) expect(typeof row?.data.time.created).toBe("number") expect(messages).toEqual( @@ -753,8 +753,8 @@ it.instance("failed subtask preserves metadata on error tool state", () => expect(tool.state.metadata).toBeDefined() expect(tool.state.metadata?.sessionId).toBeDefined() expect(tool.state.metadata?.model).toEqual({ - providerID: ProviderID.make("test"), - modelID: ModelID.make("missing-model"), + providerID: ProviderV2.ID.make("test"), + modelID: ProviderV2.ModelID.make("missing-model"), }) }), ) @@ -777,7 +777,7 @@ it.instance( Effect.gen(function* () { const msgs = yield* MessageV2.filterCompactedEffect(chat.id) const taskMsg = msgs.find((item) => item.info.role === "assistant" && item.info.agent === "general") - const tool = taskMsg?.parts.find((part): part is MessageV2.ToolPart => part.type === "tool") + const tool = taskMsg?.parts.find((part): part is SessionLegacy.ToolPart => part.type === "tool") if (tool?.state.status === "running" && tool.state.metadata?.sessionId) return tool }), "timed out waiting for running subtask metadata", @@ -820,7 +820,7 @@ it.instance( const msgs = yield* MessageV2.filterCompactedEffect(chat.id) const assistant = msgs.findLast((item) => item.info.role === "assistant" && item.info.agent === "build") const tool = assistant?.parts.find( - (part): part is MessageV2.ToolPart => part.type === "tool" && part.tool === "task", + (part): part is SessionLegacy.ToolPart => part.type === "tool" && part.tool === "task", ) if (tool?.state.status === "running" && tool.state.metadata?.sessionId) return tool }), @@ -1364,24 +1364,26 @@ unixNoLLMServer( unixNoLLMServer( "shell commands can change directory after startup", () => - Effect.gen(function* () { - const { directory: dir } = yield* TestInstance - const { prompt, run, chat } = yield* boot() - const parent = path.dirname(dir) - const result = yield* prompt.shell({ - sessionID: chat.id, - agent: "build", - command: "cd .. && pwd", - }) + withSh(() => + Effect.gen(function* () { + const { directory: dir } = yield* TestInstance + const { prompt, run, chat } = yield* boot() + const parent = path.dirname(dir) + const result = yield* prompt.shell({ + sessionID: chat.id, + agent: "build", + command: "cd .. && pwd", + }) - expect(result.info.role).toBe("assistant") - const tool = completedTool(result.parts) - if (!tool) return + expect(result.info.role).toBe("assistant") + const tool = completedTool(result.parts) + if (!tool) return - expect(tool.state.output).toContain(parent) - expect(tool.state.metadata.output).toContain(parent) - yield* run.assertNotBusy(chat.id) - }), + expect(tool.state.output).toContain(parent) + expect(tool.state.metadata.output).toContain(parent) + yield* run.assertNotBusy(chat.id) + }), + ), { config: cfg }, ) @@ -1939,11 +1941,11 @@ noLLMServer.instance( "Use @docs and @docs/README.md and @docs/guide and @docs/missing.md and @docs/README.md and @build", ) const references = parts.filter( - (part): part is MessageV2.TextPartInput => + (part): part is SessionLegacy.TextPartInput => part.type === "text" && part.synthetic === true && part.text.startsWith("Referenced configured reference "), ) - const files = parts.filter((part): part is MessageV2.FilePartInput => part.type === "file") - const agents = parts.filter((part): part is MessageV2.AgentPartInput => part.type === "agent") + const files = parts.filter((part): part is SessionLegacy.FilePartInput => part.type === "file") + const agents = parts.filter((part): part is SessionLegacy.AgentPartInput => part.type === "agent") const bare = references.find((part) => part.text.includes("@docs.")) const missing = references.find((part) => part.text.includes("@docs/missing.md")) const guide = files.find((part) => part.filename === "docs/guide") @@ -1996,7 +1998,7 @@ noLLMServer.instance( const stored = yield* MessageV2.get({ sessionID: session.id, messageID: message.info.id }) const synthetic = stored.parts.filter( - (part): part is MessageV2.TextPart => part.type === "text" && part.synthetic === true, + (part): part is SessionLegacy.TextPart => part.type === "text" && part.synthetic === true, ) const reference = synthetic.find((part) => part.text.startsWith("Referenced configured reference @docs.")) @@ -2051,7 +2053,7 @@ noLLMServer.instance( const stored = yield* MessageV2.get({ sessionID: session.id, messageID: message.info.id }) const synthetic = stored.parts.filter( - (part): part is MessageV2.TextPart => part.type === "text" && part.synthetic === true, + (part): part is SessionLegacy.TextPart => part.type === "text" && part.synthetic === true, ) const reference = synthetic.find((part) => part.text.startsWith("Referenced configured reference @docs/README.md."), @@ -2198,7 +2200,7 @@ noLLMServer.instance( const other = yield* prompt.prompt({ sessionID: session.id, agent: "build", - model: { providerID: ProviderID.make("opencode"), modelID: ModelID.make("kimi-k2.5-free") }, + model: { providerID: ProviderV2.ID.make("opencode"), modelID: ProviderV2.ModelID.make("kimi-k2.5-free") }, noReply: true, parts: [{ type: "text", text: "hello" }], }) @@ -2213,8 +2215,8 @@ noLLMServer.instance( }) if (match.info.role !== "user") throw new Error("expected user message") expect(match.info.model).toEqual({ - providerID: ProviderID.make("test"), - modelID: ModelID.make("test-model"), + providerID: ProviderV2.ID.make("test"), + modelID: ProviderV2.ModelID.make("test-model"), variant: "xhigh", }) expect(match.info.model.variant).toBe("xhigh") diff --git a/packages/opencode/test/session/retry.test.ts b/packages/opencode/test/session/retry.test.ts index e1cbec0364..32caaa1138 100644 --- a/packages/opencode/test/session/retry.test.ts +++ b/packages/opencode/test/session/retry.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test" +import { SessionLegacy } from "@opencode-ai/core/session/legacy" import type { NamedError } from "@opencode-ai/core/util/error" import { APICallError } from "ai" import { setTimeout as sleep } from "node:timers/promises" @@ -6,20 +7,19 @@ import { Effect, Layer, Schedule, Schema } from "effect" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { SessionRetry } from "../../src/session/retry" import { MessageV2 } from "../../src/session/message-v2" -import { ProviderID } from "../../src/provider/schema" import { ProviderError } from "../../src/provider/error" import { SessionID } from "../../src/session/schema" import { SessionStatus } from "../../src/session/status" -import { provideTmpdirInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" +import { ProviderV2 } from "@opencode-ai/core/provider" -const providerID = ProviderID.make("test") +const providerID = ProviderV2.ID.make("test") const retryProvider = "test" const it = testEffect(Layer.mergeAll(SessionStatus.defaultLayer, CrossSpawnSpawner.defaultLayer)) -function apiError(headers?: Record): MessageV2.APIError { - return Schema.decodeUnknownSync(MessageV2.APIError.Schema)( - new MessageV2.APIError({ +function apiError(headers?: Record): SessionLegacy.APIError { + return Schema.decodeUnknownSync(SessionLegacy.APIError.Schema)( + new SessionLegacy.APIError({ message: "boom", isRetryable: true, responseHeaders: headers, @@ -85,9 +85,8 @@ describe("session.retry.delay", () => { expect(SessionRetry.delay(1, error)).toBe(SessionRetry.RETRY_MAX_DELAY) }) - it.live("policy updates retry status and increments attempts", () => - provideTmpdirInstance(() => - Effect.gen(function* () { + it.instance("policy updates retry status and increments attempts", () => + Effect.gen(function* () { const sessionID = SessionID.make("session-retry-test") const error = apiError({ "retry-after-ms": "0" }) const status = yield* SessionStatus.Service @@ -95,7 +94,7 @@ describe("session.retry.delay", () => { const step = yield* Schedule.toStepWithMetadata( SessionRetry.policy({ provider: "test", - parse: Schema.decodeUnknownSync(MessageV2.APIError.Schema), + parse: Schema.decodeUnknownSync(SessionLegacy.APIError.Schema), set: (info) => status.set(sessionID, { type: "retry", @@ -113,8 +112,7 @@ describe("session.retry.delay", () => { attempt: 2, message: "boom", }) - }), - ), + }), ) }) @@ -166,7 +164,7 @@ describe("session.retry.retryable", () => { test("retries transport timeout errors", () => { const request = MessageV2.fromError(new ProviderError.HeaderTimeoutError(10000), { providerID }) - expect(MessageV2.APIError.isInstance(request)).toBe(true) + expect(SessionLegacy.APIError.isInstance(request)).toBe(true) expect(SessionRetry.retryable(request, retryProvider)).toEqual({ message: "Provider response headers timed out after 10000ms", }) @@ -177,14 +175,14 @@ describe("session.retry.retryable", () => { new ProviderError.ResponseStreamError("WebSocket closed before response.completed (code 1006: Connection ended)"), { providerID }, ) - expect(MessageV2.APIError.isInstance(request)).toBe(true) + expect(SessionLegacy.APIError.isInstance(request)).toBe(true) expect(SessionRetry.retryable(request, retryProvider)).toEqual({ message: "WebSocket closed before response.completed (code 1006: Connection ended)", }) }) test("does not retry context overflow errors", () => { - const error = new MessageV2.ContextOverflowError({ + const error = new SessionLegacy.ContextOverflowError({ message: "Input exceeds context window of this model", responseBody: '{"error":{"code":"context_length_exceeded"}}', }).toObject() @@ -193,8 +191,8 @@ describe("session.retry.retryable", () => { }) test("retries 500 errors even when isRetryable is false", () => { - const error = Schema.decodeUnknownSync(MessageV2.APIError.Schema)( - new MessageV2.APIError({ + const error = Schema.decodeUnknownSync(SessionLegacy.APIError.Schema)( + new SessionLegacy.APIError({ message: "Internal server error", isRetryable: false, statusCode: 500, @@ -206,8 +204,8 @@ describe("session.retry.retryable", () => { }) test("retries 502 bad gateway errors", () => { - const error = Schema.decodeUnknownSync(MessageV2.APIError.Schema)( - new MessageV2.APIError({ + const error = Schema.decodeUnknownSync(SessionLegacy.APIError.Schema)( + new SessionLegacy.APIError({ message: "Bad gateway", isRetryable: false, statusCode: 502, @@ -218,8 +216,8 @@ describe("session.retry.retryable", () => { }) test("retries 503 service unavailable errors", () => { - const error = Schema.decodeUnknownSync(MessageV2.APIError.Schema)( - new MessageV2.APIError({ + const error = Schema.decodeUnknownSync(SessionLegacy.APIError.Schema)( + new SessionLegacy.APIError({ message: "Service unavailable", isRetryable: false, statusCode: 503, @@ -230,8 +228,8 @@ describe("session.retry.retryable", () => { }) test("does not retry 4xx errors when isRetryable is false", () => { - const error = Schema.decodeUnknownSync(MessageV2.APIError.Schema)( - new MessageV2.APIError({ + const error = Schema.decodeUnknownSync(SessionLegacy.APIError.Schema)( + new SessionLegacy.APIError({ message: "Bad request", isRetryable: false, statusCode: 400, @@ -242,8 +240,8 @@ describe("session.retry.retryable", () => { }) test("retries ZlibError decompression failures", () => { - const error = Schema.decodeUnknownSync(MessageV2.APIError.Schema)( - new MessageV2.APIError({ + const error = Schema.decodeUnknownSync(SessionLegacy.APIError.Schema)( + new SessionLegacy.APIError({ message: "Response decompression failed", isRetryable: true, metadata: { code: "ZlibError" }, @@ -256,8 +254,8 @@ describe("session.retry.retryable", () => { }) test("maps free limits to Go upsell action", () => { - const error = Schema.decodeUnknownSync(MessageV2.APIError.Schema)( - new MessageV2.APIError({ + const error = Schema.decodeUnknownSync(SessionLegacy.APIError.Schema)( + new SessionLegacy.APIError({ message: "Free usage exceeded", isRetryable: true, statusCode: 429, @@ -282,8 +280,8 @@ describe("session.retry.retryable", () => { }) test("maps Go subscription limits to workspace PAYG upsell", () => { - const error = Schema.decodeUnknownSync(MessageV2.APIError.Schema)( - new MessageV2.APIError({ + const error = Schema.decodeUnknownSync(SessionLegacy.APIError.Schema)( + new SessionLegacy.APIError({ message: "Subscription quota exceeded. You can continue using free models.", isRetryable: true, statusCode: 429, @@ -320,8 +318,8 @@ describe("session.retry.retryable", () => { }) test("maps Go subscription limits without limit metadata", () => { - const error = Schema.decodeUnknownSync(MessageV2.APIError.Schema)( - new MessageV2.APIError({ + const error = Schema.decodeUnknownSync(SessionLegacy.APIError.Schema)( + new SessionLegacy.APIError({ message: "Subscription quota exceeded. You can continue using free models.", isRetryable: true, statusCode: 429, @@ -375,8 +373,8 @@ describe("session.message-v2.fromError", () => { const result = MessageV2.fromError(error, { providerID }) - expect(MessageV2.APIError.isInstance(result)).toBe(true) - if (!MessageV2.APIError.isInstance(result)) throw new Error("expected APIError") + expect(SessionLegacy.APIError.isInstance(result)).toBe(true) + if (!SessionLegacy.APIError.isInstance(result)) throw new Error("expected APIError") expect(result.data.isRetryable).toBe(true) expect(result.data.message).toBe("Connection reset by server") expect(result.data.metadata?.code).toBe("ECONNRESET") @@ -386,8 +384,8 @@ describe("session.message-v2.fromError", () => { ) test("ECONNRESET socket error is retryable", () => { - const error = Schema.decodeUnknownSync(MessageV2.APIError.Schema)( - new MessageV2.APIError({ + const error = Schema.decodeUnknownSync(SessionLegacy.APIError.Schema)( + new SessionLegacy.APIError({ message: "Connection reset by server", isRetryable: true, metadata: { code: "ECONNRESET", message: "The socket connection was closed unexpectedly" }, @@ -409,8 +407,8 @@ describe("session.message-v2.fromError", () => { responseBody: '{"error":"boom"}', isRetryable: false, }) - const result = MessageV2.fromError(error, { providerID: ProviderID.make("openai") }) - if (!MessageV2.APIError.isInstance(result)) throw new Error("expected APIError") + const result = MessageV2.fromError(error, { providerID: ProviderV2.ID.make("openai") }) + if (!SessionLegacy.APIError.isInstance(result)) throw new Error("expected APIError") expect(result.data.isRetryable).toBe(true) }) @@ -428,11 +426,11 @@ describe("session.message-v2.fromError", () => { }, }), }, - { providerID: ProviderID.make("openai") }, + { providerID: ProviderV2.ID.make("openai") }, ) - expect(MessageV2.APIError.isInstance(result)).toBe(true) - if (!MessageV2.APIError.isInstance(result)) throw new Error("expected APIError") + expect(SessionLegacy.APIError.isInstance(result)).toBe(true) + if (!SessionLegacy.APIError.isInstance(result)) throw new Error("expected APIError") expect(result.data.isRetryable).toBe(true) expect(SessionRetry.retryable(result, retryProvider)).toEqual({ message: "An error occurred while processing your request.", diff --git a/packages/opencode/test/session/revert-compact.test.ts b/packages/opencode/test/session/revert-compact.test.ts index c70c17d451..0df7910963 100644 --- a/packages/opencode/test/session/revert-compact.test.ts +++ b/packages/opencode/test/session/revert-compact.test.ts @@ -1,9 +1,10 @@ import { describe, expect } from "bun:test" +import { SessionLegacy } from "@opencode-ai/core/session/legacy" import fs from "fs/promises" import path from "path" import { Effect, Layer } from "effect" import { Session } from "@/session/session" -import { ModelID, ProviderID } from "../../src/provider/schema" + import { SessionRevert } from "../../src/session/revert" import { MessageV2 } from "../../src/session/message-v2" import { Snapshot } from "../../src/snapshot" @@ -12,6 +13,7 @@ import { MessageID, PartID, SessionID } from "../../src/session/schema" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { provideTmpdirInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" +import { ProviderV2 } from "@opencode-ai/core/provider" void Log.init({ print: false }) @@ -31,7 +33,7 @@ const user = Effect.fn("test.user")(function* (sessionID: SessionID, agent = "de role: "user" as const, sessionID, agent, - model: { providerID: ProviderID.make("openai"), modelID: ModelID.make("gpt-4") }, + model: { providerID: ProviderV2.ID.make("openai"), modelID: ProviderV2.ModelID.make("gpt-4") }, time: { created: Date.now() }, }) }) @@ -47,8 +49,8 @@ const assistant = Effect.fn("test.assistant")(function* (sessionID: SessionID, p path: { cwd: dir, root: dir }, cost: 0, tokens: { output: 0, input: 0, reasoning: 0, cache: { read: 0, write: 0 } }, - modelID: ModelID.make("gpt-4"), - providerID: ProviderID.make("openai"), + modelID: ProviderV2.ModelID.make("gpt-4"), + providerID: ProviderV2.ID.make("openai"), parentID, time: { created: Date.now() }, finish: "end_turn", @@ -114,8 +116,8 @@ describe("revert + compact workflow", () => { sessionID, agent: "default", model: { - providerID: ProviderID.make("openai"), - modelID: ModelID.make("gpt-4"), + providerID: ProviderV2.ID.make("openai"), + modelID: ProviderV2.ModelID.make("gpt-4"), }, time: { created: Date.now(), @@ -130,7 +132,7 @@ describe("revert + compact workflow", () => { text: "Hello, please help me", }) - const assistantMsg1: MessageV2.Assistant = { + const assistantMsg1: SessionLegacy.Assistant = { id: MessageID.ascending(), role: "assistant", sessionID, @@ -147,8 +149,8 @@ describe("revert + compact workflow", () => { reasoning: 0, cache: { read: 0, write: 0 }, }, - modelID: ModelID.make("gpt-4"), - providerID: ProviderID.make("openai"), + modelID: ProviderV2.ModelID.make("gpt-4"), + providerID: ProviderV2.ID.make("openai"), parentID: userMsg1.id, time: { created: Date.now(), @@ -171,8 +173,8 @@ describe("revert + compact workflow", () => { sessionID, agent: "default", model: { - providerID: ProviderID.make("openai"), - modelID: ModelID.make("gpt-4"), + providerID: ProviderV2.ID.make("openai"), + modelID: ProviderV2.ModelID.make("gpt-4"), }, time: { created: Date.now(), @@ -187,7 +189,7 @@ describe("revert + compact workflow", () => { text: "What's the capital of France?", }) - const assistantMsg2: MessageV2.Assistant = { + const assistantMsg2: SessionLegacy.Assistant = { id: MessageID.ascending(), role: "assistant", sessionID, @@ -204,8 +206,8 @@ describe("revert + compact workflow", () => { reasoning: 0, cache: { read: 0, write: 0 }, }, - modelID: ModelID.make("gpt-4"), - providerID: ProviderID.make("openai"), + modelID: ProviderV2.ModelID.make("gpt-4"), + providerID: ProviderV2.ID.make("openai"), parentID: userMsg2.id, time: { created: Date.now(), @@ -276,8 +278,8 @@ describe("revert + compact workflow", () => { sessionID, agent: "default", model: { - providerID: ProviderID.make("openai"), - modelID: ModelID.make("gpt-4"), + providerID: ProviderV2.ID.make("openai"), + modelID: ProviderV2.ModelID.make("gpt-4"), }, time: { created: Date.now(), @@ -292,7 +294,7 @@ describe("revert + compact workflow", () => { text: "Hello", }) - const assistantMsg: MessageV2.Assistant = { + const assistantMsg: SessionLegacy.Assistant = { id: MessageID.ascending(), role: "assistant", sessionID, @@ -309,8 +311,8 @@ describe("revert + compact workflow", () => { reasoning: 0, cache: { read: 0, write: 0 }, }, - modelID: ModelID.make("gpt-4"), - providerID: ProviderID.make("openai"), + modelID: ProviderV2.ModelID.make("gpt-4"), + providerID: ProviderV2.ID.make("openai"), parentID: userMsg.id, time: { created: Date.now(), diff --git a/packages/opencode/test/session/schema-decoding.test.ts b/packages/opencode/test/session/schema-decoding.test.ts index 4fcb323fe7..1323c2aba6 100644 --- a/packages/opencode/test/session/schema-decoding.test.ts +++ b/packages/opencode/test/session/schema-decoding.test.ts @@ -8,8 +8,8 @@ import { SessionStatus } from "../../src/session/status" import { SessionSummary } from "../../src/session/summary" import { Todo } from "../../src/session/todo" import { SessionID, MessageID, PartID } from "../../src/session/schema" -import { ProjectID } from "../../src/project/schema" -import { WorkspaceID } from "../../src/control-plane/schema" +import { ProjectV2 } from "@opencode-ai/core/project" +import { WorkspaceV2 } from "@opencode-ai/core/workspace" // Covers the session-domain Effect Schema migration. For each migrated // schema we assert: @@ -22,8 +22,8 @@ const sessionID = Schema.decodeUnknownSync(SessionID)("ses_01J5Y5H0AH4Q4NXJ6P4C3 const sessionIDChild = Schema.decodeUnknownSync(SessionID)("ses_01J5Y5H0AH4Q4NXJ6P4C3P5V2L") const messageID = Schema.decodeUnknownSync(MessageID)("msg_01J5Y5H0AH4Q4NXJ6P4C3P5V2M") const partID = Schema.decodeUnknownSync(PartID)("prt_01J5Y5H0AH4Q4NXJ6P4C3P5V2N") -const projectID = ProjectID.make("proj-alpha") -const workspaceID = Schema.decodeUnknownSync(WorkspaceID)("wrk-primary") +const projectID = ProjectV2.ID.make("proj-alpha") +const workspaceID = Schema.decodeUnknownSync(WorkspaceV2.ID)("wrk-primary") function decodeUnknown(schema: S) { const decode = Schema.decodeUnknownSync(schema as any) diff --git a/packages/opencode/test/session/session-schema.test.ts b/packages/opencode/test/session/session-schema.test.ts index 906414fdbe..92249c4a09 100644 --- a/packages/opencode/test/session/session-schema.test.ts +++ b/packages/opencode/test/session/session-schema.test.ts @@ -1,13 +1,13 @@ import { describe, expect, test } from "bun:test" import { Schema } from "effect" -import { ProjectID } from "../../src/project/schema" +import { ProjectV2 } from "@opencode-ai/core/project" import { MessageID, SessionID } from "../../src/session/schema" import { Session } from "../../src/session/session" const info = { id: SessionID.descending(), slug: "test-session", - projectID: ProjectID.global, + projectID: ProjectV2.ID.global, workspaceID: undefined, directory: "/tmp/opencode", parentID: undefined, @@ -43,7 +43,7 @@ describe("Session schema", () => { const encoded = Schema.encodeUnknownSync(Session.GlobalInfo)({ ...info, project: { - id: ProjectID.global, + id: ProjectV2.ID.global, name: undefined, worktree: "/tmp/opencode", }, diff --git a/packages/opencode/test/session/session.test.ts b/packages/opencode/test/session/session.test.ts index 2b958cb682..2883a1ab29 100644 --- a/packages/opencode/test/session/session.test.ts +++ b/packages/opencode/test/session/session.test.ts @@ -1,31 +1,34 @@ import { describe, expect } from "bun:test" +import { SessionLegacy } from "@opencode-ai/core/session/legacy" +import { Database } from "@opencode-ai/core/database/database" +import { SessionProjector } from "@opencode-ai/core/session/projector" import { Deferred, Effect, Exit, Layer } from "effect" import { Session as SessionNs } from "@/session/session" -import { GlobalBus, type GlobalEvent } from "../../src/bus/global" import * as Log from "@opencode-ai/core/util/log" import { MessageV2 } from "../../src/session/message-v2" import { MessageID, PartID, type SessionID } from "../../src/session/schema" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" -import { provideInstance, tmpdirScoped } from "../fixture/fixture" +import { provideInstance, testInstanceStoreLayer, tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" -import { Bus } from "@/bus" import { Storage } from "@/storage/storage" -import { SyncEvent } from "@/sync" import { RuntimeFlags } from "@/effect/runtime-flags" import { BackgroundJob } from "@/background/job" +import { EventV2Bridge } from "@/event-v2-bridge" void Log.init({ print: false }) const it = testEffect( Layer.mergeAll( SessionNs.layer.pipe( - Layer.provide(Bus.layer), Layer.provide(Storage.defaultLayer), - Layer.provide(SyncEvent.defaultLayer), + Layer.provide(Database.defaultLayer), + Layer.provideMerge(EventV2Bridge.defaultLayer), + Layer.provide(SessionProjector.defaultLayer), Layer.provide(RuntimeFlags.layer({ experimentalWorkspaces: false })), Layer.provide(BackgroundJob.defaultLayer), ), CrossSpawnSpawner.defaultLayer, + testInstanceStoreLayer, ), ) @@ -37,24 +40,19 @@ const awaitDeferred = (deferred: Deferred.Deferred, message: string) => const remove = (id: SessionID) => SessionNs.use.remove(id) -const subscribeGlobal = (type: string, callback: (event: NonNullable) => void) => { - const listener = (event: GlobalEvent) => { - if (event.payload?.type === type) callback(event.payload) - } - GlobalBus.on("event", listener) - return () => GlobalBus.off("event", listener) -} - describe("session.created event", () => { it.instance("should emit session.created event when session is created", () => Effect.gen(function* () { const session = yield* SessionNs.Service + const events = yield* EventV2Bridge.Service const received = yield* Deferred.make() - const unsub = subscribeGlobal(SessionNs.Event.Created.type, (event) => { - Deferred.doneUnsafe(received, Effect.succeed(event.properties.info as SessionNs.Info)) + const unsub = yield* events.listen((event) => { + if (event.type === SessionNs.Event.Created.type) + Deferred.doneUnsafe(received, Effect.succeed((event.data as typeof SessionNs.Event.Created.data.Type).info as SessionNs.Info)) + return Effect.void }) - yield* Effect.addFinalizer(() => Effect.sync(unsub)) + yield* Effect.addFinalizer(() => unsub) const info = yield* session.create({}) const receivedInfo = yield* awaitDeferred(received, "timed out waiting for session.created") @@ -72,6 +70,7 @@ describe("session.created event", () => { it.instance("session.created event should be emitted before session.updated", () => Effect.gen(function* () { const session = yield* SessionNs.Service + const source = yield* EventV2Bridge.Service const events: string[] = [] const received = yield* Deferred.make() const push = (event: string) => { @@ -81,17 +80,15 @@ describe("session.created event", () => { } } - const unsubCreated = subscribeGlobal(SessionNs.Event.Created.type, () => { - push("created") + const unsubscribe = yield* source.listen((event) => { + if (event.type === SessionNs.Event.Created.type) push("created") + if (event.type === SessionNs.Event.Updated.type) push("updated") + return Effect.void }) - yield* Effect.addFinalizer(() => Effect.sync(unsubCreated)) - - const unsubUpdated = subscribeGlobal(SessionNs.Event.Updated.type, () => { - push("updated") - }) - yield* Effect.addFinalizer(() => Effect.sync(unsubUpdated)) + yield* Effect.addFinalizer(() => unsubscribe) const info = yield* session.create({}) + yield* session.setTitle({ sessionID: info.id, title: "updated" }) const receivedEvents = yield* awaitDeferred(received, "timed out waiting for session created/updated events") expect(receivedEvents).toContain("created") @@ -103,12 +100,13 @@ describe("session.created event", () => { ) }) -describe("step-finish token propagation via Bus event", () => { +describe("step-finish token propagation via event", () => { it.instance( "non-zero tokens propagate through PartUpdated event", () => Effect.gen(function* () { const session = yield* SessionNs.Service + const events = yield* EventV2Bridge.Service const info = yield* session.create({}) const messageID = MessageID.ascending() @@ -121,16 +119,18 @@ describe("step-finish token propagation via Bus event", () => { model: { providerID: "test", modelID: "test" }, tools: {}, mode: "", - } as unknown as MessageV2.Info) + } as unknown as SessionLegacy.Info) - // Bus subscribers receive readonly Schema.Type payloads; `MessageV2.Part` + // Event subscribers receive readonly Schema.Type payloads; `SessionLegacy.Part` // is the mutable domain type. Cast bridges the two — safe because the // test only reads the value afterwards. - const received = yield* Deferred.make() - const unsub = subscribeGlobal(MessageV2.Event.PartUpdated.type, (event) => { - Deferred.doneUnsafe(received, Effect.succeed(event.properties.part as MessageV2.Part)) + const received = yield* Deferred.make() + const unsub = yield* events.listen((event) => { + if (event.type === MessageV2.Event.PartUpdated.type) + Deferred.doneUnsafe(received, Effect.succeed((event.data as typeof MessageV2.Event.PartUpdated.data.Type).part as SessionLegacy.Part)) + return Effect.void }) - yield* Effect.addFinalizer(() => Effect.sync(unsub)) + yield* Effect.addFinalizer(() => unsub) const tokens = { total: 1500, @@ -154,7 +154,7 @@ describe("step-finish token propagation via Bus event", () => { const receivedPart = yield* awaitDeferred(received, "timed out waiting for message.part.updated") expect(receivedPart.type).toBe("step-finish") - const finish = receivedPart as MessageV2.StepFinishPart + const finish = receivedPart as SessionLegacy.StepFinishPart expect(finish.tokens.input).toBe(500) expect(finish.tokens.output).toBe(800) expect(finish.tokens.reasoning).toBe(200) diff --git a/packages/opencode/test/session/snapshot-tool-race.test.ts b/packages/opencode/test/session/snapshot-tool-race.test.ts index 89ed11613e..b5fed974a0 100644 --- a/packages/opencode/test/session/snapshot-tool-race.test.ts +++ b/packages/opencode/test/session/snapshot-tool-race.test.ts @@ -22,6 +22,7 @@ import { SessionPrompt } from "../../src/session/prompt" import { SessionRevert } from "../../src/session/revert" import { SessionSummary } from "../../src/session/summary" import { MessageV2 } from "../../src/session/message-v2" +import { SessionLegacy } from "@opencode-ai/core/session/legacy" import * as Log from "@opencode-ai/core/util/log" import { provideTmpdirServer } from "../fixture/fixture" import { testEffect } from "../lib/effect" @@ -29,10 +30,11 @@ import { TestLLMServer } from "../lib/llm-server" // Same layer setup as prompt-effect.test.ts import { NodeFileSystem } from "@effect/platform-node" +import { Database } from "@opencode-ai/core/database/database" +import { EventV2Bridge } from "@/event-v2-bridge" import { Agent as AgentSvc } from "../../src/agent/agent" import { BackgroundJob } from "@/background/job" import { Git } from "../../src/git" -import { Bus } from "../../src/bus" import { Command } from "../../src/command" import { Config } from "@/config/config" import { LSP } from "@/lsp/lsp" @@ -60,9 +62,7 @@ import { Ripgrep } from "../../src/file/ripgrep" import { Format } from "../../src/format" import { Reference } from "../../src/reference/reference" import { RepositoryCache } from "../../src/reference/repository-cache" -import { SyncEvent } from "@/sync" import { RuntimeFlags } from "@/effect/runtime-flags" -import { EventV2Bridge } from "@/event-v2-bridge" void Log.init({ print: false }) @@ -109,7 +109,7 @@ const lsp = Layer.succeed( }), ) -const status = SessionStatus.layer.pipe(Layer.provideMerge(Bus.layer)) +const status = SessionStatus.layer.pipe(Layer.provideMerge(EventV2Bridge.defaultLayer)) const run = SessionRunState.layer.pipe(Layer.provide(status)) const infra = Layer.mergeAll(NodeFileSystem.layer, CrossSpawnSpawner.defaultLayer) @@ -130,7 +130,7 @@ function makeHttp() { AppFileSystem.defaultLayer, BackgroundJob.defaultLayer, status, - SyncEvent.defaultLayer, + Database.defaultLayer, EventV2Bridge.defaultLayer, ).pipe(Layer.provideMerge(infra)) const question = Question.layer.pipe(Layer.provideMerge(deps)) @@ -259,7 +259,7 @@ it.live("tool execution produces non-empty session diff (snapshot race)", () => const allMsgs = yield* MessageV2.filterCompactedEffect(session.id) const tool = allMsgs .flatMap((m) => m.parts) - .find((p): p is MessageV2.ToolPart => p.type === "tool" && p.tool === "bash") + .find((p): p is SessionLegacy.ToolPart => p.type === "tool" && p.tool === "bash") expect(tool?.state.status).toBe("completed") // Poll for diff — summarize() is fire-and-forget diff --git a/packages/opencode/test/session/structured-output-integration.test.ts b/packages/opencode/test/session/structured-output-integration.test.ts index 125c63c0f9..f2d28864be 100644 --- a/packages/opencode/test/session/structured-output-integration.test.ts +++ b/packages/opencode/test/session/structured-output-integration.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test" +import { SessionLegacy } from "@opencode-ai/core/session/legacy" import { Effect, Layer } from "effect" import { Session } from "@/session/session" import { SessionPrompt } from "../../src/session/prompt" @@ -218,7 +219,7 @@ describe("StructuredOutput Integration", () => { ) test("unit test: StructuredOutputError is properly structured", () => { - const error = new MessageV2.StructuredOutputError({ + const error = new SessionLegacy.StructuredOutputError({ message: "Failed to produce valid structured output after 3 attempts", retries: 3, }) diff --git a/packages/opencode/test/session/structured-output.test.ts b/packages/opencode/test/session/structured-output.test.ts index 806c574834..14bc876c89 100644 --- a/packages/opencode/test/session/structured-output.test.ts +++ b/packages/opencode/test/session/structured-output.test.ts @@ -1,12 +1,13 @@ import { describe, expect, test } from "bun:test" +import { SessionLegacy } from "@opencode-ai/core/session/legacy" import { Exit, Schema } from "effect" import { MessageV2 } from "../../src/session/message-v2" import { SessionPrompt } from "../../src/session/prompt" import { SessionID, MessageID } from "../../src/session/schema" -const decodeFormat = Schema.decodeUnknownExit(MessageV2.Format) -const decodeUser = Schema.decodeUnknownExit(MessageV2.User) -const decodeAssistant = Schema.decodeUnknownExit(MessageV2.Assistant) +const decodeFormat = Schema.decodeUnknownExit(SessionLegacy.Format) +const decodeUser = Schema.decodeUnknownExit(SessionLegacy.User) +const decodeAssistant = Schema.decodeUnknownExit(SessionLegacy.Assistant) describe("structured-output.OutputFormat", () => { test("parses text format", () => { @@ -65,7 +66,7 @@ describe("structured-output.OutputFormat", () => { describe("structured-output.StructuredOutputError", () => { test("creates error with message and retries", () => { - const error = new MessageV2.StructuredOutputError({ + const error = new SessionLegacy.StructuredOutputError({ message: "Failed to validate", retries: 3, }) @@ -76,7 +77,7 @@ describe("structured-output.StructuredOutputError", () => { }) test("converts to object correctly", () => { - const error = new MessageV2.StructuredOutputError({ + const error = new SessionLegacy.StructuredOutputError({ message: "Test error", retries: 2, }) @@ -88,13 +89,13 @@ describe("structured-output.StructuredOutputError", () => { }) test("isInstance correctly identifies error", () => { - const error = new MessageV2.StructuredOutputError({ + const error = new SessionLegacy.StructuredOutputError({ message: "Test", retries: 1, }) - expect(MessageV2.StructuredOutputError.isInstance(error)).toBe(true) - expect(MessageV2.StructuredOutputError.isInstance({ name: "other" })).toBe(false) + expect(SessionLegacy.StructuredOutputError.isInstance(error)).toBe(true) + expect(SessionLegacy.StructuredOutputError.isInstance({ name: "other" })).toBe(false) }) }) diff --git a/packages/opencode/test/share/share-next.test.ts b/packages/opencode/test/share/share-next.test.ts index 1daa4c2c8e..a3662f60c8 100644 --- a/packages/opencode/test/share/share-next.test.ts +++ b/packages/opencode/test/share/share-next.test.ts @@ -7,14 +7,14 @@ import { AccessToken, AccountID, OrgID, RefreshToken } from "../../src/account/s import { Account } from "../../src/account/account" import { AccountRepo } from "../../src/account/repo" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" -import { Bus } from "../../src/bus" +import { EventV2Bridge } from "../../src/event-v2-bridge" import { Config } from "@/config/config" import { Provider } from "@/provider/provider" import { Session } from "@/session/session" import type { SessionID } from "../../src/session/schema" import { ShareNext } from "@/share/share-next" -import { SessionShareTable } from "../../src/share/share.sql" -import { Database } from "@/storage/db" +import { SessionShareTable } from "@opencode-ai/core/share/sql" +import { Database } from "@opencode-ai/core/database/database" import { eq } from "drizzle-orm" import { provideTmpdirInstance } from "../fixture/fixture" import { resetDatabase } from "../fixture/db" @@ -22,7 +22,8 @@ import { testEffect } from "../lib/effect" const env = Layer.mergeAll( Session.defaultLayer, - AccountRepo.layer, + AccountRepo.defaultLayer, + Database.defaultLayer, NodeFileSystem.layer, CrossSpawnSpawner.defaultLayer, ) @@ -42,9 +43,10 @@ const none = HttpClient.make(() => Effect.die("unexpected http call")) function live(client: HttpClient.HttpClient) { const http = Layer.succeed(HttpClient.HttpClient, client) return ShareNext.layer.pipe( - Layer.provide(Bus.layer), - Layer.provide(Account.layer.pipe(Layer.provide(AccountRepo.layer), Layer.provide(http))), + Layer.provide(EventV2Bridge.defaultLayer), + Layer.provide(Account.layer.pipe(Layer.provide(AccountRepo.defaultLayer), Layer.provide(http))), Layer.provide(Config.defaultLayer), + Layer.provide(Database.defaultLayer), Layer.provide(http), Layer.provide(Provider.defaultLayer), Layer.provide(Session.defaultLayer), @@ -54,15 +56,16 @@ function live(client: HttpClient.HttpClient) { function wired(client: HttpClient.HttpClient) { const http = Layer.succeed(HttpClient.HttpClient, client) return Layer.mergeAll( - Bus.layer, + EventV2Bridge.defaultLayer, ShareNext.layer, Session.defaultLayer, - AccountRepo.layer, + AccountRepo.defaultLayer, + Database.defaultLayer, NodeFileSystem.layer, CrossSpawnSpawner.defaultLayer, ).pipe( - Layer.provide(Bus.layer), - Layer.provide(Account.layer.pipe(Layer.provide(AccountRepo.layer), Layer.provide(http))), + Layer.provide(EventV2Bridge.defaultLayer), + Layer.provide(Account.layer.pipe(Layer.provide(AccountRepo.defaultLayer), Layer.provide(http))), Layer.provide(Config.defaultLayer), Layer.provide(http), Layer.provide(Provider.defaultLayer), @@ -70,7 +73,10 @@ function wired(client: HttpClient.HttpClient) { } const share = (id: SessionID) => - Database.use((db) => db.select().from(SessionShareTable).where(eq(SessionShareTable.session_id, id)).get()) + Effect.gen(function* () { + const { db } = yield* Database.Service + return yield* db.select().from(SessionShareTable).where(eq(SessionShareTable.session_id, id)).get().pipe(Effect.orDie) + }) const seed = (url: string, org?: string) => AccountRepo.Service.use((repo) => @@ -169,7 +175,7 @@ describe("ShareNext", () => { expect(result.url).toBe("https://legacy-share.example.com/share/abc") expect(result.secret).toBe("sec_123") - const row = share(session.id) + const row = yield* share(session.id) expect(row?.id).toBe("shr_abc") expect(row?.url).toBe("https://legacy-share.example.com/share/abc") expect(row?.secret).toBe("sec_123") @@ -207,7 +213,7 @@ describe("ShareNext", () => { yield* ShareNext.use.remove(session.id) }).pipe(Effect.provide(live(client))) - expect(share(session.id)).toBeUndefined() + expect(yield* share(session.id)).toBeUndefined() expect(seen.map((req) => [req.method, req.url])).toEqual([ ["POST", "https://legacy-share.example.com/api/share"], ["DELETE", "https://legacy-share.example.com/api/share/shr_abc"], @@ -228,7 +234,7 @@ describe("ShareNext", () => { ) expect(Exit.isFailure(exit)).toBe(true) - expect(share(session.id)).toBeUndefined() + expect(yield* share(session.id)).toBeUndefined() }), ), ) @@ -245,28 +251,26 @@ describe("ShareNext", () => { }) return Effect.gen(function* () { - const bus = yield* Bus.Service + const events = yield* EventV2Bridge.Service const share = yield* ShareNext.Service const session = yield* Session.Service const info = yield* session.create({ title: "first" }) yield* share.init() yield* Effect.sleep(50) - yield* Effect.sync(() => - Database.use((db) => - db - .insert(SessionShareTable) - .values({ - session_id: info.id, - id: "shr_abc", - url: "https://legacy-share.example.com/share/abc", - secret: "sec_123", - }) - .run(), - ), - ) + const { db } = yield* Database.Service + yield* db + .insert(SessionShareTable) + .values({ + session_id: info.id, + id: "shr_abc", + url: "https://legacy-share.example.com/share/abc", + secret: "sec_123", + }) + .run() + .pipe(Effect.orDie) - yield* bus.publish(Session.Event.Diff, { + yield* events.publish(Session.Event.Diff, { sessionID: info.id, diff: [ { @@ -279,7 +283,7 @@ describe("ShareNext", () => { }, ], }) - yield* bus.publish(Session.Event.Diff, { + yield* events.publish(Session.Event.Diff, { sessionID: info.id, diff: [ { diff --git a/packages/opencode/test/skill/skill.test.ts b/packages/opencode/test/skill/skill.test.ts index fc1f6bff6a..e078b5eaf4 100644 --- a/packages/opencode/test/skill/skill.test.ts +++ b/packages/opencode/test/skill/skill.test.ts @@ -3,30 +3,31 @@ import { Effect, Layer } from "effect" import { Skill } from "../../src/skill" import { Discovery } from "../../src/skill/discovery" import { RuntimeFlags } from "../../src/effect/runtime-flags" -import { Bus } from "../../src/bus" +import { EventV2Bridge } from "../../src/event-v2-bridge" import { Config } from "../../src/config/config" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Global } from "@opencode-ai/core/global" -import { provideInstance, provideTmpdirInstance, tmpdir } from "../fixture/fixture" +import { provideInstance, provideTmpdirInstance, testInstanceStoreLayer, tmpdir } from "../fixture/fixture" import { testEffect } from "../lib/effect" import path from "path" import fs from "fs/promises" const node = CrossSpawnSpawner.defaultLayer -const it = testEffect(Layer.mergeAll(Skill.defaultLayer, node)) +const it = testEffect(Layer.mergeAll(Skill.defaultLayer, node, testInstanceStoreLayer)) const itWithoutClaudeCodeSkills = testEffect( Layer.mergeAll( Skill.layer.pipe( Layer.provide(Discovery.defaultLayer), Layer.provide(Config.defaultLayer), - Layer.provide(Bus.layer), + Layer.provide(EventV2Bridge.defaultLayer), Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Global.layer), Layer.provide(RuntimeFlags.layer({ disableClaudeCodeSkills: true })), ), node, + testInstanceStoreLayer, ), ) const itWithoutExternalSkills = testEffect( @@ -34,12 +35,13 @@ const itWithoutExternalSkills = testEffect( Skill.layer.pipe( Layer.provide(Discovery.defaultLayer), Layer.provide(Config.defaultLayer), - Layer.provide(Bus.layer), + Layer.provide(EventV2Bridge.defaultLayer), Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Global.layer), Layer.provide(RuntimeFlags.layer({ disableExternalSkills: true })), ), node, + testInstanceStoreLayer, ), ) diff --git a/packages/opencode/test/snapshot/snapshot.test.ts b/packages/opencode/test/snapshot/snapshot.test.ts index 8b42191952..81642f5744 100644 --- a/packages/opencode/test/snapshot/snapshot.test.ts +++ b/packages/opencode/test/snapshot/snapshot.test.ts @@ -6,10 +6,10 @@ import fs from "fs/promises" import path from "path" import { Effect, Fiber, Layer } from "effect" import { Snapshot } from "../../src/snapshot" -import { disposeAllInstances, provideInstance, TestInstance, tmpdirScoped } from "../fixture/fixture" +import { disposeAllInstances, provideInstance, testInstanceStoreLayer, TestInstance, tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" -const it = testEffect(Layer.mergeAll(Snapshot.defaultLayer, AppFileSystem.defaultLayer)) +const it = testEffect(Layer.mergeAll(Snapshot.defaultLayer, AppFileSystem.defaultLayer, testInstanceStoreLayer)) // Git always outputs /-separated paths internally. Snapshot.patch() joins them // with path.join (which produces \ on Windows) then normalizes back to /. diff --git a/packages/opencode/test/storage/db.test.ts b/packages/opencode/test/storage/db.test.ts deleted file mode 100644 index ba7f0912aa..0000000000 --- a/packages/opencode/test/storage/db.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { describe, expect } from "bun:test" -import path from "path" -import { Effect } from "effect" -import { Global } from "@opencode-ai/core/global" -import { InstallationChannel } from "@opencode-ai/core/installation/version" -import { RuntimeFlags } from "@/effect/runtime-flags" -import { Database } from "@/storage/db" -import { it } from "../lib/effect" - -describe("Database.getChannelPath", () => { - it.effect("returns database path for the current channel", () => - Effect.gen(function* () { - const flags = yield* RuntimeFlags.Service - const expected = ["latest", "beta", "prod"].includes(InstallationChannel) - ? path.join(Global.Path.data, "opencode.db") - : path.join(Global.Path.data, `opencode-${InstallationChannel.replace(/[^a-zA-Z0-9._-]/g, "-")}.db`) - - expect(Database.getChannelPath(flags)).toBe(expected) - }).pipe(Effect.provide(RuntimeFlags.layer())), - ) - - it.effect("uses the shared database path when channel databases are disabled", () => - Effect.gen(function* () { - const flags = yield* RuntimeFlags.Service - - expect(Database.getChannelPath(flags)).toBe(path.join(Global.Path.data, "opencode.db")) - }).pipe(Effect.provide(RuntimeFlags.layer({ disableChannelDb: true }))), - ) - - it.effect("accepts RuntimeFlags with skipMigrations for database callers", () => - Effect.gen(function* () { - const flags = yield* RuntimeFlags.Service - - expect(flags.skipMigrations).toBe(true) - expect(Database.getChannelPath(flags)).toBe(Database.getChannelPath({ disableChannelDb: flags.disableChannelDb })) - }).pipe(Effect.provide(RuntimeFlags.layer({ skipMigrations: true }))), - ) -}) diff --git a/packages/opencode/test/storage/json-migration.test.ts b/packages/opencode/test/storage/json-migration.test.ts index 598a635cd4..0ac2f2c596 100644 --- a/packages/opencode/test/storage/json-migration.test.ts +++ b/packages/opencode/test/storage/json-migration.test.ts @@ -4,13 +4,13 @@ import { drizzle, SQLiteBunDatabase } from "drizzle-orm/bun-sqlite" import { migrate } from "drizzle-orm/bun-sqlite/migrator" import path from "path" import fs from "fs/promises" -import { readFileSync, readdirSync } from "fs" +import { existsSync, readFileSync, readdirSync } from "fs" import { JsonMigration } from "@/storage/json-migration" import { Global } from "@opencode-ai/core/global" -import { ProjectTable } from "../../src/project/project.sql" -import { ProjectID } from "../../src/project/schema" -import { SessionTable, MessageTable, PartTable, TodoTable, PermissionTable } from "../../src/session/session.sql" -import { SessionShareTable } from "../../src/share/share.sql" +import { ProjectTable } from "@opencode-ai/core/project/sql" +import { ProjectV2 } from "@opencode-ai/core/project" +import { SessionTable, MessageTable, PartTable, TodoTable, PermissionTable } from "@opencode-ai/core/session/sql" +import { SessionShareTable } from "@opencode-ai/core/share/sql" import { SessionID, MessageID, PartID } from "../../src/session/schema" // Test fixtures @@ -79,10 +79,10 @@ function createTestDb() { sqlite.exec("PRAGMA foreign_keys = ON") // Apply schema migrations using drizzle migrate - const dir = path.join(import.meta.dirname, "../../migration") + const dir = path.join(import.meta.dirname, "../../../core/migration") const entries = readdirSync(dir, { withFileTypes: true }) const migrations = entries - .filter((entry) => entry.isDirectory()) + .filter((entry) => entry.isDirectory() && existsSync(path.join(dir, entry.name, "migration.sql"))) .map((entry) => ({ sql: readFileSync(path.join(dir, entry.name, "migration.sql"), "utf-8"), timestamp: Number(entry.name.split("_")[0]), @@ -127,7 +127,7 @@ describe("JSON to SQLite migration", () => { const projects = db.select().from(ProjectTable).all() expect(projects.length).toBe(1) - expect(projects[0].id).toBe(ProjectID.make("proj_test123abc")) + expect(projects[0].id).toBe(ProjectV2.ID.make("proj_test123abc")) expect(projects[0].worktree).toBe("/test/path") expect(projects[0].name).toBe("Test Project") expect(projects[0].sandboxes).toEqual(["/test/sandbox"]) @@ -151,7 +151,7 @@ describe("JSON to SQLite migration", () => { const projects = db.select().from(ProjectTable).all() expect(projects.length).toBe(1) - expect(projects[0].id).toBe(ProjectID.make("proj_filename")) // Uses filename, not JSON id + expect(projects[0].id).toBe(ProjectV2.ID.make("proj_filename")) // Uses filename, not JSON id }) test("migrates project with commands", async () => { @@ -171,7 +171,7 @@ describe("JSON to SQLite migration", () => { const projects = db.select().from(ProjectTable).all() expect(projects.length).toBe(1) - expect(projects[0].id).toBe(ProjectID.make("proj_with_commands")) + expect(projects[0].id).toBe(ProjectV2.ID.make("proj_with_commands")) expect(projects[0].commands).toEqual({ start: "npm run dev" }) }) @@ -191,7 +191,7 @@ describe("JSON to SQLite migration", () => { const projects = db.select().from(ProjectTable).all() expect(projects.length).toBe(1) - expect(projects[0].id).toBe(ProjectID.make("proj_no_commands")) + expect(projects[0].id).toBe(ProjectV2.ID.make("proj_no_commands")) expect(projects[0].commands).toBeNull() }) @@ -220,7 +220,7 @@ describe("JSON to SQLite migration", () => { const sessions = db.select().from(SessionTable).all() expect(sessions.length).toBe(1) expect(sessions[0].id).toBe(SessionID.make("ses_test456def")) - expect(sessions[0].project_id).toBe(ProjectID.make("proj_test123abc")) + expect(sessions[0].project_id).toBe(ProjectV2.ID.make("proj_test123abc")) expect(sessions[0].slug).toBe("test-session") expect(sessions[0].title).toBe("Test Session Title") expect(sessions[0].summary_additions).toBe(10) @@ -421,7 +421,7 @@ describe("JSON to SQLite migration", () => { const sessions = db.select().from(SessionTable).all() expect(sessions.length).toBe(1) expect(sessions[0].id).toBe(SessionID.make("ses_migrated")) - expect(sessions[0].project_id).toBe(ProjectID.make(gitBasedProjectID)) // Uses directory, not stale JSON + expect(sessions[0].project_id).toBe(ProjectV2.ID.make(gitBasedProjectID)) // Uses directory, not stale JSON }) test("uses filename for session id when JSON has different value", async () => { @@ -452,7 +452,7 @@ describe("JSON to SQLite migration", () => { const sessions = db.select().from(SessionTable).all() expect(sessions.length).toBe(1) expect(sessions[0].id).toBe(SessionID.make("ses_from_filename")) // Uses filename, not JSON id - expect(sessions[0].project_id).toBe(ProjectID.make("proj_test123abc")) + expect(sessions[0].project_id).toBe(ProjectV2.ID.make("proj_test123abc")) }) test("is idempotent (running twice doesn't duplicate)", async () => { @@ -631,7 +631,7 @@ describe("JSON to SQLite migration", () => { const projects = db.select().from(ProjectTable).all() expect(projects.length).toBe(1) - expect(projects[0].id).toBe(ProjectID.make("proj_test123abc")) + expect(projects[0].id).toBe(ProjectV2.ID.make("proj_test123abc")) }) test("skips invalid todo entries while preserving source positions", async () => { diff --git a/packages/opencode/test/storage/workspace-time-migration.test.ts b/packages/opencode/test/storage/workspace-time-migration.test.ts index 2d30646976..29a09acb7b 100644 --- a/packages/opencode/test/storage/workspace-time-migration.test.ts +++ b/packages/opencode/test/storage/workspace-time-migration.test.ts @@ -2,18 +2,22 @@ import { describe, expect, test } from "bun:test" import { Database } from "bun:sqlite" import { drizzle } from "drizzle-orm/bun-sqlite" import { migrate } from "drizzle-orm/bun-sqlite/migrator" -import { readFileSync, readdirSync } from "fs" +import { existsSync, readFileSync, readdirSync } from "fs" import path from "path" const target = "20260507164347_add_workspace_time" function migrations() { - return readdirSync(path.join(import.meta.dirname, "../../migration"), { withFileTypes: true }) - .filter((entry) => entry.isDirectory()) + return readdirSync(path.join(import.meta.dirname, "../../../core/migration"), { withFileTypes: true }) + .filter( + (entry) => + entry.isDirectory() && + existsSync(path.join(import.meta.dirname, "../../../core/migration", entry.name, "migration.sql")), + ) .map((entry) => ({ name: entry.name, timestamp: Number(entry.name.split("_")[0]), - sql: readFileSync(path.join(import.meta.dirname, "../../migration", entry.name, "migration.sql"), "utf-8"), + sql: readFileSync(path.join(import.meta.dirname, "../../../core/migration", entry.name, "migration.sql"), "utf-8"), })) .sort((a, b) => a.timestamp - b.timestamp) } diff --git a/packages/opencode/test/sync/index.test.ts b/packages/opencode/test/sync/index.test.ts deleted file mode 100644 index e3307d2aec..0000000000 --- a/packages/opencode/test/sync/index.test.ts +++ /dev/null @@ -1,390 +0,0 @@ -import { describe, expect, beforeEach, afterAll } from "bun:test" -import { provideTmpdirInstance } from "../fixture/fixture" -import { Deferred, Effect, Layer, Schema } from "effect" -import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" -import { Bus } from "../../src/bus" -import { GlobalBus, type GlobalEvent } from "../../src/bus/global" -import { SyncEvent } from "../../src/sync" -import { Database, eq } from "@/storage/db" -import { EventSequenceTable, EventTable } from "../../src/sync/event.sql" -import { MessageID } from "../../src/session/schema" -import { initProjectors } from "../../src/server/projectors" -import { awaitWithTimeout, testEffect } from "../lib/effect" -import { RuntimeFlags } from "@/effect/runtime-flags" - -const it = testEffect( - Layer.mergeAll( - SyncEvent.layer.pipe( - Layer.provide(RuntimeFlags.layer({ experimentalWorkspaces: true })), - Layer.provideMerge(Bus.layer), - ), - CrossSpawnSpawner.defaultLayer, - ), -) - -beforeEach(() => { - Database.close() -}) - -describe("SyncEvent", () => { - function setup() { - SyncEvent.reset() - - const Created = SyncEvent.define({ - type: "item.created", - version: 1, - aggregate: "id", - schema: Schema.Struct({ id: Schema.String, name: Schema.String }), - }) - const Sent = SyncEvent.define({ - type: "item.sent", - version: 1, - aggregate: "item_id", - schema: Schema.Struct({ item_id: Schema.String, to: Schema.String }), - }) - - SyncEvent.init({ - projectors: [SyncEvent.project(Created, () => {}), SyncEvent.project(Sent, () => {})], - }) - - return { Created, Sent } - } - - function expectDefect(effect: Effect.Effect, pattern: RegExp) { - return Effect.gen(function* () { - const exit = yield* Effect.exit(effect) - if (exit._tag === "Success") throw new Error("Expected effect to fail") - expect(String(exit.cause)).toMatch(pattern) - }) - } - - afterAll(() => { - SyncEvent.reset() - initProjectors() - }) - - describe("run", () => { - it.live( - "inserts event row", - provideTmpdirInstance(() => - Effect.gen(function* () { - const { Created } = setup() - yield* SyncEvent.use.run(Created, { id: "evt_1", name: "first" }) - const rows = Database.use((db) => db.select().from(EventTable).all()) - expect(rows).toHaveLength(1) - expect(rows[0].type).toBe("item.created.1") - expect(rows[0].aggregate_id).toBe("evt_1") - }), - ), - ) - - it.live( - "increments seq per aggregate", - provideTmpdirInstance(() => - Effect.gen(function* () { - const { Created } = setup() - yield* SyncEvent.use.run(Created, { id: "evt_1", name: "first" }) - yield* SyncEvent.use.run(Created, { id: "evt_1", name: "second" }) - const rows = Database.use((db) => db.select().from(EventTable).all()) - expect(rows).toHaveLength(2) - expect(rows[1].seq).toBe(rows[0].seq + 1) - }), - ), - ) - - it.live( - "uses custom aggregate field from agg()", - provideTmpdirInstance(() => - Effect.gen(function* () { - const { Sent } = setup() - yield* SyncEvent.use.run(Sent, { item_id: "evt_1", to: "james" }) - const rows = Database.use((db) => db.select().from(EventTable).all()) - expect(rows).toHaveLength(1) - expect(rows[0].aggregate_id).toBe("evt_1") - }), - ), - ) - - it.live( - "emits events", - provideTmpdirInstance(() => - Effect.gen(function* () { - const { Created } = setup() - const events: Array<{ - type: string - properties: { id: string; name: string } - }> = [] - let resolve = () => {} - const received = new Promise((done) => { - resolve = done - }) - const bus = yield* Bus.Service - const dispose = yield* bus.subscribeAllCallback((event) => { - events.push(event) - resolve() - }) - try { - yield* SyncEvent.use.run(Created, { id: "evt_1", name: "test" }) - yield* Effect.promise(() => received) - expect(events).toHaveLength(1) - expect(events[0]).toMatchObject({ - type: "item.created", - properties: { - id: "evt_1", - name: "test", - }, - }) - } finally { - dispose() - } - }), - ), - ) - - // Regression for the EffectBridge migration. GlobalBus.emit used to fire - // synchronously inside the Database.effect post-commit callback. After the - // migration it fires inside the forked publish Effect, AFTER bus.publish - // completes. Consumers don't care about microsecond-level ordering, but - // we still need to prove the emit actually fires. - it.live( - "emits sync events to GlobalBus after publishing to ProjectBus", - provideTmpdirInstance(() => - Effect.gen(function* () { - const { Created } = setup() - // Filter for OUR specific event in the handler so we ignore any - // stray sync events from other tests' lingering forks. - const received = yield* Deferred.make() - const handler = (evt: GlobalEvent) => { - if (evt.payload?.type === "sync" && evt.payload?.syncEvent?.type === "item.created.1") { - Deferred.doneUnsafe(received, Effect.succeed(evt)) - } - } - GlobalBus.on("event", handler) - try { - yield* SyncEvent.use.run(Created, { id: "evt_global_1", name: "global" }) - const event = yield* awaitWithTimeout( - Deferred.await(received), - "timed out waiting for sync event on GlobalBus", - "2 seconds", - ) - expect(event.payload).toMatchObject({ - type: "sync", - syncEvent: { type: "item.created.1", data: { id: "evt_global_1", name: "global" } }, - }) - } finally { - GlobalBus.off("event", handler) - } - }), - ), - ) - }) - - describe("replay", () => { - it.live( - "inserts event from external payload", - provideTmpdirInstance(() => - Effect.gen(function* () { - const id = MessageID.ascending() - yield* SyncEvent.use.replay({ - id: "evt_1", - type: "item.created.1", - seq: 0, - aggregateID: id, - data: { id, name: "replayed" }, - }) - const rows = Database.use((db) => db.select().from(EventTable).all()) - expect(rows).toHaveLength(1) - expect(rows[0].aggregate_id).toBe(id) - }), - ), - ) - - it.live( - "throws on sequence mismatch", - provideTmpdirInstance(() => - Effect.gen(function* () { - const id = MessageID.ascending() - yield* SyncEvent.use.replay({ - id: "evt_1", - type: "item.created.1", - seq: 0, - aggregateID: id, - data: { id, name: "first" }, - }) - yield* expectDefect( - SyncEvent.use.replay({ - id: "evt_1", - type: "item.created.1", - seq: 5, - aggregateID: id, - data: { id, name: "bad" }, - }), - /Sequence mismatch/, - ) - }), - ), - ) - - it.live( - "throws on unknown event type", - provideTmpdirInstance(() => - Effect.gen(function* () { - yield* expectDefect( - SyncEvent.use.replay({ - id: "evt_1", - type: "unknown.event.1", - seq: 0, - aggregateID: "x", - data: {}, - }), - /Unknown event type/, - ) - }), - ), - ) - - it.live( - "replayAll accepts later chunks after the first batch", - provideTmpdirInstance(() => - Effect.gen(function* () { - const { Created } = setup() - const id = MessageID.ascending() - - const one = yield* SyncEvent.use.replayAll([ - { - id: "evt_1", - type: SyncEvent.versionedType(Created.type, Created.version), - seq: 0, - aggregateID: id, - data: { id, name: "first" }, - }, - { - id: "evt_2", - type: SyncEvent.versionedType(Created.type, Created.version), - seq: 1, - aggregateID: id, - data: { id, name: "second" }, - }, - ]) - - const two = yield* SyncEvent.use.replayAll([ - { - id: "evt_3", - type: SyncEvent.versionedType(Created.type, Created.version), - seq: 2, - aggregateID: id, - data: { id, name: "third" }, - }, - { - id: "evt_4", - type: SyncEvent.versionedType(Created.type, Created.version), - seq: 3, - aggregateID: id, - data: { id, name: "fourth" }, - }, - ]) - - expect(one).toBe(id) - expect(two).toBe(id) - - const rows = Database.use((db) => db.select().from(EventTable).all()) - expect(rows.map((row) => row.seq)).toEqual([0, 1, 2, 3]) - }), - ), - ) - - it.live( - "claims unowned event sequence on replay with ownerID", - provideTmpdirInstance(() => - Effect.gen(function* () { - const { Created } = setup() - const id = MessageID.ascending() - - yield* SyncEvent.use.replay( - { - id: "evt_1", - type: SyncEvent.versionedType(Created.type, Created.version), - seq: 0, - aggregateID: id, - data: { id, name: "owned" }, - }, - { publish: false, ownerID: "owner-1" }, - ) - - const row = Database.use((db) => - db - .select({ seq: EventSequenceTable.seq, ownerID: EventSequenceTable.owner_id }) - .from(EventSequenceTable) - .get(), - ) - expect(row).toEqual({ seq: 0, ownerID: "owner-1" }) - }), - ), - ) - - it.live( - "ignores replay from a different owner after sequence is claimed", - provideTmpdirInstance(() => - Effect.gen(function* () { - const { Created } = setup() - const id = MessageID.ascending() - - yield* SyncEvent.use.replay( - { - id: "evt_1", - type: SyncEvent.versionedType(Created.type, Created.version), - seq: 0, - aggregateID: id, - data: { id, name: "first" }, - }, - { publish: false, ownerID: "owner-1" }, - ) - yield* SyncEvent.use.replay( - { - id: "evt_2", - type: SyncEvent.versionedType(Created.type, Created.version), - seq: 1, - aggregateID: id, - data: { id, name: "ignored" }, - }, - { publish: false, ownerID: "owner-2" }, - ) - - const events = Database.use((db) => db.select().from(EventTable).all()) - const sequence = Database.use((db) => - db - .select({ seq: EventSequenceTable.seq, ownerID: EventSequenceTable.owner_id }) - .from(EventSequenceTable) - .get(), - ) - expect(events).toHaveLength(1) - expect(events[0].id).toBe("evt_1") - expect(sequence).toEqual({ seq: 0, ownerID: "owner-1" }) - }), - ), - ) - - it.live( - "claim updates the event sequence owner", - provideTmpdirInstance(() => - Effect.gen(function* () { - const { Created } = setup() - const id = MessageID.ascending() - - yield* SyncEvent.use.run(Created, { id, name: "claimed" }, { publish: false }) - yield* SyncEvent.use.claim(id, "owner-1") - yield* SyncEvent.use.claim(id, "owner-2") - - const row = Database.use((db) => - db - .select({ seq: EventSequenceTable.seq, ownerID: EventSequenceTable.owner_id }) - .from(EventSequenceTable) - .where(eq(EventSequenceTable.aggregate_id, id)) - .get(), - ) - expect(row).toEqual({ seq: 0, ownerID: "owner-2" }) - }), - ), - ) - }) -}) diff --git a/packages/opencode/test/tool/apply_patch.test.ts b/packages/opencode/test/tool/apply_patch.test.ts index be5754f3b4..01e326ec5d 100644 --- a/packages/opencode/test/tool/apply_patch.test.ts +++ b/packages/opencode/test/tool/apply_patch.test.ts @@ -7,7 +7,7 @@ import { LSP } from "@/lsp/lsp" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Format } from "../../src/format" import { Agent } from "../../src/agent/agent" -import { Bus } from "../../src/bus" +import { EventV2Bridge } from "../../src/event-v2-bridge" import { Truncate } from "@/tool/truncate" import { TestInstance } from "../fixture/fixture" import { SessionID, MessageID } from "../../src/session/schema" @@ -18,7 +18,7 @@ const it = testEffect( LSP.defaultLayer, AppFileSystem.defaultLayer, Format.defaultLayer, - Bus.layer, + EventV2Bridge.defaultLayer, Truncate.defaultLayer, Agent.defaultLayer, ), diff --git a/packages/opencode/test/tool/edit.test.ts b/packages/opencode/test/tool/edit.test.ts index 3f644ed53d..9abc33885b 100644 --- a/packages/opencode/test/tool/edit.test.ts +++ b/packages/opencode/test/tool/edit.test.ts @@ -8,7 +8,7 @@ import { LSP } from "@/lsp/lsp" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Format } from "../../src/format" import { Agent } from "../../src/agent/agent" -import { Bus } from "../../src/bus" +import { EventV2Bridge } from "../../src/event-v2-bridge" import { Truncate } from "@/tool/truncate" import { SessionID, MessageID } from "../../src/session/schema" import * as Tool from "../../src/tool/tool" @@ -34,7 +34,7 @@ const layer = Layer.mergeAll( LSP.defaultLayer, AppFileSystem.defaultLayer, Format.defaultLayer, - Bus.layer, + EventV2Bridge.defaultLayer, Truncate.defaultLayer, Agent.defaultLayer, ) @@ -83,10 +83,13 @@ const makeDirectory = Effect.fn("EditToolTest.makeDirectory")(function* (p: stri }) const onceBus = Effect.fn("EditToolTest.onceBus")(function* (def: typeof FileWatcher.Event.Updated) { - const bus = yield* Bus.Service + const events = yield* EventV2Bridge.Service const deferred = yield* Deferred.make() - const unsub = yield* bus.subscribeCallback(def, () => Effect.runSync(Deferred.succeed(deferred, undefined))) - yield* Effect.addFinalizer(() => Effect.sync(unsub)) + const unsub = yield* events.listen((event) => { + if (event.type === def.type) Deferred.doneUnsafe(deferred, Effect.void) + return Effect.void + }) + yield* Effect.addFinalizer(() => unsub) return deferred }) diff --git a/packages/opencode/test/tool/external-directory.test.ts b/packages/opencode/test/tool/external-directory.test.ts index e59caaa720..06019001ff 100644 --- a/packages/opencode/test/tool/external-directory.test.ts +++ b/packages/opencode/test/tool/external-directory.test.ts @@ -5,7 +5,7 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import type { Tool } from "@/tool/tool" import { assertExternalDirectoryEffect } from "../../src/tool/external-directory" import { Filesystem } from "@/util/filesystem" -import { provideInstance, TestInstance, tmpdirScoped } from "../fixture/fixture" +import { TestInstance, tmpdirScoped } from "../fixture/fixture" import type { Permission } from "../../src/permission" import { SessionID, MessageID } from "../../src/session/schema" import { testEffect } from "../lib/effect" @@ -48,27 +48,26 @@ describe("tool.assertExternalDirectory", () => { }), ) - it.live("no-ops for paths inside the instance directory", () => - provideInstance("/tmp/project")( - Effect.gen(function* () { - const { requests, ctx } = makeCtx() - - yield* assertExternalDirectoryEffect(ctx, path.join("/tmp/project", "file.txt")) - - expect(requests.length).toBe(0) - }), - ), - ) - - it.live("asks with a single canonical glob", () => + it.instance("no-ops for paths inside the instance directory", () => Effect.gen(function* () { + const test = yield* TestInstance const { requests, ctx } = makeCtx() - const directory = "/tmp/project" - const target = "/tmp/outside/file.txt" + yield* assertExternalDirectoryEffect(ctx, path.join(test.directory, "file.txt")) + + expect(requests.length).toBe(0) + }), + ) + + it.instance("asks with a single canonical glob", () => + Effect.gen(function* () { + const test = yield* TestInstance + const { requests, ctx } = makeCtx() + + const target = path.join(path.dirname(test.directory), "outside", "file.txt") const expected = glob(path.join(path.dirname(target), "*")) - yield* provideInstance(directory)(assertExternalDirectoryEffect(ctx, target)) + yield* assertExternalDirectoryEffect(ctx, target) const req = requests.find((r) => r.permission === "external_directory") expect(req).toBeDefined() @@ -77,15 +76,15 @@ describe("tool.assertExternalDirectory", () => { }), ) - it.live("uses target directory when kind=directory", () => + it.instance("uses target directory when kind=directory", () => Effect.gen(function* () { + const test = yield* TestInstance const { requests, ctx } = makeCtx() - const directory = "/tmp/project" - const target = "/tmp/outside" + const target = path.join(path.dirname(test.directory), "outside") const expected = glob(path.join(target, "*")) - yield* provideInstance(directory)(assertExternalDirectoryEffect(ctx, target, { kind: "directory" })) + yield* assertExternalDirectoryEffect(ctx, target, { kind: "directory" }) const req = requests.find((r) => r.permission === "external_directory") expect(req).toBeDefined() @@ -95,15 +94,13 @@ describe("tool.assertExternalDirectory", () => { ) it.live("skips prompting when bypass=true", () => - provideInstance("/tmp/project")( - Effect.gen(function* () { - const { requests, ctx } = makeCtx() + Effect.gen(function* () { + const { requests, ctx } = makeCtx() - yield* assertExternalDirectoryEffect(ctx, "/tmp/outside/file.txt", { bypass: true }) + yield* assertExternalDirectoryEffect(ctx, "/tmp/outside/file.txt", { bypass: true }) - expect(requests.length).toBe(0) - }), - ), + expect(requests.length).toBe(0) + }), ) if (process.platform === "win32") { diff --git a/packages/opencode/test/tool/grep.test.ts b/packages/opencode/test/tool/grep.test.ts index 027d5201cb..a8cf5c9a32 100644 --- a/packages/opencode/test/tool/grep.test.ts +++ b/packages/opencode/test/tool/grep.test.ts @@ -4,7 +4,7 @@ import os from "os" import path from "path" import { Effect, Layer } from "effect" import { GrepTool } from "../../src/tool/grep" -import { provideInstance, TestInstance, tmpdirScoped } from "../fixture/fixture" +import { provideInstance, testInstanceStoreLayer, TestInstance, tmpdirScoped } from "../fixture/fixture" import { SessionID, MessageID } from "../../src/session/schema" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Global } from "@opencode-ai/core/global" @@ -42,6 +42,7 @@ const toolLayer = (flags: Partial = {}) => const it = testEffect(toolLayer()) const scout = testEffect(toolLayer({ experimentalScout: true })) +const rooted = testEffect(Layer.mergeAll(toolLayer(), testInstanceStoreLayer)) const ctx = { sessionID: SessionID.make("ses_test"), @@ -90,7 +91,7 @@ const git = Effect.fn("GrepToolTest.git")(function* (cwd: string, args: string[] }) describe("tool.grep", () => { - it.live("basic search", () => + rooted.live("basic search", () => Effect.gen(function* () { const info = yield* GrepTool const grep = yield* info.init() diff --git a/packages/opencode/test/tool/lsp.test.ts b/packages/opencode/test/tool/lsp.test.ts index 875edc1c05..f3b1d7efd6 100644 --- a/packages/opencode/test/tool/lsp.test.ts +++ b/packages/opencode/test/tool/lsp.test.ts @@ -10,7 +10,7 @@ import { MessageID, SessionID } from "../../src/session/schema" import { Tool } from "@/tool/tool" import { Truncate } from "@/tool/truncate" import { LspTool } from "../../src/tool/lsp" -import { disposeAllInstances, provideTmpdirInstance } from "../fixture/fixture" +import { disposeAllInstances, TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" afterEach(async () => { @@ -98,10 +98,11 @@ const asks = () => { describe("tool.lsp", () => { describe("permission metadata", () => { - it.live("keeps cursor details for position-based operations", () => - provideTmpdirInstance( - (dir) => - Effect.gen(function* () { + it.instance( + "keeps cursor details for position-based operations", + () => + Effect.gen(function* () { + const dir = (yield* TestInstance).directory const file = path.join(dir, "test.ts") yield* put(file) @@ -117,15 +118,15 @@ describe("tool.lsp", () => { character: 7, }) expect(result.title).toBe("goToDefinition test.ts:3:7") - }), - { git: true }, - ), + }), + { git: true }, ) - it.live("omits cursor details for documentSymbol", () => - provideTmpdirInstance( - (dir) => - Effect.gen(function* () { + it.instance( + "omits cursor details for documentSymbol", + () => + Effect.gen(function* () { + const dir = (yield* TestInstance).directory const file = path.join(dir, "test.ts") yield* put(file) @@ -139,15 +140,15 @@ describe("tool.lsp", () => { filePath: file, }) expect(result.title).toBe("documentSymbol test.ts") - }), - { git: true }, - ), + }), + { git: true }, ) - it.live("omits file and cursor details for workspaceSymbol", () => - provideTmpdirInstance( - (dir) => - Effect.gen(function* () { + it.instance( + "omits file and cursor details for workspaceSymbol", + () => + Effect.gen(function* () { + const dir = (yield* TestInstance).directory workspaceSymbolQueries.length = 0 const file = path.join(dir, "test.ts") yield* put(file) @@ -161,15 +162,15 @@ describe("tool.lsp", () => { operation: "workspaceSymbol", }) expect(result.title).toBe("workspaceSymbol") - }), - { git: true }, - ), + }), + { git: true }, ) - it.live("passes workspaceSymbol query to LSP", () => - provideTmpdirInstance( - (dir) => - Effect.gen(function* () { + it.instance( + "passes workspaceSymbol query to LSP", + () => + Effect.gen(function* () { + const dir = (yield* TestInstance).directory workspaceSymbolQueries.length = 0 const file = path.join(dir, "test.ts") yield* put(file) @@ -178,9 +179,8 @@ describe("tool.lsp", () => { yield* run({ operation: "workspaceSymbol", filePath: file, line: 3, character: 7 }) expect(workspaceSymbolQueries).toEqual(["TestSymbol", ""]) - }), - { git: true }, - ), + }), + { git: true }, ) }) }) diff --git a/packages/opencode/test/tool/question.test.ts b/packages/opencode/test/tool/question.test.ts index 854c1f8911..0bbc58d442 100644 --- a/packages/opencode/test/tool/question.test.ts +++ b/packages/opencode/test/tool/question.test.ts @@ -7,7 +7,7 @@ import { Agent } from "../../src/agent/agent" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Truncate } from "@/tool/truncate" import { testEffect } from "../lib/effect" -import { Bus } from "../../src/bus" +import { EventV2Bridge } from "../../src/event-v2-bridge" const ctx = { sessionID: SessionID.make("ses_test-session"), @@ -22,7 +22,7 @@ const ctx = { const it = testEffect( Layer.mergeAll( - Question.layer.pipe(Layer.provideMerge(Bus.layer)), + Question.layer.pipe(Layer.provideMerge(EventV2Bridge.defaultLayer)), CrossSpawnSpawner.defaultLayer, Truncate.defaultLayer, Agent.defaultLayer, @@ -30,10 +30,13 @@ const it = testEffect( ) const pending = Effect.fn("QuestionToolTest.pending")(function* (question: Question.Interface) { - const bus = yield* Bus.Service + const events = yield* EventV2Bridge.Service const asked = yield* Queue.unbounded() - const off = yield* bus.subscribeCallback(Question.Event.Asked, () => Queue.offerUnsafe(asked, undefined)) - yield* Effect.addFinalizer(() => Effect.sync(off)) + const off = yield* events.listen((event) => { + if (event.type === Question.Event.Asked.type) Queue.offerUnsafe(asked, undefined) + return Effect.void + }) + yield* Effect.addFinalizer(() => off) for (;;) { const items = yield* question.list() diff --git a/packages/opencode/test/tool/read.test.ts b/packages/opencode/test/tool/read.test.ts index f8c656ccfb..4853cbe2ff 100644 --- a/packages/opencode/test/tool/read.test.ts +++ b/packages/opencode/test/tool/read.test.ts @@ -15,7 +15,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, TestInstance, tmpdirScoped } from "../fixture/fixture" +import { disposeAllInstances, provideInstance, testInstanceStoreLayer, TestInstance, tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" import { Reference } from "@/reference/reference" import { RepositoryCache } from "@/reference/repository-cache" @@ -55,8 +55,8 @@ const readLayer = (flags: Partial = {}) => Truncate.defaultLayer, ) -const it = testEffect(readLayer()) -const scout = testEffect(readLayer({ experimentalScout: true })) +const it = testEffect(Layer.mergeAll(readLayer(), testInstanceStoreLayer)) +const scout = testEffect(Layer.mergeAll(readLayer({ experimentalScout: true }), testInstanceStoreLayer)) const init = Effect.fn("ReadToolTest.init")(function* () { const info = yield* ReadTool diff --git a/packages/opencode/test/tool/registry.test.ts b/packages/opencode/test/tool/registry.test.ts index 25c50678ad..489a756d56 100644 --- a/packages/opencode/test/tool/registry.test.ts +++ b/packages/opencode/test/tool/registry.test.ts @@ -4,6 +4,7 @@ import fs from "fs/promises" import { fileURLToPath, pathToFileURL } from "url" import { Effect, Layer, Result, Schema } from "effect" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { Database } from "@opencode-ai/core/database/database" import { ToolRegistry } from "@/tool/registry" import { Tool } from "@/tool/tool" import { disposeAllInstances, TestInstance } from "../fixture/fixture" @@ -22,7 +23,7 @@ import { Provider } from "@/provider/provider" import { Git } from "@/git" import { LSP } from "@/lsp/lsp" import { Instruction } from "@/session/instruction" -import { Bus } from "@/bus" +import { EventV2Bridge } from "@/event-v2-bridge" import { FetchHttpClient } from "effect/unstable/http" import { Format } from "@/format" import { Ripgrep } from "@/file/ripgrep" @@ -30,10 +31,11 @@ import * as Truncate from "@/tool/truncate" import { InstanceState } from "@/effect/instance-state" import { Reference } from "@/reference/reference" import { RepositoryCache } from "@/reference/repository-cache" -import { ProviderID, ModelID } from "@/provider/schema" + import { ToolJsonSchema } from "@/tool/json-schema" import { MessageID, SessionID } from "@/session/schema" import { RuntimeFlags } from "@/effect/runtime-flags" +import { ProviderV2 } from "@opencode-ai/core/provider" const node = CrossSpawnSpawner.defaultLayer const configLayer = TestConfig.layer({ @@ -62,10 +64,10 @@ const registryLayer = (opts: RegistryLayerOptions = {}) => Layer.provide(LSP.defaultLayer), Layer.provide(Instruction.defaultLayer), Layer.provide(AppFileSystem.defaultLayer), - Layer.provide(Bus.layer), + Layer.provide(EventV2Bridge.defaultLayer), Layer.provide(FetchHttpClient.layer), Layer.provide(Format.defaultLayer), - Layer.provide(node), + Layer.provide(Layer.mergeAll(node, Database.defaultLayer)), Layer.provide(Ripgrep.defaultLayer), Layer.provide(Truncate.defaultLayer), ) @@ -144,8 +146,8 @@ describe("tool.registry", () => { const build = yield* agent.get("build") if (!build) throw new Error("build agent not found") const task = (yield* registry.tools({ - providerID: ProviderID.opencode, - modelID: ModelID.make("test"), + providerID: ProviderV2.ID.opencode, + modelID: ProviderV2.ModelID.make("test"), agent: build, })).find((tool) => tool.id === "task") @@ -322,8 +324,8 @@ describe("tool.registry", () => { const agents = yield* Agent.Service const promptTools = yield* registry.tools({ - providerID: ProviderID.opencode, - modelID: ModelID.make("test"), + providerID: ProviderV2.ID.opencode, + modelID: ProviderV2.ModelID.make("test"), agent: yield* agents.defaultInfo(), }) const promptTool = promptTools.find((tool) => tool.id === "sql") diff --git a/packages/opencode/test/tool/repo_clone.test.ts b/packages/opencode/test/tool/repo_clone.test.ts index 2d7c70efcf..75b103e2f5 100644 --- a/packages/opencode/test/tool/repo_clone.test.ts +++ b/packages/opencode/test/tool/repo_clone.test.ts @@ -11,7 +11,7 @@ import { MessageID, SessionID } from "../../src/session/schema" import { Truncate } from "../../src/tool/truncate" import { RepoCloneTool } from "../../src/tool/repo_clone" import { RepositoryCache } from "../../src/reference/repository-cache" -import { disposeAllInstances, provideTmpdirInstance, tmpdirScoped } from "../fixture/fixture" +import { disposeAllInstances, TestInstance, tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" afterEach(async () => { @@ -80,9 +80,8 @@ const githubBase = (url: string, self: Effect.Effect) => ) describe("tool.repo_clone", () => { - it.live("clones a repo into the managed cache and reuses it on subsequent calls", () => - provideTmpdirInstance((_dir) => - Effect.gen(function* () { + it.instance("clones a repo into the managed cache and reuses it on subsequent calls", () => + Effect.gen(function* () { const fs = yield* AppFileSystem.Service const source = yield* tmpdirScoped({ git: true }) const remoteRoot = yield* tmpdirScoped() @@ -106,13 +105,11 @@ describe("tool.repo_clone", () => { expect(cloned.metadata.localPath).toBe(path.join(Global.Path.repos, "github.com", "owner", "repo")) expect(cached.metadata.status).toBe("cached") expect(yield* fs.readFileString(path.join(cloned.metadata.localPath, "README.md"))).toBe("v1\n") - }), - ), + }), ) - it.live("refresh updates an existing cached clone", () => - provideTmpdirInstance((_dir) => - Effect.gen(function* () { + it.instance("refresh updates an existing cached clone", () => + Effect.gen(function* () { const fs = yield* AppFileSystem.Service const source = yield* tmpdirScoped({ git: true }) const remoteRoot = yield* tmpdirScoped() @@ -145,13 +142,11 @@ describe("tool.repo_clone", () => { expect(first.metadata.status).toBe("cloned") expect(refreshed.metadata.status).toBe("refreshed") expect(yield* fs.readFileString(path.join(first.metadata.localPath, "README.md"))).toBe("v2\n") - }), - ), + }), ) - it.live("clones a configured branch", () => - provideTmpdirInstance((_dir) => - Effect.gen(function* () { + it.instance("clones a configured branch", () => + Effect.gen(function* () { const fs = yield* AppFileSystem.Service const source = yield* tmpdirScoped({ git: true }) const remoteRoot = yield* tmpdirScoped() @@ -177,19 +172,18 @@ describe("tool.repo_clone", () => { expect(result.metadata.status).toBe("cloned") expect(result.metadata.branch).toBe("docs") expect(yield* fs.readFileString(path.join(result.metadata.localPath, "DOCS.md"))).toBe("docs\n") - }), - ), + }), ) - it.live("rejects invalid repository inputs", () => - provideTmpdirInstance((_dir) => - Effect.gen(function* () { + it.instance("rejects invalid repository inputs", () => + Effect.gen(function* () { + const dir = (yield* TestInstance).directory const tool = yield* init() const inputs = [ { repository: "not-a-repo", message: "git URL" }, { repository: "git@github.com:../../../etc/passwd", message: "git URL" }, { repository: "-u:foo/bar", message: "git URL" }, - { repository: pathToFileURL(path.join(_dir, "local.git")).href, message: "Local file" }, + { repository: pathToFileURL(path.join(dir, "local.git")).href, message: "Local file" }, ] yield* Effect.forEach( @@ -206,13 +200,11 @@ describe("tool.repo_clone", () => { }), { discard: true }, ) - }), - ), + }), ) - it.live("rejects local file repository URLs", () => - provideTmpdirInstance((_dir) => - Effect.gen(function* () { + it.instance("rejects local file repository URLs", () => + Effect.gen(function* () { const source = yield* tmpdirScoped({ git: true }) const tool = yield* init() const result = yield* tool.execute({ repository: pathToFileURL(source).href }, ctx).pipe(Effect.exit) @@ -222,13 +214,11 @@ describe("tool.repo_clone", () => { const error = Cause.squash(result.cause) expect(error instanceof Error ? error.message : String(error)).toContain("Local file") } - }), - ), + }), ) - it.live("rejects invalid branch inputs", () => - provideTmpdirInstance((_dir) => - Effect.gen(function* () { + it.instance("rejects invalid branch inputs", () => + Effect.gen(function* () { const tool = yield* init() const result = yield* tool.execute({ repository: "owner/repo", branch: "bad..branch" }, ctx).pipe(Effect.exit) @@ -239,7 +229,6 @@ describe("tool.repo_clone", () => { "Branch must contain only alphanumeric characters", ) } - }), - ), + }), ) }) diff --git a/packages/opencode/test/tool/repo_overview.test.ts b/packages/opencode/test/tool/repo_overview.test.ts index c854e51a3f..34c29b717f 100644 --- a/packages/opencode/test/tool/repo_overview.test.ts +++ b/packages/opencode/test/tool/repo_overview.test.ts @@ -9,7 +9,7 @@ import { Global } from "@opencode-ai/core/global" import { MessageID, SessionID } from "../../src/session/schema" import { Truncate } from "../../src/tool/truncate" import { RepoOverviewTool } from "../../src/tool/repo_overview" -import { disposeAllInstances, provideTmpdirInstance, tmpdirScoped } from "../fixture/fixture" +import { disposeAllInstances, TestInstance, tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" afterEach(async () => { @@ -43,9 +43,8 @@ const init = Effect.fn("RepoOverviewToolTest.init")(function* () { }) describe("tool.repo_overview", () => { - it.live("summarizes a local repository path", () => - provideTmpdirInstance((_dir) => - Effect.gen(function* () { + it.instance("summarizes a local repository path", () => + Effect.gen(function* () { const repo = yield* tmpdirScoped({ git: true }) const fs = yield* AppFileSystem.Service yield* fs.writeWithDirs( @@ -93,13 +92,12 @@ describe("tool.repo_overview", () => { expect(result.output).toContain("Top-level structure:") expect(result.output).toContain("src/") expect(result.output).toContain("README.md") - }), - ), + }), ) - it.live("resolves relative paths from the instance directory", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { + it.instance("resolves relative paths from the instance directory", () => + Effect.gen(function* () { + const dir = (yield* TestInstance).directory const fs = yield* AppFileSystem.Service yield* fs.writeWithDirs(path.join(dir, "nested", "README.md"), "# Nested\n") @@ -108,13 +106,11 @@ describe("tool.repo_overview", () => { expect(result.metadata.path).toBe(path.join(dir, "nested")) expect(result.output).toContain("README.md") - }), - ), + }), ) - it.live("resolves a cached repository from repository shorthand", () => - provideTmpdirInstance((_dir) => - Effect.gen(function* () { + it.instance("resolves a cached repository from repository shorthand", () => + Effect.gen(function* () { const fs = yield* AppFileSystem.Service const cached = path.join(Global.Path.repos, "github.com", "owner", "repo") yield* fs.writeWithDirs(path.join(cached, "package.json"), JSON.stringify({ name: "cached-repo" }, null, 2)) @@ -127,13 +123,11 @@ describe("tool.repo_overview", () => { expect(result.metadata.repository).toBe("owner/repo") expect(result.output).toContain("Repository: owner/repo") expect(result.output).toContain(`Path: ${cached}`) - }), - ), + }), ) - it.live("fails clearly when a repository is not cloned", () => - provideTmpdirInstance((_dir) => - Effect.gen(function* () { + it.instance("fails clearly when a repository is not cloned", () => + Effect.gen(function* () { const tool = yield* init() const result = yield* tool.execute({ repository: "missing/repo" }, ctx).pipe(Effect.exit) @@ -142,13 +136,11 @@ describe("tool.repo_overview", () => { const error = Cause.squash(result.cause) expect(error instanceof Error ? error.message : String(error)).toContain("Use repo_clone first") } - }), - ), + }), ) - it.live("resolves cached repositories from host/path references", () => - provideTmpdirInstance((_dir) => - Effect.gen(function* () { + it.instance("resolves cached repositories from host/path references", () => + Effect.gen(function* () { const fs = yield* AppFileSystem.Service const cached = path.join(Global.Path.repos, "gitlab.com", "group", "repo") yield* fs.writeWithDirs(path.join(cached, "README.md"), "cached\n") @@ -159,7 +151,6 @@ describe("tool.repo_overview", () => { expect(result.metadata.path).toBe(cached) expect(result.metadata.repository).toBe("gitlab.com/group/repo") expect(result.output).toContain("Repository: gitlab.com/group/repo") - }), - ), + }), ) }) diff --git a/packages/opencode/test/tool/shell.test.ts b/packages/opencode/test/tool/shell.test.ts index ddaa5c2ec7..fb8f958823 100644 --- a/packages/opencode/test/tool/shell.test.ts +++ b/packages/opencode/test/tool/shell.test.ts @@ -7,7 +7,7 @@ import { Config } from "@/config/config" import { Shell } from "../../src/shell/shell" import { ShellTool } from "../../src/tool/shell" import { Filesystem } from "@/util/filesystem" -import { provideInstance, tmpdirScoped } from "../fixture/fixture" +import { provideInstance, testInstanceStoreLayer, tmpdirScoped } from "../fixture/fixture" import type { Permission } from "../../src/permission" import { Agent } from "../../src/agent/agent" import { Truncate } from "@/tool/truncate" @@ -18,6 +18,7 @@ import { Plugin } from "../../src/plugin" import { testEffect } from "../lib/effect" import { Tool } from "@/tool/tool" import { RuntimeFlags } from "@/effect/runtime-flags" +import { InstanceStore } from "@/project/instance-store" const shellLayer = Layer.mergeAll( CrossSpawnSpawner.defaultLayer, @@ -27,10 +28,12 @@ const shellLayer = Layer.mergeAll( Config.defaultLayer, Agent.defaultLayer, RuntimeFlags.defaultLayer, + testInstanceStoreLayer, ) const it = testEffect(shellLayer) type ShellTestServices = | (typeof shellLayer extends Layer.Layer ? ROut : never) + | InstanceStore.Service | Scope.Scope const initShell = Effect.fn("ShellToolTest.init")(function* () { diff --git a/packages/opencode/test/tool/skill.test.ts b/packages/opencode/test/tool/skill.test.ts index 6732b42bbe..f96730094f 100644 --- a/packages/opencode/test/tool/skill.test.ts +++ b/packages/opencode/test/tool/skill.test.ts @@ -7,7 +7,7 @@ import type { Permission } from "../../src/permission" import type { Tool } from "@/tool/tool" import { SkillTool } from "../../src/tool/skill" import { ToolRegistry } from "@/tool/registry" -import { disposeAllInstances, provideTmpdirInstance } from "../fixture/fixture" +import { disposeAllInstances, TestInstance } from "../fixture/fixture" import { SessionID, MessageID } from "../../src/session/schema" import { testEffect } from "../lib/effect" @@ -30,9 +30,9 @@ const node = CrossSpawnSpawner.defaultLayer const it = testEffect(Layer.mergeAll(ToolRegistry.defaultLayer, node)) describe("tool.skill", () => { - it.live("execute returns skill content block with files", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { + it.instance("execute returns skill content block with files", () => + Effect.gen(function* () { + const dir = (yield* TestInstance).directory const skill = path.join(dir, ".opencode", "skill", "tool-skill") yield* Effect.promise(() => Bun.write( @@ -87,13 +87,12 @@ Use this skill. expect(result.output).toContain(``) expect(result.output).toContain(`Base directory for this skill: ${pathToFileURL(skill).href}`) expect(result.output).toContain(`${file}`) - }), - ), + }), ) - it.live("execute preserves not found message", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { + it.instance("execute preserves not found message", () => + Effect.gen(function* () { + const dir = (yield* TestInstance).directory const home = process.env.OPENCODE_TEST_HOME process.env.OPENCODE_TEST_HOME = dir yield* Effect.addFinalizer(() => @@ -127,7 +126,6 @@ Use this skill. expect(error).toBeInstanceOf(Error) if (error instanceof Error) expect(error.message).toContain('Skill "missing-skill" not found.') } - }), - ), + }), ) }) diff --git a/packages/opencode/test/tool/task.test.ts b/packages/opencode/test/tool/task.test.ts index 17e7fbea61..58391e0e3f 100644 --- a/packages/opencode/test/tool/task.test.ts +++ b/packages/opencode/test/tool/task.test.ts @@ -1,8 +1,10 @@ import { afterEach, describe, expect } from "bun:test" +import { SessionLegacy } from "@opencode-ai/core/session/legacy" +import { Database } from "@opencode-ai/core/database/database" import { Effect, Exit, Fiber, Layer } from "effect" import { Agent } from "../../src/agent/agent" import { BackgroundJob } from "@/background/job" -import { Bus } from "@/bus" +import { EventV2Bridge } from "@/event-v2-bridge" import { Config } from "@/config/config" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Session } from "@/session/session" @@ -11,28 +13,29 @@ import type { SessionPrompt } from "../../src/session/prompt" import { MessageID, PartID, SessionID } from "../../src/session/schema" import { SessionRunState } from "@/session/run-state" import { SessionStatus } from "@/session/status" -import { ModelID, ProviderID } from "../../src/provider/schema" + import { TaskTool, type TaskPromptOps } from "../../src/tool/task" import { Truncate } from "@/tool/truncate" import { ToolRegistry } from "@/tool/registry" import { RuntimeFlags } from "@/effect/runtime-flags" import { disposeAllInstances } from "../fixture/fixture" import { testEffect } from "../lib/effect" +import { ProviderV2 } from "@opencode-ai/core/provider" afterEach(async () => { await disposeAllInstances() }) const ref = { - providerID: ProviderID.make("test"), - modelID: ModelID.make("test-model"), + providerID: ProviderV2.ID.make("test"), + modelID: ProviderV2.ModelID.make("test-model"), } const layer = (flags: Partial = {}) => Layer.mergeAll( Agent.defaultLayer, BackgroundJob.defaultLayer, - Bus.defaultLayer, + EventV2Bridge.defaultLayer, Config.defaultLayer, CrossSpawnSpawner.defaultLayer, Session.defaultLayer, @@ -40,6 +43,7 @@ const layer = (flags: Partial = {}) => SessionStatus.defaultLayer, Truncate.defaultLayer, ToolRegistry.defaultLayer, + Database.defaultLayer, RuntimeFlags.layer(flags), ) @@ -65,7 +69,7 @@ const seed = Effect.fn("TaskToolTest.seed")(function* (title = "Pinned") { model: ref, time: { created: Date.now() }, }) - const assistant: MessageV2.Assistant = { + const assistant: SessionLegacy.Assistant = { id: MessageID.ascending(), role: "assistant", parentID: user.id, @@ -95,7 +99,7 @@ function stubOps(opts?: { onPrompt?: (input: SessionPrompt.PromptInput) => void; } } -function reply(input: SessionPrompt.PromptInput, text: string): MessageV2.WithParts { +function reply(input: SessionPrompt.PromptInput, text: string): SessionLegacy.WithParts { const id = MessageID.ascending() return { info: { diff --git a/packages/opencode/test/tool/websearch.test.ts b/packages/opencode/test/tool/websearch.test.ts index b8edc2dc2f..349606dec7 100644 --- a/packages/opencode/test/tool/websearch.test.ts +++ b/packages/opencode/test/tool/websearch.test.ts @@ -2,9 +2,10 @@ import { describe, expect, test } from "bun:test" import { Effect } from "effect" import { parseResponse } from "../../src/tool/mcp-websearch" import { selectWebSearchProvider, webSearchModelName, webSearchProviderLabel } from "../../src/tool/websearch" -import { ProviderID } from "../../src/provider/schema" + import { webSearchEnabled } from "../../src/tool/registry" import { it } from "../lib/effect" +import { ProviderV2 } from "@opencode-ai/core/provider" const SESSION_ID = "ses_0196aabbccddeeff001122334455" @@ -37,10 +38,10 @@ describe("websearch provider", () => { }) test("is only enabled for opencode or explicit websearch provider flags", () => { - expect(webSearchEnabled(ProviderID.opencode, { exa: false, parallel: false })).toBe(true) - expect(webSearchEnabled(ProviderID.openai, { exa: false, parallel: false })).toBe(false) - expect(webSearchEnabled(ProviderID.openai, { exa: true, parallel: false })).toBe(true) - expect(webSearchEnabled(ProviderID.openai, { exa: false, parallel: true })).toBe(true) + expect(webSearchEnabled(ProviderV2.ID.opencode, { exa: false, parallel: false })).toBe(true) + expect(webSearchEnabled(ProviderV2.ID.openai, { exa: false, parallel: false })).toBe(false) + expect(webSearchEnabled(ProviderV2.ID.openai, { exa: true, parallel: false })).toBe(true) + expect(webSearchEnabled(ProviderV2.ID.openai, { exa: false, parallel: true })).toBe(true) }) test("uses branded labels", () => { diff --git a/packages/opencode/test/tool/write.test.ts b/packages/opencode/test/tool/write.test.ts index 08f156092b..6cc72f3833 100644 --- a/packages/opencode/test/tool/write.test.ts +++ b/packages/opencode/test/tool/write.test.ts @@ -5,7 +5,7 @@ import fs from "fs/promises" import { WriteTool } from "../../src/tool/write" import { LSP } from "@/lsp/lsp" import { AppFileSystem } from "@opencode-ai/core/filesystem" -import { Bus } from "../../src/bus" +import { EventV2Bridge } from "../../src/event-v2-bridge" import { Format } from "../../src/format" import { Truncate } from "@/tool/truncate" import { Tool } from "@/tool/tool" @@ -34,7 +34,7 @@ const it = testEffect( Layer.mergeAll( LSP.defaultLayer, AppFileSystem.defaultLayer, - Bus.layer, + EventV2Bridge.defaultLayer, Format.defaultLayer, CrossSpawnSpawner.defaultLayer, Truncate.defaultLayer, diff --git a/packages/opencode/test/v2/session-message-updater.test.ts b/packages/opencode/test/v2/session-message-updater.test.ts index 588521281c..a8d69c7bef 100644 --- a/packages/opencode/test/v2/session-message-updater.test.ts +++ b/packages/opencode/test/v2/session-message-updater.test.ts @@ -1,17 +1,18 @@ import { expect, test } from "bun:test" +import { Effect } from "effect" import * as DateTime from "effect/DateTime" import { SessionID } from "../../src/session/schema" import { EventV2 } from "@opencode-ai/core/event" import { ModelV2 } from "@opencode-ai/core/model" import { ProviderV2 } from "@opencode-ai/core/provider" -import { SessionEvent } from "@opencode-ai/core/session-event" -import { SessionMessageUpdater } from "@opencode-ai/core/session-message-updater" +import { SessionEvent } from "@opencode-ai/core/session/event" +import { SessionMessageUpdater } from "@opencode-ai/core/session/message-updater" -test("step snapshots carry over to assistant messages", () => { +test.skip("step snapshots carry over to assistant messages", () => { const state: SessionMessageUpdater.MemoryState = { messages: [] } const sessionID = SessionID.make("session") - SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { + Effect.runSync(SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { id: EventV2.ID.create(), type: "session.next.step.started", data: { @@ -25,9 +26,9 @@ test("step snapshots carry over to assistant messages", () => { }, snapshot: "before", }, - } satisfies SessionEvent.Event) + } satisfies SessionEvent.Event)) - SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { + Effect.runSync(SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { id: EventV2.ID.create(), type: "session.next.step.ended", data: { @@ -43,7 +44,7 @@ test("step snapshots carry over to assistant messages", () => { }, snapshot: "after", }, - } satisfies SessionEvent.Event) + } satisfies SessionEvent.Event)) expect(state.messages[0]?.type).toBe("assistant") if (state.messages[0]?.type !== "assistant") return @@ -51,11 +52,11 @@ test("step snapshots carry over to assistant messages", () => { expect(state.messages[0].finish).toBe("stop") }) -test("text ended populates assistant text content", () => { +test.skip("text ended populates assistant text content", () => { const state: SessionMessageUpdater.MemoryState = { messages: [] } const sessionID = SessionID.make("session") - SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { + Effect.runSync(SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { id: EventV2.ID.create(), type: "session.next.step.started", data: { @@ -68,18 +69,18 @@ test("text ended populates assistant text content", () => { variant: ModelV2.VariantID.make("default"), }, }, - } satisfies SessionEvent.Event) + } satisfies SessionEvent.Event)) - SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { + Effect.runSync(SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { id: EventV2.ID.create(), type: "session.next.text.started", data: { sessionID, timestamp: DateTime.makeUnsafe(2), }, - } satisfies SessionEvent.Event) + } satisfies SessionEvent.Event)) - SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { + Effect.runSync(SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { id: EventV2.ID.create(), type: "session.next.text.ended", data: { @@ -87,19 +88,19 @@ test("text ended populates assistant text content", () => { timestamp: DateTime.makeUnsafe(3), text: "hello assistant", }, - } satisfies SessionEvent.Event) + } 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", () => { +test.skip("tool completion stores completed timestamp", () => { const state: SessionMessageUpdater.MemoryState = { messages: [] } const sessionID = SessionID.make("session") const callID = "call" - SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { + Effect.runSync(SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { id: EventV2.ID.create(), type: "session.next.step.started", data: { @@ -112,9 +113,9 @@ test("tool completion stores completed timestamp", () => { variant: ModelV2.VariantID.make("default"), }, }, - } satisfies SessionEvent.Event) + } satisfies SessionEvent.Event)) - SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { + Effect.runSync(SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { id: EventV2.ID.create(), type: "session.next.tool.input.started", data: { @@ -123,9 +124,9 @@ test("tool completion stores completed timestamp", () => { callID, name: "bash", }, - } satisfies SessionEvent.Event) + } satisfies SessionEvent.Event)) - SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { + Effect.runSync(SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { id: EventV2.ID.create(), type: "session.next.tool.called", data: { @@ -136,9 +137,9 @@ test("tool completion stores completed timestamp", () => { input: { command: "pwd" }, provider: { executed: true, metadata: { source: "provider" } }, }, - } satisfies SessionEvent.Event) + } satisfies SessionEvent.Event)) - SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { + Effect.runSync(SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { id: EventV2.ID.create(), type: "session.next.tool.success", data: { @@ -149,7 +150,7 @@ test("tool completion stores completed timestamp", () => { content: [{ type: "text", text: "/tmp" }], provider: { executed: true, metadata: { status: "done" } }, }, - } satisfies SessionEvent.Event) + } satisfies SessionEvent.Event)) expect(state.messages[0]?.type).toBe("assistant") if (state.messages[0]?.type !== "assistant") return @@ -159,12 +160,12 @@ test("tool completion stores completed timestamp", () => { expect(state.messages[0].content[0].provider).toEqual({ executed: true, metadata: { status: "done" } }) }) -test("compaction events reduce to compaction message", () => { +test.skip("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), { + Effect.runSync(SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { id, type: "session.next.compaction.started", data: { @@ -172,9 +173,9 @@ test("compaction events reduce to compaction message", () => { timestamp: DateTime.makeUnsafe(1), reason: "auto", }, - } satisfies SessionEvent.Event) + } satisfies SessionEvent.Event)) - SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { + Effect.runSync(SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { id: EventV2.ID.create(), type: "session.next.compaction.delta", data: { @@ -182,9 +183,9 @@ test("compaction events reduce to compaction message", () => { timestamp: DateTime.makeUnsafe(2), text: "hello ", }, - } satisfies SessionEvent.Event) + } satisfies SessionEvent.Event)) - SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { + Effect.runSync(SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { id: EventV2.ID.create(), type: "session.next.compaction.delta", data: { @@ -192,9 +193,9 @@ test("compaction events reduce to compaction message", () => { timestamp: DateTime.makeUnsafe(3), text: "summary", }, - } satisfies SessionEvent.Event) + } satisfies SessionEvent.Event)) - SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { + Effect.runSync(SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { id: EventV2.ID.create(), type: "session.next.compaction.ended", data: { @@ -203,7 +204,7 @@ test("compaction events reduce to compaction message", () => { text: "final summary", include: "recent context", }, - } satisfies SessionEvent.Event) + } satisfies SessionEvent.Event)) expect(state.messages).toHaveLength(1) expect(state.messages[0]).toMatchObject({ diff --git a/specs/storage/remove-opencode-db.md b/specs/storage/remove-opencode-db.md new file mode 100644 index 0000000000..3e83446761 --- /dev/null +++ b/specs/storage/remove-opencode-db.md @@ -0,0 +1,239 @@ +# Remove `packages/opencode/src/storage/db.ts` + +## Goal + +Remove all production usages of the legacy `packages/opencode/src/storage/db.ts` module. + +This means eliminating imports from `@/storage/db` or `./storage/db`, including: + +- `Database.use(...)` +- `Database.transaction(...)` +- `Database.effect(...)` +- `Database.Client()` +- `Database.getPath()` +- `Database.TxOrDb` / `Database.Transaction` +- drizzle helpers re-exported from `@/storage/db`, such as `eq` + +This does not mean removing SQLite or Drizzle everywhere in one step. The smaller target is deleting the opencode legacy wrapper by moving call sites onto deeper modules or onto the core/effect database adapter directly. + +## Current Inventory + +Production imports from `packages/opencode/src/storage/db.ts` are concentrated in 22 source files: + +- `packages/opencode/src/account/repo.ts` +- `packages/opencode/src/cli/cmd/db.ts` +- `packages/opencode/src/cli/cmd/import.ts` +- `packages/opencode/src/cli/cmd/stats.ts` +- `packages/opencode/src/control-plane/workspace.ts` +- `packages/opencode/src/index.ts` +- `packages/opencode/src/node.ts` +- `packages/opencode/src/permission/index.ts` +- `packages/opencode/src/project/project.ts` +- `packages/opencode/src/server/projectors.ts` +- `packages/opencode/src/server/routes/instance/httpapi/handlers/sync.ts` +- `packages/opencode/src/server/shared/fence.ts` +- `packages/opencode/src/session/message-v2.ts` +- `packages/opencode/src/session/projectors.ts` +- `packages/opencode/src/session/prompt.ts` +- `packages/opencode/src/session/session.ts` +- `packages/opencode/src/session/todo.ts` +- `packages/opencode/src/share/share-next.ts` +- `packages/opencode/src/storage/db.ts` +- `packages/opencode/src/sync/index.ts` +- `packages/opencode/src/worktree/index.ts` + +There are 65 direct API/type references in those files. The references fall into the groups below. + +## Group 1: Database Runtime And Startup + +Status: Completed. Startup, the public node export, and database CLI tooling no longer import the legacy opencode database wrapper; `packages/opencode/src/storage/db.ts` has been deleted. + +Files: + +- `packages/opencode/src/storage/db.ts` +- `packages/opencode/src/index.ts` +- `packages/opencode/src/node.ts` +- `packages/opencode/src/cli/cmd/db.ts` + +Current usage: + +- `storage/db.ts` opens the singleton database, applies pragmas, exposes callback-style access, holds ambient transaction context, and queues post-commit effects. +- `index.ts` checks `Database.getPath()` to decide whether JSON migration is needed, then runs `JsonMigration.run(drizzle({ client: Database.Client().$client }), ...)`. +- `node.ts` publicly re-exports `Database` from the legacy module. +- `cli/cmd/db.ts` uses `Database.getPath()` to print the path, open a readonly Bun SQLite handle, run `sqlite3`, and vacuum. + +Why this group comes first: + +- These call sites define the seam currently used by every other group. +- Deleting `storage/db.ts` requires an explicit replacement for database path, client acquisition, migration startup, and close/finalization. + +Target shape: + +- Move database path and client startup behind the core/effect database module rather than the opencode wrapper. +- Replace `Database.Client()` with an Effect-provided database service or a narrow startup-only adapter. +- Replace the public `node.ts` re-export with either no export or a stable non-legacy database capability. +- Keep `cli/cmd/db.ts` as an admin/raw SQLite tool, but make it ask the replacement database path provider instead of importing `@/storage/db`. + +## Group 2: Sync Event Transaction Boundary + +Status: Completed. `SyncEvent` and the opencode projector boundary were removed; session/message event projection now lives in core EventV2/projector infrastructure. + +Files: + +- `packages/opencode/src/sync/index.ts` +- `packages/opencode/src/session/projectors.ts` +- `packages/opencode/src/server/projectors.ts` + +Current usage: + +- `SyncEvent.run` uses `Database.transaction(..., { behavior: "immediate" })` to allocate event sequence numbers safely. +- `SyncEvent.process` wraps projector execution, event sequence writes, event log writes, and post-commit publishing in `Database.transaction(...)`. +- `Database.effect(...)` queues publish side effects until after the transaction commits. +- Projector functions accept `Database.TxOrDb` so they can write through either a root client or the active transaction. + +Why this group is critical: + +- It depends on the most non-obvious legacy behavior: nested `Database.use` inside a transaction must see the active transaction, and `Database.effect` must not publish until commit. +- It is the central seam for session, message, permission, workspace, and server projection writes. + +Target shape: + +- Replace `Database.TxOrDb` with an explicit projector transaction type from the replacement database adapter. +- Move transaction context and after-commit behavior into an Effect-native sync event implementation. +- Preserve immediate transaction behavior for sequence allocation. +- Convert projector registration to accept the new transaction interface before converting every projector body. + +Suggested first step: + +- Create a narrow internal module for sync projection execution, then migrate `SyncEvent.project(...)` and projector type signatures to that module. Keep the implementation backed by the new database adapter until all projector users are moved. + +## Group 3: Domain Repositories Already Behind Services + +Status: Completed. These services no longer import the legacy opencode database wrapper. + +Files: + +- `packages/opencode/src/account/repo.ts` +- `packages/opencode/src/project/project.ts` +- `packages/opencode/src/control-plane/workspace.ts` +- `packages/opencode/src/share/share-next.ts` + +Current usage: + +- These modules already expose Effect services or Effect functions, but internally wrap `Database.use` with local `db(...)` helpers or `Effect.try`. +- `account/repo.ts` uses both `Database.use` and `Database.transaction` through a repository interface. +- `project/project.ts` has the largest mixed usage: Effect service methods use a local `db(...)` helper, while legacy top-level functions still call `Database.use` directly. +- `control-plane/workspace.ts` and `share/share-next.ts` have local Effect wrappers around `Database.use`. + +Why this group is tractable: + +- The public interfaces are already deeper than the database calls. +- Most callers should not need to know whether these modules use Drizzle, files, or core services internally. + +Target shape: + +- Inject the replacement database service into each Effect layer and yield Effect Drizzle queries directly. +- Replace local callback wrappers with direct Effect queries. +- Move remaining synchronous top-level helpers either behind the existing service interface or onto core modules. + +Suggested order: + +- Start with `account/repo.ts`; it has a clear repository interface and few call sites. +- Then migrate `share/share-next.ts` and `control-plane/workspace.ts` local wrappers. +- Leave `project/project.ts` for last in this group because it mixes project resolution, VCS, global bus emission, migration, and legacy top-level helpers. + +## Group 4: Session And Message Read Models + +Status: Completed. Session/message reads and projector writes have moved off the legacy opencode database wrapper. + +Files: + +- `packages/opencode/src/session/session.ts` +- `packages/opencode/src/session/message-v2.ts` +- `packages/opencode/src/session/prompt.ts` +- `packages/opencode/src/session/todo.ts` +- `packages/opencode/src/session/projectors.ts` + +Current usage: + +- `session/session.ts` uses `Database.use` for session reads, list queries, children, part lookup, and global list helpers. +- `session/message-v2.ts` uses `Database.use` to page messages, hydrate parts, fetch one message, and fetch parts. +- `session/prompt.ts` imports `eq` from `@/storage/db` and reads current prompt-related session/message rows directly. +- `session/todo.ts` uses `Database.transaction` for todo replacement and `Database.use` for list reads. +- `session/projectors.ts` uses `TxOrDb` for session/message usage projection helpers. + +Why this group should be split: + +- Reads can move independently from projector writes. +- Message hydration is used by model prompt construction and session APIs, so changing it without a stable read module would spread query details across callers. +- Projector writes are tied to Group 2's transaction type. + +Target shape: + +- Create or use a session/message read module with Effect-native methods for `get`, `list`, `page`, `parts`, and prompt assembly reads. +- Move todo persistence either into a session todo repository or into the sync event projection path. +- Convert `session/projectors.ts` only after Group 2 defines the replacement projector transaction type. + +Suggested order: + +- Migrate `session/message-v2.ts` reads first because the module already centralizes message pagination and hydration. +- Migrate `session/session.ts` read helpers next. +- Migrate `session/prompt.ts` after message/session reads exist, and import drizzle operators from `drizzle-orm` if any direct SQL remains temporarily. +- Migrate `session/todo.ts` writes with the sync transaction work or move them behind a repository. + +## Group 5: Legacy CLI And One-Off Admin Reads + +Status: Completed. Remaining one-off CLI/admin reads and writes now use core database services or domain services instead of the legacy opencode database wrapper. + +Files: + +- `packages/opencode/src/cli/cmd/import.ts` +- `packages/opencode/src/cli/cmd/stats.ts` +- `packages/opencode/src/server/shared/fence.ts` +- `packages/opencode/src/server/routes/instance/httpapi/handlers/sync.ts` +- `packages/opencode/src/worktree/index.ts` +- `packages/opencode/src/permission/index.ts` + +Current usage: + +- `cli/cmd/import.ts` writes imported sessions/messages/parts directly with `Database.use`. +- `cli/cmd/stats.ts` reads all sessions directly. +- `server/shared/fence.ts` queries sessions for fence context. +- `handlers/sync.ts` reads event rows for HTTP sync endpoints. +- `worktree/index.ts` looks up a project row for worktree behavior. +- `permission/index.ts` reads permission rows directly. + +Why this group is mostly cleanup: + +- Most usages are small and can either call an existing domain service or be given a narrow query function. +- They are not defining shared transaction semantics. + +Target shape: + +- Replace direct database reads with existing services where possible. +- For admin/import commands, prefer dedicated import/stat modules rather than direct database access from command handlers. +- For HTTP sync reads, move the event log query behind the sync event module. +- For permission and worktree reads, call the permission/project services if available; otherwise add narrow repository methods. + +## Recommended Migration Sequence + +All migration groups are complete or superseded. `packages/opencode/src/storage/db.ts` has been deleted. + +## Superseded: Data Migrations + +Status: Superseded. No opencode data-migration group remains. + +The previous opencode `data-migration.ts` service only backfilled session usage from message rows. That work is now covered by core database migration `packages/core/src/database/migration/20260510033149_session_usage.ts`, so there is no separate opencode data-migration group. + +## Invariants To Preserve + +- Nested reads inside a transaction must use the active transaction, not the root client. +- `SyncEvent.run` sequence allocation must keep immediate transaction behavior. +- Post-commit publish effects must not run before the transaction commits. +- Existing schema ownership remains in `packages/core/src/**/*.sql.ts`; do not move table definitions back into `packages/opencode`. + +## Verification Commands + +- `rg "@/storage/db|./storage/db|Database\.(use|transaction|effect|Client|getPath)|\bTxOrDb\b|\bTransaction\b" packages/opencode/src` +- `bun typecheck` from `packages/opencode` +- Relevant package tests from `packages/opencode`, not the repo root From 102c8353e002d6897bfb40330a6ac9c43601ff47 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sun, 31 May 2026 01:09:55 +0000 Subject: [PATCH 026/840] chore: generate --- .../snapshot.json | 149 +- packages/core/script/migration.ts | 13 +- packages/core/src/aisdk.ts | 4 +- packages/core/src/database/migration.gen.ts | 48 +- .../20260309230000_move_org_to_state.ts | 4 +- .../20260312043431_session_message_cursor.ts | 4 +- .../20260410174513_workspace-name.ts | 4 +- .../20260427172553_slow_nightmare.ts | 4 +- packages/core/src/database/sqlite.bun.ts | 17 +- packages/core/src/database/sqlite.node.ts | 17 +- packages/core/src/event.ts | 3 +- packages/core/src/session/message-updater.ts | 696 +- packages/core/test/catalog.test.ts | 5 +- packages/core/test/database-migration.test.ts | 16 +- packages/core/test/event.test.ts | 42 +- packages/effect-sqlite-node/src/index.ts | 4 +- packages/opencode/src/account/repo.ts | 12 +- packages/opencode/src/permission/index.ts | 7 +- packages/opencode/src/project/project.ts | 188 +- packages/opencode/src/project/vcs.ts | 8 +- packages/opencode/src/provider/auth.ts | 4 +- packages/opencode/src/provider/provider.ts | 10 +- packages/opencode/src/server/projectors.ts | 3 +- .../instance/httpapi/handlers/control.ts | 4 +- .../routes/instance/httpapi/handlers/event.ts | 10 +- .../routes/instance/httpapi/handlers/tui.ts | 3 +- packages/opencode/src/session/compaction.ts | 4 +- packages/opencode/src/session/llm.ts | 16 +- packages/opencode/src/session/message-v2.ts | 16 +- packages/opencode/src/session/prompt.ts | 10 +- packages/opencode/src/session/session.ts | 6 +- packages/opencode/src/session/summary.ts | 4 +- packages/opencode/src/share/share-next.ts | 4 +- packages/opencode/src/skill/index.ts | 6 +- packages/opencode/src/tool/registry.ts | 6 +- packages/opencode/src/worktree/index.ts | 15 +- .../opencode/test/cli/github-action.test.ts | 6 +- packages/opencode/test/format/format.test.ts | 4 +- packages/opencode/test/lsp/index.test.ts | 250 +- packages/opencode/test/lsp/lifecycle.test.ts | 12 +- .../opencode/test/permission/next.test.ts | 8 +- .../test/plugin/loader-shared.test.ts | 4 +- .../test/plugin/workspace-adapter.test.ts | 130 +- .../opencode/test/project/project.test.ts | 23 +- packages/opencode/test/project/vcs.test.ts | 8 +- .../test/project/worktree-remove.test.ts | 162 +- .../test/provider/amazon-bedrock.test.ts | 4 +- .../opencode/test/provider/provider.test.ts | 34 +- packages/opencode/test/pty/ticket.test.ts | 4 +- .../opencode/test/question/question.test.ts | 6 +- .../test/server/httpapi-event.test.ts | 1 - .../server/httpapi-workspace-routing.test.ts | 10 +- .../opencode/test/server/session-list.test.ts | 14 +- .../opencode/test/session/compaction.test.ts | 11 +- packages/opencode/test/session/llm.test.ts | 10 +- .../opencode/test/session/message-v2.test.ts | 4 +- packages/opencode/test/session/prompt.test.ts | 7 +- packages/opencode/test/session/retry.test.ts | 46 +- .../opencode/test/session/session.test.ts | 10 +- .../opencode/test/share/share-next.test.ts | 7 +- .../opencode/test/snapshot/snapshot.test.ts | 8 +- .../storage/workspace-time-migration.test.ts | 5 +- packages/opencode/test/tool/lsp.test.ts | 90 +- packages/opencode/test/tool/read.test.ts | 8 +- .../opencode/test/tool/repo_clone.test.ts | 212 +- .../opencode/test/tool/repo_overview.test.ts | 152 +- packages/opencode/test/tool/skill.test.ts | 148 +- .../test/v2/session-message-updater.test.ts | 314 +- packages/sdk/js/src/v2/gen/sdk.gen.ts | 10 +- packages/sdk/js/src/v2/gen/types.gen.ts | 4166 ++--- packages/sdk/openapi.json | 12887 +++++++++------- 71 files changed, 11252 insertions(+), 8889 deletions(-) diff --git a/packages/core/migration/20260530232709_lovely_romulus/snapshot.json b/packages/core/migration/20260530232709_lovely_romulus/snapshot.json index f171a75273..8c979997ca 100644 --- a/packages/core/migration/20260530232709_lovely_romulus/snapshot.json +++ b/packages/core/migration/20260530232709_lovely_romulus/snapshot.json @@ -2,10 +2,7 @@ "version": "7", "dialect": "sqlite", "id": "bf93c73b-5a48-4d63-9909-3c36a79b9788", - "prevIds": [ - "be5eae31-b7f8-4292-8827-c36a524abd1b", - "fdfcccee-fb3a-481f-b801-b9835fa30d5d" - ], + "prevIds": ["be5eae31-b7f8-4292-8827-c36a524abd1b", "fdfcccee-fb3a-481f-b801-b9835fa30d5d"], "ddl": [ { "name": "workspace", @@ -1188,13 +1185,9 @@ "table": "session_share" }, { - "columns": [ - "project_id" - ], + "columns": ["project_id"], "tableTo": "project", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1203,13 +1196,9 @@ "table": "workspace" }, { - "columns": [ - "active_account_id" - ], + "columns": ["active_account_id"], "tableTo": "account", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "SET NULL", "nameExplicit": false, @@ -1218,13 +1207,9 @@ "table": "account_state" }, { - "columns": [ - "aggregate_id" - ], + "columns": ["aggregate_id"], "tableTo": "event_sequence", - "columnsTo": [ - "aggregate_id" - ], + "columnsTo": ["aggregate_id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1233,13 +1218,9 @@ "table": "event" }, { - "columns": [ - "session_id" - ], + "columns": ["session_id"], "tableTo": "session", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1248,13 +1229,9 @@ "table": "message" }, { - "columns": [ - "message_id" - ], + "columns": ["message_id"], "tableTo": "message", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1263,13 +1240,9 @@ "table": "part" }, { - "columns": [ - "project_id" - ], + "columns": ["project_id"], "tableTo": "project", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1278,13 +1251,9 @@ "table": "permission" }, { - "columns": [ - "session_id" - ], + "columns": ["session_id"], "tableTo": "session", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1293,13 +1262,9 @@ "table": "session_message" }, { - "columns": [ - "project_id" - ], + "columns": ["project_id"], "tableTo": "project", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1308,13 +1273,9 @@ "table": "session" }, { - "columns": [ - "session_id" - ], + "columns": ["session_id"], "tableTo": "session", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1323,13 +1284,9 @@ "table": "todo" }, { - "columns": [ - "session_id" - ], + "columns": ["session_id"], "tableTo": "session", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1338,137 +1295,105 @@ "table": "session_share" }, { - "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": "workspace_pk", "table": "workspace", "entityType": "pks" }, { - "columns": [ - "name" - ], + "columns": ["name"], "nameExplicit": false, "name": "data_migration_pk", "table": "data_migration", "entityType": "pks" }, { - "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": [ - "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", "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", @@ -1632,4 +1557,4 @@ } ], "renames": [] -} \ No newline at end of file +} diff --git a/packages/core/script/migration.ts b/packages/core/script/migration.ts index 8c5f1f6cc6..a74d39f4ed 100644 --- a/packages/core/script/migration.ts +++ b/packages/core/script/migration.ts @@ -25,7 +25,10 @@ const sqlMigrations = (await Array.fromAsync(new Bun.Glob("*/migration.sql").sca for (const name of sqlMigrations) { if (await Bun.file(path.join(tsDir, `${name}.ts`)).exists()) continue - await Bun.write(path.join(tsDir, `${name}.ts`), renderMigration(name, await Bun.file(path.join(sqlDir, name, "migration.sql")).text())) + await Bun.write( + path.join(tsDir, `${name}.ts`), + renderMigration(name, await Bun.file(path.join(sqlDir, name, "migration.sql")).text()), + ) } await Bun.write(registry, renderRegistry(sqlMigrations)) @@ -47,7 +50,9 @@ export default { ...config, out: ${JSON.stringify(output)} } await $`bun drizzle-kit generate --config ${config}`.cwd(path.join(root, "packages/core")) const after = await snapshot(output) if (JSON.stringify(after) !== JSON.stringify(before)) { - throw new Error("Core schema has ungenerated database migrations. Run `bun script/migration.ts` from packages/core.") + throw new Error( + "Core schema has ungenerated database migrations. Run `bun script/migration.ts` from packages/core.", + ) } const migrations = before @@ -56,7 +61,9 @@ export default { ...config, out: ${JSON.stringify(output)} } .sort() for (const name of migrations) { if (await Bun.file(path.join(tsDir, `${name}.ts`)).exists()) continue - throw new Error(`Database migration TypeScript wrapper is missing for ${name}. Run \`bun script/migration.ts\` from packages/core.`) + throw new Error( + `Database migration TypeScript wrapper is missing for ${name}. Run \`bun script/migration.ts\` from packages/core.`, + ) } if ((await Bun.file(registry).text()) !== renderRegistry(migrations)) { throw new Error("Database migration registry is stale. Run `bun script/migration.ts` from packages/core.") diff --git a/packages/core/src/aisdk.ts b/packages/core/src/aisdk.ts index 4560f8a2ce..f3d39f3ac0 100644 --- a/packages/core/src/aisdk.ts +++ b/packages/core/src/aisdk.ts @@ -170,6 +170,4 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer.pipe( - Layer.provide(PluginV2.locationLayer.pipe(Layer.provide(EventV2.defaultLayer))), -) +export const defaultLayer = layer.pipe(Layer.provide(PluginV2.locationLayer.pipe(Layer.provide(EventV2.defaultLayer)))) diff --git a/packages/core/src/database/migration.gen.ts b/packages/core/src/database/migration.gen.ts index 4447c60088..1a6918b33b 100644 --- a/packages/core/src/database/migration.gen.ts +++ b/packages/core/src/database/migration.gen.ts @@ -1,25 +1,27 @@ import type { DatabaseMigration } from "./migration" -export const migrations = (await Promise.all([ - import("./migration/20260127222353_familiar_lady_ursula"), - import("./migration/20260211171708_add_project_commands"), - import("./migration/20260213144116_wakeful_the_professor"), - import("./migration/20260225215848_workspace"), - import("./migration/20260227213759_add_session_workspace_id"), - import("./migration/20260228203230_blue_harpoon"), - import("./migration/20260303231226_add_workspace_fields"), - import("./migration/20260309230000_move_org_to_state"), - import("./migration/20260312043431_session_message_cursor"), - import("./migration/20260323234822_events"), - import("./migration/20260410174513_workspace-name"), - import("./migration/20260413175956_chief_energizer"), - import("./migration/20260423070820_add_icon_url_override"), - import("./migration/20260427172553_slow_nightmare"), - import("./migration/20260428004200_add_session_path"), - import("./migration/20260501142318_next_venus"), - import("./migration/20260504145000_add_sync_owner"), - import("./migration/20260507164347_add_workspace_time"), - import("./migration/20260510033149_session_usage"), - import("./migration/20260511000411_data_migration_state"), - import("./migration/20260530232709_lovely_romulus"), -])).map((module) => module.default) satisfies DatabaseMigration.Migration[] +export const migrations = ( + await Promise.all([ + import("./migration/20260127222353_familiar_lady_ursula"), + import("./migration/20260211171708_add_project_commands"), + import("./migration/20260213144116_wakeful_the_professor"), + import("./migration/20260225215848_workspace"), + import("./migration/20260227213759_add_session_workspace_id"), + import("./migration/20260228203230_blue_harpoon"), + import("./migration/20260303231226_add_workspace_fields"), + import("./migration/20260309230000_move_org_to_state"), + import("./migration/20260312043431_session_message_cursor"), + import("./migration/20260323234822_events"), + import("./migration/20260410174513_workspace-name"), + import("./migration/20260413175956_chief_energizer"), + import("./migration/20260423070820_add_icon_url_override"), + import("./migration/20260427172553_slow_nightmare"), + import("./migration/20260428004200_add_session_path"), + import("./migration/20260501142318_next_venus"), + import("./migration/20260504145000_add_sync_owner"), + import("./migration/20260507164347_add_workspace_time"), + import("./migration/20260510033149_session_usage"), + import("./migration/20260511000411_data_migration_state"), + import("./migration/20260530232709_lovely_romulus"), + ]) +).map((module) => module.default) satisfies DatabaseMigration.Migration[] diff --git a/packages/core/src/database/migration/20260309230000_move_org_to_state.ts b/packages/core/src/database/migration/20260309230000_move_org_to_state.ts index 63671a84fd..bf39f3e5bf 100644 --- a/packages/core/src/database/migration/20260309230000_move_org_to_state.ts +++ b/packages/core/src/database/migration/20260309230000_move_org_to_state.ts @@ -6,7 +6,9 @@ export default { up(tx) { return Effect.gen(function* () { yield* tx.run(`ALTER TABLE \`account_state\` ADD \`active_org_id\` text;`) - yield* tx.run(`UPDATE \`account_state\` SET \`active_org_id\` = (SELECT \`selected_org_id\` FROM \`account\` WHERE \`account\`.\`id\` = \`account_state\`.\`active_account_id\`);`) + yield* tx.run( + `UPDATE \`account_state\` SET \`active_org_id\` = (SELECT \`selected_org_id\` FROM \`account\` WHERE \`account\`.\`id\` = \`account_state\`.\`active_account_id\`);`, + ) yield* tx.run(`ALTER TABLE \`account\` DROP COLUMN \`selected_org_id\`;`) }) }, diff --git a/packages/core/src/database/migration/20260312043431_session_message_cursor.ts b/packages/core/src/database/migration/20260312043431_session_message_cursor.ts index 86e20a66d2..1603c3fa73 100644 --- a/packages/core/src/database/migration/20260312043431_session_message_cursor.ts +++ b/packages/core/src/database/migration/20260312043431_session_message_cursor.ts @@ -7,7 +7,9 @@ export default { return Effect.gen(function* () { yield* tx.run(`DROP INDEX IF EXISTS \`message_session_idx\`;`) yield* tx.run(`DROP INDEX IF EXISTS \`part_message_idx\`;`) - yield* tx.run(`CREATE INDEX \`message_session_time_created_id_idx\` ON \`message\` (\`session_id\`,\`time_created\`,\`id\`);`) + yield* tx.run( + `CREATE INDEX \`message_session_time_created_id_idx\` ON \`message\` (\`session_id\`,\`time_created\`,\`id\`);`, + ) yield* tx.run(`CREATE INDEX \`part_message_id_id_idx\` ON \`part\` (\`message_id\`,\`id\`);`) }) }, diff --git a/packages/core/src/database/migration/20260410174513_workspace-name.ts b/packages/core/src/database/migration/20260410174513_workspace-name.ts index 3b37a0bfc1..18483e1cf0 100644 --- a/packages/core/src/database/migration/20260410174513_workspace-name.ts +++ b/packages/core/src/database/migration/20260410174513_workspace-name.ts @@ -18,7 +18,9 @@ export default { CONSTRAINT \`fk_workspace_project_id_project_id_fk\` FOREIGN KEY (\`project_id\`) REFERENCES \`project\`(\`id\`) ON DELETE CASCADE ); `) - yield* tx.run(`INSERT INTO \`__new_workspace\`(\`id\`, \`type\`, \`branch\`, \`name\`, \`directory\`, \`extra\`, \`project_id\`) SELECT \`id\`, \`type\`, \`branch\`, \`name\`, \`directory\`, \`extra\`, \`project_id\` FROM \`workspace\`;`) + yield* tx.run( + `INSERT INTO \`__new_workspace\`(\`id\`, \`type\`, \`branch\`, \`name\`, \`directory\`, \`extra\`, \`project_id\`) SELECT \`id\`, \`type\`, \`branch\`, \`name\`, \`directory\`, \`extra\`, \`project_id\` FROM \`workspace\`;`, + ) yield* tx.run(`DROP TABLE \`workspace\`;`) yield* tx.run(`ALTER TABLE \`__new_workspace\` RENAME TO \`workspace\`;`) yield* tx.run(`PRAGMA foreign_keys=ON;`) diff --git a/packages/core/src/database/migration/20260427172553_slow_nightmare.ts b/packages/core/src/database/migration/20260427172553_slow_nightmare.ts index 0b0bd133a6..32e67decf3 100644 --- a/packages/core/src/database/migration/20260427172553_slow_nightmare.ts +++ b/packages/core/src/database/migration/20260427172553_slow_nightmare.ts @@ -20,7 +20,9 @@ export default { yield* tx.run(`DROP INDEX IF EXISTS \`session_entry_session_type_idx\`;`) yield* tx.run(`DROP INDEX IF EXISTS \`session_entry_time_created_idx\`;`) yield* tx.run(`CREATE INDEX \`session_message_session_idx\` ON \`session_message\` (\`session_id\`);`) - yield* tx.run(`CREATE INDEX \`session_message_session_type_idx\` ON \`session_message\` (\`session_id\`,\`type\`);`) + yield* tx.run( + `CREATE INDEX \`session_message_session_type_idx\` ON \`session_message\` (\`session_id\`,\`type\`);`, + ) yield* tx.run(`CREATE INDEX \`session_message_time_created_idx\` ON \`session_message\` (\`time_created\`);`) yield* tx.run(`DROP TABLE \`session_entry\`;`) }) diff --git a/packages/core/src/database/sqlite.bun.ts b/packages/core/src/database/sqlite.bun.ts index 02a41e07cb..5dda2cd2c6 100644 --- a/packages/core/src/database/sqlite.bun.ts +++ b/packages/core/src/database/sqlite.bun.ts @@ -49,7 +49,9 @@ const make = (options: Config) => const native = (yield* Sqlite.Native) as Database const compiler = Statement.makeCompilerSqlite(options.transformQueryNames) - const transformRows = options.transformResultNames ? Statement.defaultTransforms(options.transformResultNames).array : undefined + const transformRows = options.transformResultNames + ? Statement.defaultTransforms(options.transformResultNames).array + : undefined const run = (query: string, params: ReadonlyArray = []) => Effect.withFiber>, SqlError>((fiber) => { @@ -102,7 +104,9 @@ const make = (options: Config) => export: Effect.try({ try: () => native.serialize(), catch: (cause) => - new SqlError({ reason: classifySqliteError(cause, { message: "Failed to export database", operation: "export" }) }), + new SqlError({ + reason: classifySqliteError(cause, { message: "Failed to export database", operation: "export" }), + }), }), loadExtension: (path) => Effect.try({ @@ -119,7 +123,10 @@ const make = (options: Config) => const transactionAcquirer = Effect.uninterruptibleMask((restore) => { const fiber = Fiber.getCurrent()! const scope = Context.getUnsafe(fiber.context, Scope.Scope) - return Effect.as(Effect.tap(restore(semaphore.take(1)), () => Scope.addFinalizer(scope, semaphore.release(1))), connection) + return Effect.as( + Effect.tap(restore(semaphore.take(1)), () => Scope.addFinalizer(scope, semaphore.release(1))), + connection, + ) }) const client = Object.assign( @@ -172,6 +179,4 @@ export const layer = (config: Config) => Layer.merge( nativeLayer(config), Layer.merge(sqliteLayer(config), drizzleLayer).pipe(Layer.provide(nativeLayer(config))), - ).pipe( - Layer.provide(Reactivity.layer), - ) + ).pipe(Layer.provide(Reactivity.layer)) diff --git a/packages/core/src/database/sqlite.node.ts b/packages/core/src/database/sqlite.node.ts index cb9272adfb..d7471a4401 100644 --- a/packages/core/src/database/sqlite.node.ts +++ b/packages/core/src/database/sqlite.node.ts @@ -49,7 +49,9 @@ const make = (options: Config) => const native = (yield* Sqlite.Native) as DatabaseSync const compiler = Statement.makeCompilerSqlite(options.transformQueryNames) - const transformRows = options.transformResultNames ? Statement.defaultTransforms(options.transformResultNames).array : undefined + const transformRows = options.transformResultNames + ? Statement.defaultTransforms(options.transformResultNames).array + : undefined const run = (query: string, params: ReadonlyArray = []) => Effect.withFiber>, SqlError>((fiber) => { @@ -72,7 +74,9 @@ const make = (options: Config) => statement.setReadBigInts(Context.get(fiber.context, Client.SafeIntegers)) statement.setReturnArrays(true) try { - return Effect.succeed(statement.all(...(params as SQLInputValue[])) as unknown as ReadonlyArray>) + return Effect.succeed( + statement.all(...(params as SQLInputValue[])) as unknown as ReadonlyArray>, + ) } catch (cause) { return Effect.fail( new SqlError({ @@ -113,7 +117,10 @@ const make = (options: Config) => const transactionAcquirer = Effect.uninterruptibleMask((restore) => { const fiber = Fiber.getCurrent()! const scope = Context.getUnsafe(fiber.context, Scope.Scope) - return Effect.as(Effect.tap(restore(semaphore.take(1)), () => Scope.addFinalizer(scope, semaphore.release(1))), connection) + return Effect.as( + Effect.tap(restore(semaphore.take(1)), () => Scope.addFinalizer(scope, semaphore.release(1))), + connection, + ) }) const client = Object.assign( @@ -167,6 +174,4 @@ export const layer = (config: Config) => Layer.merge( nativeLayer(config), Layer.merge(sqliteLayer(config), drizzleLayer).pipe(Layer.provide(nativeLayer(config))), - ).pipe( - Layer.provide(Reactivity.layer), - ) + ).pipe(Layer.provide(Reactivity.layer)) diff --git a/packages/core/src/event.ts b/packages/core/src/event.ts index 105fb12dd1..0be8c64ef6 100644 --- a/packages/core/src/event.ts +++ b/packages/core/src/event.ts @@ -261,7 +261,8 @@ export const layer = Layer.effect( function publish(definition: D, data: Data, options?: PublishOptions) { return Effect.gen(function* () { const serviceLocation = Option.getOrUndefined(yield* Effect.serviceOption(Location.Service)) - const location = options?.location ?? + const location = + options?.location ?? (serviceLocation ? { directory: serviceLocation.directory, workspaceID: serviceLocation.workspaceID } : undefined) diff --git a/packages/core/src/session/message-updater.ts b/packages/core/src/session/message-updater.ts index 99fc3243c7..1c1e218805 100644 --- a/packages/core/src/session/message-updater.ts +++ b/packages/core/src/session/message-updater.ts @@ -103,371 +103,371 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { return Effect.gen(function* () { yield* SessionEvent.All.match(event, { - "session.next.agent.switched": (event) => { - return 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) => { - return adapter.appendMessage( - new SessionMessage.ModelSwitched({ - id: event.id, - type: "model-switched", - metadata: event.metadata, - model: event.data.model, - time: { created: event.data.timestamp }, - }), - ) - }, - "session.next.prompted": (event) => { - return 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, - references: event.data.prompt.references, - time: { created: event.data.timestamp }, - }), - ) - }, - "session.next.synthetic": (event) => { - return 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) => { - return 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) => { - return Effect.gen(function* () { - const currentShell = yield* adapter.getCurrentShell(event.data.callID) - if (currentShell) { - yield* adapter.updateShell( - produce(currentShell, (draft) => { - draft.output = event.data.output - draft.time.completed = event.data.timestamp + "session.next.agent.switched": (event) => { + return 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.step.started": (event) => { - return Effect.gen(function* () { - const currentAssistant = yield* adapter.getCurrentAssistant() - if (currentAssistant) { - yield* adapter.updateAssistant( - produce(currentAssistant, (draft) => { - draft.time.completed = event.data.timestamp - }), - ) - } - yield* adapter.appendMessage( - new SessionMessage.Assistant({ + }, + "session.next.model.switched": (event) => { + return adapter.appendMessage( + new SessionMessage.ModelSwitched({ id: event.id, - type: "assistant", - agent: event.data.agent, + type: "model-switched", + metadata: event.metadata, model: event.data.model, time: { created: event.data.timestamp }, - content: [], - snapshot: event.data.snapshot ? { start: event.data.snapshot } : undefined, }), ) - }) - }, - "session.next.step.ended": (event) => { - return Effect.gen(function* () { - const currentAssistant = yield* adapter.getCurrentAssistant() - if (currentAssistant) { - yield* 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.prompted": (event) => { + return 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, + references: event.data.prompt.references, + time: { created: event.data.timestamp }, }), ) - } - }) - }, - "session.next.step.failed": (event) => { - return Effect.gen(function* () { - const currentAssistant = yield* adapter.getCurrentAssistant() - if (currentAssistant) { - yield* adapter.updateAssistant( - produce(currentAssistant, (draft) => { - draft.time.completed = event.data.timestamp - draft.finish = "error" - draft.error = event.data.error + }, + "session.next.synthetic": (event) => { + return 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.text.started": () => { - return Effect.gen(function* () { - const currentAssistant = yield* adapter.getCurrentAssistant() - if (currentAssistant) { - yield* adapter.updateAssistant( - produce(currentAssistant, (draft) => { - draft.content.push(new SessionMessage.AssistantText({ type: "text", text: "" }) as DraftText) + }, + "session.next.shell.started": (event) => { + return 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.text.delta": (event) => { - return Effect.gen(function* () { - const currentAssistant = yield* adapter.getCurrentAssistant() - if (currentAssistant) { - yield* adapter.updateAssistant( - produce(currentAssistant, (draft) => { - const match = latestText(draft) - if (match) match.text += event.data.delta - }), - ) - } - }) - }, - "session.next.text.ended": (event) => { - return Effect.gen(function* () { - const currentAssistant = yield* adapter.getCurrentAssistant() - if (currentAssistant) { - yield* adapter.updateAssistant( - produce(currentAssistant, (draft) => { - const match = latestText(draft) - if (match) match.text = event.data.text - }), - ) - } - }) - }, - "session.next.tool.input.started": (event) => { - return Effect.gen(function* () { - const currentAssistant = yield* adapter.getCurrentAssistant() - if (currentAssistant) { - yield* adapter.updateAssistant( - produce(currentAssistant, (draft) => { - draft.content.push( - new SessionMessage.AssistantTool({ - type: "tool", - id: event.data.callID, - name: event.data.name, - time: { created: event.data.timestamp }, - state: new SessionMessage.ToolStatePending({ status: "pending", input: "" }), - }) as DraftTool, + }, + "session.next.shell.ended": (event) => { + return Effect.gen(function* () { + const currentShell = yield* adapter.getCurrentShell(event.data.callID) + if (currentShell) { + yield* adapter.updateShell( + produce(currentShell, (draft) => { + draft.output = event.data.output + draft.time.completed = event.data.timestamp + }), ) - }), - ) - } - }) - }, - "session.next.tool.input.delta": (event) => { - return Effect.gen(function* () { - const currentAssistant = yield* adapter.getCurrentAssistant() - if (currentAssistant) { - yield* 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": () => Effect.void, - "session.next.tool.called": (event) => { - return Effect.gen(function* () { - const currentAssistant = yield* adapter.getCurrentAssistant() - if (currentAssistant) { - yield* 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) => { - return Effect.gen(function* () { - const currentAssistant = yield* adapter.getCurrentAssistant() - if (currentAssistant) { - yield* 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) => { - return Effect.gen(function* () { - const currentAssistant = yield* adapter.getCurrentAssistant() - if (currentAssistant) { - yield* 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.failed": (event) => { - return Effect.gen(function* () { - const currentAssistant = yield* adapter.getCurrentAssistant() - if (currentAssistant) { - yield* 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) => { - return Effect.gen(function* () { - const currentAssistant = yield* adapter.getCurrentAssistant() - if (currentAssistant) { - yield* adapter.updateAssistant( - produce(currentAssistant, (draft) => { - draft.content.push( - new SessionMessage.AssistantReasoning({ - type: "reasoning", - id: event.data.reasoningID, - text: "", - }) as DraftReasoning, + } + }) + }, + "session.next.step.started": (event) => { + return Effect.gen(function* () { + const currentAssistant = yield* adapter.getCurrentAssistant() + if (currentAssistant) { + yield* adapter.updateAssistant( + produce(currentAssistant, (draft) => { + draft.time.completed = event.data.timestamp + }), ) + } + yield* 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) => { + return Effect.gen(function* () { + const currentAssistant = yield* adapter.getCurrentAssistant() + if (currentAssistant) { + yield* 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.step.failed": (event) => { + return Effect.gen(function* () { + const currentAssistant = yield* adapter.getCurrentAssistant() + if (currentAssistant) { + yield* adapter.updateAssistant( + produce(currentAssistant, (draft) => { + draft.time.completed = event.data.timestamp + draft.finish = "error" + draft.error = event.data.error + }), + ) + } + }) + }, + "session.next.text.started": () => { + return Effect.gen(function* () { + const currentAssistant = yield* adapter.getCurrentAssistant() + if (currentAssistant) { + yield* adapter.updateAssistant( + produce(currentAssistant, (draft) => { + draft.content.push(new SessionMessage.AssistantText({ type: "text", text: "" }) as DraftText) + }), + ) + } + }) + }, + "session.next.text.delta": (event) => { + return Effect.gen(function* () { + const currentAssistant = yield* adapter.getCurrentAssistant() + if (currentAssistant) { + yield* adapter.updateAssistant( + produce(currentAssistant, (draft) => { + const match = latestText(draft) + if (match) match.text += event.data.delta + }), + ) + } + }) + }, + "session.next.text.ended": (event) => { + return Effect.gen(function* () { + const currentAssistant = yield* adapter.getCurrentAssistant() + if (currentAssistant) { + yield* adapter.updateAssistant( + produce(currentAssistant, (draft) => { + const match = latestText(draft) + if (match) match.text = event.data.text + }), + ) + } + }) + }, + "session.next.tool.input.started": (event) => { + return Effect.gen(function* () { + const currentAssistant = yield* adapter.getCurrentAssistant() + if (currentAssistant) { + yield* adapter.updateAssistant( + produce(currentAssistant, (draft) => { + draft.content.push( + new SessionMessage.AssistantTool({ + type: "tool", + id: event.data.callID, + name: event.data.name, + time: { created: event.data.timestamp }, + state: new SessionMessage.ToolStatePending({ status: "pending", input: "" }), + }) as DraftTool, + ) + }), + ) + } + }) + }, + "session.next.tool.input.delta": (event) => { + return Effect.gen(function* () { + const currentAssistant = yield* adapter.getCurrentAssistant() + if (currentAssistant) { + yield* 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": () => Effect.void, + "session.next.tool.called": (event) => { + return Effect.gen(function* () { + const currentAssistant = yield* adapter.getCurrentAssistant() + if (currentAssistant) { + yield* 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) => { + return Effect.gen(function* () { + const currentAssistant = yield* adapter.getCurrentAssistant() + if (currentAssistant) { + yield* 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) => { + return Effect.gen(function* () { + const currentAssistant = yield* adapter.getCurrentAssistant() + if (currentAssistant) { + yield* 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.failed": (event) => { + return Effect.gen(function* () { + const currentAssistant = yield* adapter.getCurrentAssistant() + if (currentAssistant) { + yield* 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) => { + return Effect.gen(function* () { + const currentAssistant = yield* adapter.getCurrentAssistant() + if (currentAssistant) { + yield* adapter.updateAssistant( + produce(currentAssistant, (draft) => { + draft.content.push( + new SessionMessage.AssistantReasoning({ + type: "reasoning", + id: event.data.reasoningID, + text: "", + }) as DraftReasoning, + ) + }), + ) + } + }) + }, + "session.next.reasoning.delta": (event) => { + return Effect.gen(function* () { + const currentAssistant = yield* adapter.getCurrentAssistant() + if (currentAssistant) { + yield* adapter.updateAssistant( + produce(currentAssistant, (draft) => { + const match = latestReasoning(draft, event.data.reasoningID) + if (match) match.text += event.data.delta + }), + ) + } + }) + }, + "session.next.reasoning.ended": (event) => { + return Effect.gen(function* () { + const currentAssistant = yield* adapter.getCurrentAssistant() + if (currentAssistant) { + yield* adapter.updateAssistant( + produce(currentAssistant, (draft) => { + const match = latestReasoning(draft, event.data.reasoningID) + if (match) match.text = event.data.text + }), + ) + } + }) + }, + "session.next.retried": () => Effect.void, + "session.next.compaction.started": (event) => { + return 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.reasoning.delta": (event) => { - return Effect.gen(function* () { - const currentAssistant = yield* adapter.getCurrentAssistant() - if (currentAssistant) { - yield* adapter.updateAssistant( - produce(currentAssistant, (draft) => { - const match = latestReasoning(draft, event.data.reasoningID) - if (match) match.text += event.data.delta - }), - ) - } - }) - }, - "session.next.reasoning.ended": (event) => { - return Effect.gen(function* () { - const currentAssistant = yield* adapter.getCurrentAssistant() - if (currentAssistant) { - yield* adapter.updateAssistant( - produce(currentAssistant, (draft) => { - const match = latestReasoning(draft, event.data.reasoningID) - if (match) match.text = event.data.text - }), - ) - } - }) - }, - "session.next.retried": () => Effect.void, - "session.next.compaction.started": (event) => { - return 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) => { - return Effect.gen(function* () { - const currentCompaction = yield* adapter.getCurrentCompaction() - if (currentCompaction) { - yield* adapter.updateCompaction( - produce(currentCompaction, (draft) => { - draft.summary += event.data.text - }), - ) - } - }) - }, - "session.next.compaction.ended": (event) => { - return Effect.gen(function* () { - const currentCompaction = yield* adapter.getCurrentCompaction() - if (currentCompaction) { - yield* adapter.updateCompaction( - produce(currentCompaction, (draft) => { - draft.summary = event.data.text - draft.include = event.data.include - }), - ) - } - }) - }, - }) + }, + "session.next.compaction.delta": (event) => { + return Effect.gen(function* () { + const currentCompaction = yield* adapter.getCurrentCompaction() + if (currentCompaction) { + yield* adapter.updateCompaction( + produce(currentCompaction, (draft) => { + draft.summary += event.data.text + }), + ) + } + }) + }, + "session.next.compaction.ended": (event) => { + return Effect.gen(function* () { + const currentCompaction = yield* adapter.getCurrentCompaction() + if (currentCompaction) { + yield* adapter.updateCompaction( + produce(currentCompaction, (draft) => { + draft.summary = event.data.text + draft.include = event.data.include + }), + ) + } + }) + }, + }) }) } diff --git a/packages/core/test/catalog.test.ts b/packages/core/test/catalog.test.ts index d2314adc61..121b1fe012 100644 --- a/packages/core/test/catalog.test.ts +++ b/packages/core/test/catalog.test.ts @@ -16,10 +16,7 @@ const locationLayer = Layer.succeed( Location.Service.of(location({ directory: AbsolutePath.make("test") })), ) const it = testEffect( - Catalog.locationLayer.pipe( - Layer.provideMerge(EventV2.defaultLayer), - Layer.provideMerge(locationLayer), - ), + Catalog.locationLayer.pipe(Layer.provideMerge(EventV2.defaultLayer), Layer.provideMerge(locationLayer)), ) describe("CatalogV2", () => { diff --git a/packages/core/test/database-migration.test.ts b/packages/core/test/database-migration.test.ts index e0e1b3f689..5b0b08c962 100644 --- a/packages/core/test/database-migration.test.ts +++ b/packages/core/test/database-migration.test.ts @@ -10,14 +10,18 @@ import sessionUsageMigration from "@opencode-ai/core/database/migration/20260510 import type { SqlClient as SqlClientService } from "effect/unstable/sql/SqlClient" const run = (effect: Effect.Effect) => - Effect.runPromise(effect.pipe(Effect.provide(SqliteClient.layer({ filename: ":memory:", disableWAL: true })), Effect.scoped)) + Effect.runPromise( + effect.pipe(Effect.provide(SqliteClient.layer({ filename: ":memory:", disableWAL: true })), Effect.scoped), + ) const makeDb = EffectDrizzleSqlite.makeWithDefaults() describe("DatabaseMigration", () => { if (process.platform === "linux") { test("declared schema has no ungenerated migrations", async () => { - const result = await $`bun ${fileURLToPath(new URL("../script/migration.ts", import.meta.url))} --check`.quiet().nothrow() + const result = await $`bun ${fileURLToPath(new URL("../script/migration.ts", import.meta.url))} --check` + .quiet() + .nothrow() expect(result.exitCode, result.stderr.toString()).toBe(0) expect(result.stdout.toString()).toContain("No schema changes, nothing to migrate") }, 30_000) @@ -70,7 +74,9 @@ describe("DatabaseMigration", () => { await run( Effect.gen(function* () { const db = yield* makeDb - yield* db.run(sql`CREATE TABLE __drizzle_migrations (id INTEGER PRIMARY KEY, hash text NOT NULL, created_at numeric, name text, applied_at TEXT)`) + yield* db.run( + sql`CREATE TABLE __drizzle_migrations (id INTEGER PRIMARY KEY, hash text NOT NULL, created_at numeric, name text, applied_at TEXT)`, + ) yield* db.run(sql` INSERT INTO __drizzle_migrations (hash, created_at, name, applied_at) VALUES ('hash', 1, '20260127222353_familiar_lady_ursula', ${new Date().toISOString()}) @@ -89,7 +95,9 @@ describe("DatabaseMigration", () => { const db = yield* makeDb yield* db.run(sql`CREATE TABLE migration (id TEXT PRIMARY KEY, time_completed INTEGER NOT NULL)`) yield* db.run(sql`INSERT INTO migration (id, time_completed) VALUES ('existing', 1)`) - yield* db.run(sql`CREATE TABLE __drizzle_migrations (id INTEGER PRIMARY KEY, hash text NOT NULL, created_at numeric, name text, applied_at TEXT)`) + yield* db.run( + sql`CREATE TABLE __drizzle_migrations (id INTEGER PRIMARY KEY, hash text NOT NULL, created_at numeric, name text, applied_at TEXT)`, + ) yield* db.run(sql` INSERT INTO __drizzle_migrations (hash, created_at, name, applied_at) VALUES ('hash', 1, '20260127222353_familiar_lady_ursula', ${new Date().toISOString()}) diff --git a/packages/core/test/event.test.ts b/packages/core/test/event.test.ts index 53d2330899..c3e5d2d75a 100644 --- a/packages/core/test/event.test.ts +++ b/packages/core/test/event.test.ts @@ -211,7 +211,12 @@ describe("EventV2", () => { const aggregateID = EventV2.ID.create() yield* events.publish(SyncMessage, { id: aggregateID, text: "first" }) - const rows = yield* db.select().from(EventTable).where(eq(EventTable.aggregate_id, aggregateID)).all().pipe(Effect.orDie) + const rows = yield* db + .select() + .from(EventTable) + .where(eq(EventTable.aggregate_id, aggregateID)) + .all() + .pipe(Effect.orDie) expect(rows).toHaveLength(1) expect(rows[0]?.type).toBe(EventV2.versionedType(SyncMessage.type, 1)) @@ -227,7 +232,12 @@ describe("EventV2", () => { yield* events.publish(SyncMessage, { id: aggregateID, text: "first" }) yield* events.publish(SyncMessage, { id: aggregateID, text: "second" }) - const rows = yield* db.select().from(EventTable).where(eq(EventTable.aggregate_id, aggregateID)).all().pipe(Effect.orDie) + const rows = yield* db + .select() + .from(EventTable) + .where(eq(EventTable.aggregate_id, aggregateID)) + .all() + .pipe(Effect.orDie) expect(rows.map((row) => row.seq)).toEqual([0, 1]) }), @@ -240,7 +250,12 @@ describe("EventV2", () => { const aggregateID = EventV2.ID.create() yield* events.publish(SyncSent, { messageID: aggregateID, text: "sent" }) - const rows = yield* db.select().from(EventTable).where(eq(EventTable.aggregate_id, aggregateID)).all().pipe(Effect.orDie) + const rows = yield* db + .select() + .from(EventTable) + .where(eq(EventTable.aggregate_id, aggregateID)) + .all() + .pipe(Effect.orDie) expect(rows).toHaveLength(1) expect(rows[0]?.aggregate_id).toBe(aggregateID) @@ -284,7 +299,12 @@ describe("EventV2", () => { aggregateID, data: { id: aggregateID, text: "replayed" }, }) - const rows = yield* db.select().from(EventTable).where(eq(EventTable.aggregate_id, aggregateID)).all().pipe(Effect.orDie) + const rows = yield* db + .select() + .from(EventTable) + .where(eq(EventTable.aggregate_id, aggregateID)) + .all() + .pipe(Effect.orDie) expect(rows).toHaveLength(1) expect(rows[0]?.aggregate_id).toBe(aggregateID) @@ -397,7 +417,12 @@ describe("EventV2", () => { data: { id: aggregateID, text: "four" }, }, ]) - const rows = yield* db.select().from(EventTable).where(eq(EventTable.aggregate_id, aggregateID)).all().pipe(Effect.orDie) + const rows = yield* db + .select() + .from(EventTable) + .where(eq(EventTable.aggregate_id, aggregateID)) + .all() + .pipe(Effect.orDie) expect(one).toBe(aggregateID) expect(two).toBe(aggregateID) @@ -486,7 +511,12 @@ describe("EventV2", () => { }, { ownerID: "owner-2" }, ) - const rows = yield* db.select().from(EventTable).where(eq(EventTable.aggregate_id, aggregateID)).all().pipe(Effect.orDie) + const rows = yield* db + .select() + .from(EventTable) + .where(eq(EventTable.aggregate_id, aggregateID)) + .all() + .pipe(Effect.orDie) const sequence = yield* db .select({ seq: EventSequenceTable.seq, ownerID: EventSequenceTable.owner_id }) .from(EventSequenceTable) diff --git a/packages/effect-sqlite-node/src/index.ts b/packages/effect-sqlite-node/src/index.ts index 8720d88cf0..37e255391d 100644 --- a/packages/effect-sqlite-node/src/index.ts +++ b/packages/effect-sqlite-node/src/index.ts @@ -162,5 +162,7 @@ export const make = ( export const layer = (config: SqliteClientConfig): Layer.Layer => Layer.effectContext( - Effect.map(make(config), (client) => Context.make(SqliteClient, client).pipe(Context.add(Client.SqlClient, client))), + Effect.map(make(config), (client) => + Context.make(SqliteClient, client).pipe(Context.add(Client.SqlClient, client)), + ), ).pipe(Layer.provide(Reactivity.layer)) diff --git a/packages/opencode/src/account/repo.ts b/packages/opencode/src/account/repo.ts index c42b7d5247..8b091d4fff 100644 --- a/packages/opencode/src/account/repo.ts +++ b/packages/opencode/src/account/repo.ts @@ -48,17 +48,9 @@ export const layer = Layer.effect( effect.pipe(Effect.mapError((cause) => new AccountRepoError({ message: "Database operation failed", cause }))) const current = Effect.fnUntraced(function* () { - const state = yield* db - .select() - .from(AccountStateTable) - .where(eq(AccountStateTable.id, ACCOUNT_STATE_ID)) - .get() + const state = yield* db.select().from(AccountStateTable).where(eq(AccountStateTable.id, ACCOUNT_STATE_ID)).get() if (!state?.active_account_id) return - const account = yield* db - .select() - .from(AccountTable) - .where(eq(AccountTable.id, state.active_account_id)) - .get() + const account = yield* db.select().from(AccountTable).where(eq(AccountTable.id, state.active_account_id)).get() if (!account) return return { ...account, active_org_id: state.active_org_id ?? null } }) diff --git a/packages/opencode/src/permission/index.ts b/packages/opencode/src/permission/index.ts index 35546db84d..220abc8348 100644 --- a/packages/opencode/src/permission/index.ts +++ b/packages/opencode/src/permission/index.ts @@ -148,7 +148,12 @@ export const layer = Layer.effect( const { db } = yield* Database.Service const state = yield* InstanceState.make( Effect.fn("Permission.state")(function* (ctx) { - const row = yield* db.select().from(PermissionTable).where(eq(PermissionTable.project_id, ctx.project.id)).get().pipe(Effect.orDie) + const row = yield* db + .select() + .from(PermissionTable) + .where(eq(PermissionTable.project_id, ctx.project.id)) + .get() + .pipe(Effect.orDie) const state = { pending: new Map(), approved: [...(row?.data ?? [])], diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts index d84e21dad6..35f58f6666 100644 --- a/packages/opencode/src/project/project.ts +++ b/packages/opencode/src/project/project.ts @@ -190,41 +190,57 @@ export const layer = Layer.effect( Effect.gen(function* () { const oldProject = yield* d.select().from(ProjectTable).where(eq(ProjectTable.id, oldID)).get() const newProject = yield* d.select().from(ProjectTable).where(eq(ProjectTable.id, newID)).get() - if (oldProject && !newProject) { - yield* d - .insert(ProjectTable) - .values({ - ...oldProject, - id: newID, - time_updated: Date.now(), - }) - .run() - } + if (oldProject && !newProject) { + yield* d + .insert(ProjectTable) + .values({ + ...oldProject, + id: newID, + time_updated: Date.now(), + }) + .run() + } - const oldPermission = yield* d.select().from(PermissionTable).where(eq(PermissionTable.project_id, oldID)).get() - const newPermission = yield* d.select().from(PermissionTable).where(eq(PermissionTable.project_id, newID)).get() - if (oldPermission && newPermission) { - yield* d - .update(PermissionTable) - .set({ - data: mergePermissionRules(oldPermission.data, newPermission.data), - time_created: Math.min(oldPermission.time_created, newPermission.time_created), - time_updated: Date.now(), - }) + const oldPermission = yield* d + .select() + .from(PermissionTable) + .where(eq(PermissionTable.project_id, oldID)) + .get() + const newPermission = yield* d + .select() + .from(PermissionTable) .where(eq(PermissionTable.project_id, newID)) - .run() + .get() + if (oldPermission && newPermission) { + yield* d + .update(PermissionTable) + .set({ + data: mergePermissionRules(oldPermission.data, newPermission.data), + time_created: Math.min(oldPermission.time_created, newPermission.time_created), + time_updated: Date.now(), + }) + .where(eq(PermissionTable.project_id, newID)) + .run() yield* d.delete(PermissionTable).where(eq(PermissionTable.project_id, oldID)).run() - } - if (oldPermission && !newPermission) { - yield* d.update(PermissionTable).set({ project_id: newID }).where(eq(PermissionTable.project_id, oldID)).run() - } + } + if (oldPermission && !newPermission) { + yield* d + .update(PermissionTable) + .set({ project_id: newID }) + .where(eq(PermissionTable.project_id, oldID)) + .run() + } yield* d .update(SessionTable) - .set({ project_id: newID, time_updated: sql`${SessionTable.time_updated}` }) - .where(eq(SessionTable.project_id, oldID)) - .run() - yield* d.update(WorkspaceTable).set({ project_id: newID }).where(eq(WorkspaceTable.project_id, oldID)).run() + .set({ project_id: newID, time_updated: sql`${SessionTable.time_updated}` }) + .where(eq(SessionTable.project_id, oldID)) + .run() + yield* d + .update(WorkspaceTable) + .set({ project_id: newID }) + .where(eq(WorkspaceTable.project_id, oldID)) + .run() if (oldProject) yield* d.delete(ProjectTable).where(eq(ProjectTable.id, oldID)).run() }), @@ -278,46 +294,46 @@ export const layer = Layer.effect( ).pipe(Effect.map((arr) => arr.filter((x): x is string => x !== undefined))) yield* db - .insert(ProjectTable) - .values({ - id: result.id, + .insert(ProjectTable) + .values({ + id: result.id, + worktree: result.worktree, + vcs: result.vcs ?? null, + name: result.name, + icon_url: result.icon?.url, + icon_url_override: result.icon?.override, + icon_color: result.icon?.color, + time_created: result.time.created, + time_updated: result.time.updated, + time_initialized: result.time.initialized, + sandboxes: result.sandboxes, + commands: result.commands, + }) + .onConflictDoUpdate({ + target: ProjectTable.id, + set: { worktree: result.worktree, vcs: result.vcs ?? null, name: result.name, icon_url: result.icon?.url, icon_url_override: result.icon?.override, icon_color: result.icon?.color, - time_created: result.time.created, time_updated: result.time.updated, time_initialized: result.time.initialized, sandboxes: result.sandboxes, commands: result.commands, - }) - .onConflictDoUpdate({ - target: ProjectTable.id, - set: { - worktree: result.worktree, - vcs: result.vcs ?? null, - name: result.name, - icon_url: result.icon?.url, - icon_url_override: result.icon?.override, - icon_color: result.icon?.color, - time_updated: result.time.updated, - time_initialized: result.time.initialized, - sandboxes: result.sandboxes, - commands: result.commands, - }, - }) - .run() - .pipe(Effect.orDie) + }, + }) + .run() + .pipe(Effect.orDie) if (projectID !== ProjectV2.ID.global) { yield* db - .update(SessionTable) - .set({ project_id: projectID }) - .where(and(eq(SessionTable.project_id, ProjectV2.ID.global), eq(SessionTable.directory, data.directory))) - .run() - .pipe(Effect.orDie) + .update(SessionTable) + .set({ project_id: projectID }) + .where(and(eq(SessionTable.project_id, ProjectV2.ID.global), eq(SessionTable.directory, data.directory))) + .run() + .pipe(Effect.orDie) } yield* emitUpdated(result) @@ -362,19 +378,19 @@ export const layer = Layer.effect( const update = Effect.fn("Project.update")(function* (input: UpdateInput) { const result = yield* db - .update(ProjectTable) - .set({ - name: input.name, - icon_url: input.icon?.url, - icon_url_override: input.icon?.override, - icon_color: input.icon?.color, - commands: input.commands, - time_updated: Date.now(), - }) - .where(eq(ProjectTable.id, input.projectID)) - .returning() - .get() - .pipe(Effect.orDie) + .update(ProjectTable) + .set({ + name: input.name, + icon_url: input.icon?.url, + icon_url_override: input.icon?.override, + icon_color: input.icon?.color, + commands: input.commands, + time_updated: Date.now(), + }) + .where(eq(ProjectTable.id, input.projectID)) + .returning() + .get() + .pipe(Effect.orDie) if (!result) return yield* new NotFoundError({ projectID: input.projectID }) const data = fromRow(result) yield* emitUpdated(data) @@ -393,13 +409,19 @@ export const layer = Layer.effect( }) const setInitialized = Effect.fn("Project.setInitialized")(function* (id: ProjectV2.ID) { - yield* db.update(ProjectTable).set({ time_initialized: Date.now() }).where(eq(ProjectTable.id, id)).run().pipe(Effect.orDie) + yield* db + .update(ProjectTable) + .set({ time_initialized: Date.now() }) + .where(eq(ProjectTable.id, id)) + .run() + .pipe(Effect.orDie) }) const initState = yield* InstanceState.make( Effect.fn("Project.initState")(function* (ctx) { const unsubscribe = yield* events.listen((event) => { - if (event.type !== Command.Event.Executed.type || event.location?.directory !== ctx.directory) return Effect.void + if (event.type !== Command.Event.Executed.type || event.location?.directory !== ctx.directory) + return Effect.void const data = event.data as EventV2.Data return data.name === Command.Default.INIT ? setInitialized(ctx.project.id) : Effect.void }) @@ -432,12 +454,12 @@ export const layer = Layer.effect( const sboxes = [...row.sandboxes] if (!sboxes.includes(directory)) sboxes.push(directory) const result = yield* db - .update(ProjectTable) - .set({ sandboxes: sboxes, time_updated: Date.now() }) - .where(eq(ProjectTable.id, id)) - .returning() - .get() - .pipe(Effect.orDie) + .update(ProjectTable) + .set({ sandboxes: sboxes, time_updated: Date.now() }) + .where(eq(ProjectTable.id, id)) + .returning() + .get() + .pipe(Effect.orDie) if (!result) throw new Error(`Project not found: ${id}`) yield* emitUpdated(fromRow(result)) }) @@ -447,12 +469,12 @@ export const layer = Layer.effect( if (!row) throw new Error(`Project not found: ${id}`) const sboxes = row.sandboxes.filter((s) => s !== directory) const result = yield* db - .update(ProjectTable) - .set({ sandboxes: sboxes, time_updated: Date.now() }) - .where(eq(ProjectTable.id, id)) - .returning() - .get() - .pipe(Effect.orDie) + .update(ProjectTable) + .set({ sandboxes: sboxes, time_updated: Date.now() }) + .where(eq(ProjectTable.id, id)) + .returning() + .get() + .pipe(Effect.orDie) if (!result) throw new Error(`Project not found: ${id}`) yield* emitUpdated(fromRow(result)) }) diff --git a/packages/opencode/src/project/vcs.ts b/packages/opencode/src/project/vcs.ts index 69e3ca9aa1..d809bc31f8 100644 --- a/packages/opencode/src/project/vcs.ts +++ b/packages/opencode/src/project/vcs.ts @@ -328,7 +328,8 @@ export const layer: Layer.Layer { - if (event.type !== FileWatcher.Event.Updated.type || event.location?.directory !== ctx.directory) return Effect.void + if (event.type !== FileWatcher.Event.Updated.type || event.location?.directory !== ctx.directory) + return Effect.void const data = event.data as EventV2.Data if (!data.file.endsWith("HEAD")) return Effect.void return Effect.gen(function* () { @@ -429,9 +430,6 @@ export const layer: Layer.Layer = } }) - const callback = Effect.fn("ProviderAuth.callback")(function* (input: { providerID: ProviderV2.ID } & CallbackInput) { + const callback = Effect.fn("ProviderAuth.callback")(function* ( + input: { providerID: ProviderV2.ID } & CallbackInput, + ) { const pending = (yield* InstanceState.get(state)).pending const match = pending.get(input.providerID) if (!match) return yield* new OauthMissing({ providerID: input.providerID }) diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 468ee34ab0..1806fb6a7f 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -1024,14 +1024,20 @@ export type Error = ModelNotFoundError | InitError | NoProvidersError | NoModels export interface Interface { readonly list: () => Effect.Effect> readonly getProvider: (providerID: ProviderV2.ID) => Effect.Effect - readonly getModel: (providerID: ProviderV2.ID, modelID: ProviderV2.ModelID) => Effect.Effect + readonly getModel: ( + providerID: ProviderV2.ID, + modelID: ProviderV2.ModelID, + ) => Effect.Effect readonly getLanguage: (model: Model) => Effect.Effect readonly closest: ( providerID: ProviderV2.ID, query: string[], ) => Effect.Effect<{ providerID: ProviderV2.ID; modelID: string } | undefined> readonly getSmallModel: (providerID: ProviderV2.ID) => Effect.Effect - readonly defaultModel: () => Effect.Effect<{ providerID: ProviderV2.ID; modelID: ProviderV2.ModelID }, DefaultModelError> + readonly defaultModel: () => Effect.Effect< + { providerID: ProviderV2.ID; modelID: ProviderV2.ModelID }, + DefaultModelError + > } interface State { diff --git a/packages/opencode/src/server/projectors.ts b/packages/opencode/src/server/projectors.ts index 2ded2c2cd1..b9142beab2 100644 --- a/packages/opencode/src/server/projectors.ts +++ b/packages/opencode/src/server/projectors.ts @@ -1,2 +1 @@ -export function initProjectors() { -} +export function initProjectors() {} diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/control.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/control.ts index 076307291a..7bbbfcca61 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/control.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/control.ts @@ -19,7 +19,9 @@ export const controlHandlers = HttpApiBuilder.group(RootHttpApi, "control", (han return true }) - const authRemove = Effect.fn("ControlHttpApi.authRemove")(function* (ctx: { params: { providerID: ProviderV2.ID } }) { + const authRemove = Effect.fn("ControlHttpApi.authRemove")(function* (ctx: { + params: { providerID: ProviderV2.ID } + }) { yield* auth.remove(ctx.params.providerID).pipe(Effect.orDie) return true }) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/event.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/event.ts index edf50927ad..e1294c1774 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/event.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/event.ts @@ -43,7 +43,10 @@ function eventResponse(events: EventV2.Interface) { Stream.map((event) => ({ id: event.id, type: event.type, properties: event.data })), ) const disposed = Stream.callback<{ id: string; type: string; properties: unknown }>((queue) => { - const listener = (event: { directory?: string; payload: { id?: string; type?: string; properties?: unknown } }) => { + const listener = (event: { + directory?: string + payload: { id?: string; type?: string; properties?: unknown } + }) => { if (event.directory !== instance.directory || event.payload.type !== "server.instance.disposed") return Queue.offerUnsafe(queue, { id: event.payload.id ?? eventID(), @@ -56,7 +59,10 @@ function eventResponse(events: EventV2.Interface) { () => Effect.sync(() => GlobalBus.off("event", listener)), ) }) - const output = stream.pipe(Stream.merge(disposed, { haltStrategy: "left" }), Stream.takeUntil((event) => event.type === "server.instance.disposed")) + const output = stream.pipe( + Stream.merge(disposed, { haltStrategy: "left" }), + Stream.takeUntil((event) => event.type === "server.instance.disposed"), + ) const heartbeat = Stream.tick("10 seconds").pipe( Stream.drop(1), Stream.map(() => ({ id: eventID(), type: "server.heartbeat", properties: {} })), diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/tui.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/tui.ts index 31ecd5effb..22039ca454 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/tui.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/tui.ts @@ -88,7 +88,8 @@ export const tuiHandlers = HttpApiBuilder.group(InstanceHttpApi, "tui", (handler yield* events.publish(TuiEvent.PromptAppend, ctx.payload.properties) if (ctx.payload.type === TuiEvent.CommandExecute.type) yield* events.publish(TuiEvent.CommandExecute, ctx.payload.properties) - if (ctx.payload.type === TuiEvent.ToastShow.type) yield* events.publish(TuiEvent.ToastShow, ctx.payload.properties) + if (ctx.payload.type === TuiEvent.ToastShow.type) + yield* events.publish(TuiEvent.ToastShow, ctx.payload.properties) if (ctx.payload.type === TuiEvent.SessionSelect.type) yield* events.publish(TuiEvent.SessionSelect, ctx.payload.properties) return true diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index c20cf7e072..ceb78e6321 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -353,7 +353,9 @@ export const layer = Layer.effect( throw new Error(`Compaction parent must be a user message: ${input.parentID}`) } const userMessage = parent.info - const compactionPart = parent.parts.find((part): part is SessionLegacy.CompactionPart => part.type === "compaction") + const compactionPart = parent.parts.find( + (part): part is SessionLegacy.CompactionPart => part.type === "compaction", + ) let messages = input.messages let replay: diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index 0ea62147c3..eb63ee534e 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -168,13 +168,15 @@ const live: Layer.Layer< const id = PermissionID.ascending() let unsub: EventV2.Unsubscribe | undefined try { - unsub = await bridge.promise(events.listen((event) => { - if (event.type !== Permission.Event.Replied.type) return Effect.void - const data = event.data as EventV2.Data - if (data.requestID !== id) return Effect.void - void data.reply - return Effect.void - })) + unsub = await bridge.promise( + events.listen((event) => { + if (event.type !== Permission.Event.Replied.type) return Effect.void + const data = event.data as EventV2.Data + if (data.requestID !== id) return Effect.void + void data.reply + return Effect.void + }), + ) const toolPatterns = approvalTools.map((t: { name: string; args: string }) => { try { const parsed = JSON.parse(t.args) as Record diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 209b1e05d9..862e06fc21 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -503,14 +503,14 @@ export function stream(sessionID: SessionID) { export function parts(messageID: MessageID) { return Effect.gen(function* () { const { db } = yield* Database.Service - const rows = yield* db - .select() - .from(PartTable) - .where(eq(PartTable.message_id, messageID)) - .orderBy(PartTable.id) - .all() - .pipe(Effect.orDie) - return rows.map(part) + const rows = yield* db + .select() + .from(PartTable) + .where(eq(PartTable.message_id, messageID)) + .orderBy(PartTable.id) + .all() + .pipe(Effect.orDie) + return rows.map(part) }) } diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index f51635bf83..36e394f0aa 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1502,11 +1502,11 @@ export const layer = Layer.effect( }, ) - const loop: (input: LoopInput) => Effect.Effect = Effect.fn("SessionPrompt.loop")(function* ( - input: LoopInput, - ) { - return yield* state.ensureRunning(input.sessionID, lastAssistant(input.sessionID), runLoop(input.sessionID)) - }) + const loop: (input: LoopInput) => Effect.Effect = Effect.fn("SessionPrompt.loop")( + function* (input: LoopInput) { + return yield* state.ensureRunning(input.sessionID, lastAssistant(input.sessionID), runLoop(input.sessionID)) + }, + ) const shell: (input: ShellInput) => Effect.Effect = Effect.fn( "SessionPrompt.shell", diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index b055c488b8..f87a24a74e 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -536,11 +536,7 @@ export type Patch = Omit, "time" | "share" | "summary" | "revert" export const layer: Layer.Layer< Service, never, - | BackgroundJob.Service - | Storage.Service - | RuntimeFlags.Service - | Database.Service - | EventV2Bridge.Service + BackgroundJob.Service | Storage.Service | RuntimeFlags.Service | Database.Service | EventV2Bridge.Service > = Layer.effect( Service, Effect.gen(function* () { diff --git a/packages/opencode/src/session/summary.ts b/packages/opencode/src/session/summary.ts index 0b0d46526b..e32ce9803e 100644 --- a/packages/opencode/src/session/summary.ts +++ b/packages/opencode/src/session/summary.ts @@ -79,7 +79,9 @@ export const layer = Layer.effect( const storage = yield* Storage.Service const events = yield* EventV2Bridge.Service - const computeDiff = Effect.fn("SessionSummary.computeDiff")(function* (input: { messages: SessionLegacy.WithParts[] }) { + const computeDiff = Effect.fn("SessionSummary.computeDiff")(function* (input: { + messages: SessionLegacy.WithParts[] + }) { let from: string | undefined let to: string | undefined for (const item of input.messages) { diff --git a/packages/opencode/src/share/share-next.ts b/packages/opencode/src/share/share-next.ts index bf0ae3b844..665b62898b 100644 --- a/packages/opencode/src/share/share-next.ts +++ b/packages/opencode/src/share/share-next.ts @@ -173,7 +173,9 @@ export const layer = Layer.effect( events.listen((event) => { if (event.type !== def.type || event.location?.directory !== _ctx.directory) return Effect.void return fn(event.data as EventV2.Data).pipe( - Effect.catchCause((cause) => Effect.sync(() => log.error("share subscriber failed", { type: def.type, cause }))), + Effect.catchCause((cause) => + Effect.sync(() => log.error("share subscriber failed", { type: def.type, cause })), + ), ) }) diff --git a/packages/opencode/src/skill/index.ts b/packages/opencode/src/skill/index.ts index 1d21774eeb..fc2fd0799f 100644 --- a/packages/opencode/src/skill/index.ts +++ b/packages/opencode/src/skill/index.ts @@ -232,7 +232,11 @@ const discoverSkills = Effect.fnUntraced(function* ( } }) -const loadSkills = Effect.fnUntraced(function* (state: State, discovered: DiscoveryState, events: EventV2Bridge.Service["Service"]) { +const loadSkills = Effect.fnUntraced(function* ( + state: State, + discovered: DiscoveryState, + events: EventV2Bridge.Service["Service"], +) { yield* Effect.forEach(discovered.matches, (match) => add(state, match, events), { concurrency: "unbounded", discard: true, diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index aae98a8c7c..fed3824a7f 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -76,7 +76,11 @@ export interface Interface { readonly ids: () => Effect.Effect readonly all: () => Effect.Effect readonly named: () => Effect.Effect<{ task: TaskDef; read: ReadDef }> - readonly tools: (model: { providerID: ProviderV2.ID; modelID: ProviderV2.ModelID; agent: Agent.Info }) => Effect.Effect + readonly tools: (model: { + providerID: ProviderV2.ID + modelID: ProviderV2.ModelID + agent: Agent.Info + }) => Effect.Effect } export class Service extends Context.Service()("@opencode/ToolRegistry") {} diff --git a/packages/opencode/src/worktree/index.ts b/packages/opencode/src/worktree/index.ts index 27041925dc..6565555737 100644 --- a/packages/opencode/src/worktree/index.ts +++ b/packages/opencode/src/worktree/index.ts @@ -149,7 +149,13 @@ type GitResult = { code: number; text: string; stderr: string } export const layer: Layer.Layer< Service, never, - AppFileSystem.Service | Path.Path | AppProcess.Service | Git.Service | Project.Service | InstanceStore.Service | Database.Service + | AppFileSystem.Service + | Path.Path + | AppProcess.Service + | Git.Service + | Project.Service + | InstanceStore.Service + | Database.Service > = Layer.effect( Service, Effect.gen(function* () { @@ -484,7 +490,12 @@ export const layer: Layer.Layer< directory: string, input: { projectID: ProjectV2.ID; extra?: string }, ) { - const row = yield* db.select().from(ProjectTable).where(eq(ProjectTable.id, input.projectID)).get().pipe(Effect.orDie) + const row = yield* db + .select() + .from(ProjectTable) + .where(eq(ProjectTable.id, input.projectID)) + .get() + .pipe(Effect.orDie) const project = row ? Project.fromRow(row) : undefined const startup = project?.commands?.start?.trim() ?? "" const ok = yield* runStartScript(directory, startup, "project") diff --git a/packages/opencode/test/cli/github-action.test.ts b/packages/opencode/test/cli/github-action.test.ts index 35f8e44d79..1530df66b9 100644 --- a/packages/opencode/test/cli/github-action.test.ts +++ b/packages/opencode/test/cli/github-action.test.ts @@ -26,7 +26,11 @@ function createReasoningPart(text: string): SessionLegacy.Part { } } -function createToolPart(tool: string, title: string, status: "completed" | "running" = "completed"): SessionLegacy.Part { +function createToolPart( + tool: string, + title: string, + status: "completed" | "running" = "completed", +): SessionLegacy.Part { if (status === "completed") { return { id: PartID.ascending(), diff --git a/packages/opencode/test/format/format.test.ts b/packages/opencode/test/format/format.test.ts index 96b5ad5aed..e0388a7fd3 100644 --- a/packages/opencode/test/format/format.test.ts +++ b/packages/opencode/test/format/format.test.ts @@ -105,7 +105,9 @@ describe("Format", () => { { config: { formatter: false } }, ) - testEffect(Layer.mergeAll(Format.defaultLayer, CrossSpawnSpawner.defaultLayer, NodeFileSystem.layer, testInstanceStoreLayer)).live("status() initializes formatter state per directory", () => + testEffect( + Layer.mergeAll(Format.defaultLayer, CrossSpawnSpawner.defaultLayer, NodeFileSystem.layer, testInstanceStoreLayer), + ).live("status() initializes formatter state per directory", () => Effect.gen(function* () { const a = yield* provideTmpdirInstance(() => Format.use.status(), { config: { formatter: false }, diff --git a/packages/opencode/test/lsp/index.test.ts b/packages/opencode/test/lsp/index.test.ts index b99131e268..86f3a5dadf 100644 --- a/packages/opencode/test/lsp/index.test.ts +++ b/packages/opencode/test/lsp/index.test.ts @@ -19,17 +19,11 @@ const lspLayer = (flags: Parameters[0] = {}) => const it = testEffect(Layer.mergeAll(lspLayer(), CrossSpawnSpawner.defaultLayer)) const experimentalTyIt = testEffect( - Layer.mergeAll( - lspLayer({ experimentalLspTy: true }), - CrossSpawnSpawner.defaultLayer, - ), + Layer.mergeAll(lspLayer({ experimentalLspTy: true }), CrossSpawnSpawner.defaultLayer), ) const fakeServerPath = path.join(__dirname, "../fixture/lsp/fake-lsp-server.js") const disabledDownloadIt = testEffect( - Layer.mergeAll( - lspLayer({ disableLspDownload: true }), - CrossSpawnSpawner.defaultLayer, - ), + Layer.mergeAll(lspLayer({ disableLspDownload: true }), CrossSpawnSpawner.defaultLayer), ) describe("lsp.spawn", () => { @@ -37,27 +31,49 @@ describe("lsp.spawn", () => { "does not spawn builtin LSP for files outside instance", () => LSP.Service.use((lsp) => - Effect.gen(function* () { - const dir = (yield* TestInstance).directory - const spy = spyOn(LSPServer.Typescript, "spawn").mockResolvedValue(undefined) + Effect.gen(function* () { + const dir = (yield* TestInstance).directory + const spy = spyOn(LSPServer.Typescript, "spawn").mockResolvedValue(undefined) - try { - yield* lsp.touchFile(path.join(dir, "..", "outside.ts")) - yield* lsp.hover({ - file: path.join(dir, "..", "hover.ts"), - line: 0, - character: 0, - }) - expect(spy).toHaveBeenCalledTimes(0) - } finally { - spy.mockRestore() - } - }), - ), + try { + yield* lsp.touchFile(path.join(dir, "..", "outside.ts")) + yield* lsp.hover({ + file: path.join(dir, "..", "hover.ts"), + line: 0, + character: 0, + }) + expect(spy).toHaveBeenCalledTimes(0) + } finally { + spy.mockRestore() + } + }), + ), { config: { lsp: true } }, ) it.instance("does not spawn builtin LSP for files inside instance when LSP is unset", () => + LSP.Service.use((lsp) => + Effect.gen(function* () { + const dir = (yield* TestInstance).directory + const spy = spyOn(LSPServer.Typescript, "spawn").mockResolvedValue(undefined) + + try { + yield* lsp.hover({ + file: path.join(dir, "src", "inside.ts"), + line: 0, + character: 0, + }) + expect(spy).toHaveBeenCalledTimes(0) + } finally { + spy.mockRestore() + } + }), + ), + ) + + it.instance( + "would spawn builtin LSP for files inside instance when lsp is true", + () => LSP.Service.use((lsp) => Effect.gen(function* () { const dir = (yield* TestInstance).directory @@ -69,34 +85,12 @@ describe("lsp.spawn", () => { line: 0, character: 0, }) - expect(spy).toHaveBeenCalledTimes(0) + expect(spy).toHaveBeenCalledTimes(1) } finally { spy.mockRestore() } }), ), - ) - - it.instance( - "would spawn builtin LSP for files inside instance when lsp is true", - () => - LSP.Service.use((lsp) => - Effect.gen(function* () { - const dir = (yield* TestInstance).directory - const spy = spyOn(LSPServer.Typescript, "spawn").mockResolvedValue(undefined) - - try { - yield* lsp.hover({ - file: path.join(dir, "src", "inside.ts"), - line: 0, - character: 0, - }) - expect(spy).toHaveBeenCalledTimes(1) - } finally { - spy.mockRestore() - } - }), - ), { config: { lsp: true } }, ) @@ -104,21 +98,21 @@ describe("lsp.spawn", () => { "publishes lsp.updated after custom LSP initialization", () => Effect.gen(function* () { - const dir = (yield* TestInstance).directory - const lsp = yield* LSP.Service - const updated = yield* Deferred.make() - const events = yield* EventV2Bridge.Service - const unsubscribe = yield* events.listen((event) => { - if (event.type === LSP.Event.Updated.type) Deferred.doneUnsafe(updated, Effect.void) - return Effect.void - }) - yield* Effect.addFinalizer(() => unsubscribe) + const dir = (yield* TestInstance).directory + const lsp = yield* LSP.Service + const updated = yield* Deferred.make() + const events = yield* EventV2Bridge.Service + const unsubscribe = yield* events.listen((event) => { + if (event.type === LSP.Event.Updated.type) Deferred.doneUnsafe(updated, Effect.void) + return Effect.void + }) + yield* Effect.addFinalizer(() => unsubscribe) - const file = path.join(dir, "sample.repro") - yield* Effect.promise(() => Bun.write(file, "sample\n")) - yield* lsp.touchFile(file) - yield* awaitWithTimeout(Deferred.await(updated), "lsp.updated event was not published") - }), + const file = path.join(dir, "sample.repro") + yield* Effect.promise(() => Bun.write(file, "sample\n")) + yield* lsp.touchFile(file) + yield* awaitWithTimeout(Deferred.await(updated), "lsp.updated event was not published") + }), { config: { lsp: { @@ -135,22 +129,22 @@ describe("lsp.spawn", () => { "would spawn builtin LSP for files inside instance when config object is provided", () => LSP.Service.use((lsp) => - Effect.gen(function* () { - const dir = (yield* TestInstance).directory - const spy = spyOn(LSPServer.Typescript, "spawn").mockResolvedValue(undefined) + Effect.gen(function* () { + const dir = (yield* TestInstance).directory + const spy = spyOn(LSPServer.Typescript, "spawn").mockResolvedValue(undefined) - try { - yield* lsp.hover({ - file: path.join(dir, "src", "inside.ts"), - line: 0, - character: 0, - }) - expect(spy).toHaveBeenCalledTimes(1) - } finally { - spy.mockRestore() - } - }), - ), + try { + yield* lsp.hover({ + file: path.join(dir, "src", "inside.ts"), + line: 0, + character: 0, + }) + expect(spy).toHaveBeenCalledTimes(1) + } finally { + spy.mockRestore() + } + }), + ), { config: { lsp: { @@ -164,25 +158,25 @@ describe("lsp.spawn", () => { "uses pyright instead of ty by default", () => LSP.Service.use((lsp) => - Effect.gen(function* () { - const dir = (yield* TestInstance).directory - const ty = spyOn(LSPServer.Ty, "spawn").mockResolvedValue(undefined) - const pyright = spyOn(LSPServer.Pyright, "spawn").mockResolvedValue(undefined) + Effect.gen(function* () { + const dir = (yield* TestInstance).directory + const ty = spyOn(LSPServer.Ty, "spawn").mockResolvedValue(undefined) + const pyright = spyOn(LSPServer.Pyright, "spawn").mockResolvedValue(undefined) - try { - yield* lsp.hover({ - file: path.join(dir, "src", "inside.py"), - line: 0, - character: 0, - }) - expect(ty).toHaveBeenCalledTimes(0) - expect(pyright).toHaveBeenCalledTimes(1) - } finally { - ty.mockRestore() - pyright.mockRestore() - } - }), - ), + try { + yield* lsp.hover({ + file: path.join(dir, "src", "inside.py"), + line: 0, + character: 0, + }) + expect(ty).toHaveBeenCalledTimes(0) + expect(pyright).toHaveBeenCalledTimes(1) + } finally { + ty.mockRestore() + pyright.mockRestore() + } + }), + ), { config: { lsp: true } }, ) @@ -190,25 +184,25 @@ describe("lsp.spawn", () => { "uses ty instead of pyright when experimentalLspTy is enabled", () => LSP.Service.use((lsp) => - Effect.gen(function* () { - const dir = (yield* TestInstance).directory - const ty = spyOn(LSPServer.Ty, "spawn").mockResolvedValue(undefined) - const pyright = spyOn(LSPServer.Pyright, "spawn").mockResolvedValue(undefined) + Effect.gen(function* () { + const dir = (yield* TestInstance).directory + const ty = spyOn(LSPServer.Ty, "spawn").mockResolvedValue(undefined) + const pyright = spyOn(LSPServer.Pyright, "spawn").mockResolvedValue(undefined) - try { - yield* lsp.hover({ - file: path.join(dir, "src", "inside.py"), - line: 0, - character: 0, - }) - expect(ty).toHaveBeenCalledTimes(1) - expect(pyright).toHaveBeenCalledTimes(0) - } finally { - ty.mockRestore() - pyright.mockRestore() - } - }), - ), + try { + yield* lsp.hover({ + file: path.join(dir, "src", "inside.py"), + line: 0, + character: 0, + }) + expect(ty).toHaveBeenCalledTimes(1) + expect(pyright).toHaveBeenCalledTimes(0) + } finally { + ty.mockRestore() + pyright.mockRestore() + } + }), + ), { config: { lsp: true } }, ) @@ -216,23 +210,23 @@ describe("lsp.spawn", () => { "passes disableLspDownload to builtin LSP spawn", () => LSP.Service.use((lsp) => - Effect.gen(function* () { - const dir = (yield* TestInstance).directory - const pyright = spyOn(LSPServer.Pyright, "spawn").mockResolvedValue(undefined) + Effect.gen(function* () { + const dir = (yield* TestInstance).directory + const pyright = spyOn(LSPServer.Pyright, "spawn").mockResolvedValue(undefined) - try { - yield* lsp.hover({ - file: path.join(dir, "src", "inside.py"), - line: 0, - character: 0, - }) - expect(pyright).toHaveBeenCalledTimes(1) - expect(pyright.mock.calls[0]?.[2]).toMatchObject({ disableLspDownload: true }) - } finally { - pyright.mockRestore() - } - }), - ), + try { + yield* lsp.hover({ + file: path.join(dir, "src", "inside.py"), + line: 0, + character: 0, + }) + expect(pyright).toHaveBeenCalledTimes(1) + expect(pyright.mock.calls[0]?.[2]).toMatchObject({ disableLspDownload: true }) + } finally { + pyright.mockRestore() + } + }), + ), { config: { lsp: true } }, ) }) diff --git a/packages/opencode/test/lsp/lifecycle.test.ts b/packages/opencode/test/lsp/lifecycle.test.ts index f6db6c087a..5d0313e6d2 100644 --- a/packages/opencode/test/lsp/lifecycle.test.ts +++ b/packages/opencode/test/lsp/lifecycle.test.ts @@ -43,12 +43,12 @@ describe("LSP service lifecycle", () => { ) it.instance("hasClients() returns false for .ts files in instance when LSP is unset", () => - LSP.Service.use((lsp) => - Effect.gen(function* () { - const result = yield* lsp.hasClients(path.join((yield* TestInstance).directory, "test.ts")) - expect(result).toBe(false) - }), - ), + LSP.Service.use((lsp) => + Effect.gen(function* () { + const result = yield* lsp.hasClients(path.join((yield* TestInstance).directory, "test.ts")) + expect(result).toBe(false) + }), + ), ) it.instance( diff --git a/packages/opencode/test/permission/next.test.ts b/packages/opencode/test/permission/next.test.ts index b05da50cb8..a6d7e2ead0 100644 --- a/packages/opencode/test/permission/next.test.ts +++ b/packages/opencode/test/permission/next.test.ts @@ -657,7 +657,8 @@ it.instance( const events = yield* EventV2Bridge.Service const seen = yield* Deferred.make() const unsub = yield* events.listen((event) => { - if (event.type === Permission.Event.Asked.type) Deferred.doneUnsafe(seen, Effect.succeed(event.data as Permission.Request)) + if (event.type === Permission.Event.Asked.type) + Deferred.doneUnsafe(seen, Effect.succeed(event.data as Permission.Request)) return Effect.void }) yield* Effect.addFinalizer(() => unsub) @@ -932,7 +933,10 @@ it.instance( const unsub = yield* events.listen((event) => { if (event.type === Permission.Event.Replied.type) - Deferred.doneUnsafe(seen, Effect.succeed(event.data as { sessionID: SessionID; requestID: PermissionID; reply: Permission.Reply })) + Deferred.doneUnsafe( + seen, + Effect.succeed(event.data as { sessionID: SessionID; requestID: PermissionID; reply: Permission.Reply }), + ) return Effect.void }) yield* Effect.addFinalizer(() => unsub) diff --git a/packages/opencode/test/plugin/loader-shared.test.ts b/packages/opencode/test/plugin/loader-shared.test.ts index 316fb2bc46..909e46c7d6 100644 --- a/packages/opencode/test/plugin/loader-shared.test.ts +++ b/packages/opencode/test/plugin/loader-shared.test.ts @@ -20,7 +20,9 @@ afterEach(async () => { await disposeAllInstances() }) -const it = testEffect(Layer.mergeAll(CrossSpawnSpawner.defaultLayer, AppFileSystem.defaultLayer, testInstanceStoreLayer)) +const it = testEffect( + Layer.mergeAll(CrossSpawnSpawner.defaultLayer, AppFileSystem.defaultLayer, testInstanceStoreLayer), +) function withTmp( init: (dir: string) => Promise, diff --git a/packages/opencode/test/plugin/workspace-adapter.test.ts b/packages/opencode/test/plugin/workspace-adapter.test.ts index 2953aaa5f1..7fcc26cec6 100644 --- a/packages/opencode/test/plugin/workspace-adapter.test.ts +++ b/packages/opencode/test/plugin/workspace-adapter.test.ts @@ -64,74 +64,74 @@ afterEach(async () => { describe("plugin.workspace", () => { it.instance("plugin can install a workspace adapter", () => Effect.gen(function* () { - const dir = (yield* TestInstance).directory - const type = `plug-${Math.random().toString(36).slice(2)}` - const file = path.join(dir, "plugin.ts") - const mark = path.join(dir, "created.json") - const space = path.join(dir, "space") - yield* Effect.promise(() => - Bun.write( - file, - [ - "export default async ({ experimental_workspace }) => {", - ` experimental_workspace.register(${JSON.stringify(type)}, {`, - ' name: "plug",', - ' description: "plugin workspace adapter",', - " configure(input) {", - ` return { ...input, name: "plug", branch: "plug/main", directory: ${JSON.stringify(space)} }`, - " },", - " async create(input) {", - ` await Bun.write(${JSON.stringify(mark)}, JSON.stringify(input))`, - " },", - " async remove() {},", - " target(input) {", - ' return { type: "local", directory: input.directory }', - " },", - " })", - " return {}", - "}", - "", - ].join("\n"), + const dir = (yield* TestInstance).directory + const type = `plug-${Math.random().toString(36).slice(2)}` + const file = path.join(dir, "plugin.ts") + const mark = path.join(dir, "created.json") + const space = path.join(dir, "space") + yield* Effect.promise(() => + Bun.write( + file, + [ + "export default async ({ experimental_workspace }) => {", + ` experimental_workspace.register(${JSON.stringify(type)}, {`, + ' name: "plug",', + ' description: "plugin workspace adapter",', + " configure(input) {", + ` return { ...input, name: "plug", branch: "plug/main", directory: ${JSON.stringify(space)} }`, + " },", + " async create(input) {", + ` await Bun.write(${JSON.stringify(mark)}, JSON.stringify(input))`, + " },", + " async remove() {},", + " target(input) {", + ' return { type: "local", directory: input.directory }', + " },", + " })", + " return {}", + "}", + "", + ].join("\n"), + ), + ) + + yield* Effect.promise(() => + Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify( + { + $schema: "https://opencode.ai/config.json", + plugin: [pathToFileURL(file).href], + }, + null, + 2, ), - ) + ), + ) - yield* Effect.promise(() => - Bun.write( - path.join(dir, "opencode.json"), - JSON.stringify( - { - $schema: "https://opencode.ai/config.json", - plugin: [pathToFileURL(file).href], - }, - null, - 2, - ), - ), - ) + const plugin = yield* Plugin.Service + yield* plugin.init() + const workspace = yield* Workspace.Service + const ctx = yield* InstanceState.context + const info = yield* workspace.create({ + type, + branch: null, + extra: { key: "value" }, + projectID: ctx.project.id, + }) - const plugin = yield* Plugin.Service - yield* plugin.init() - const workspace = yield* Workspace.Service - const ctx = yield* InstanceState.context - const info = yield* workspace.create({ - type, - branch: null, - extra: { key: "value" }, - projectID: ctx.project.id, - }) - - expect(info.type).toBe(type) - expect(info.name).toBe("plug") - expect(info.branch).toBe("plug/main") - expect(info.directory).toBe(space) - expect(info.extra).toEqual({ key: "value" }) - expect(JSON.parse(yield* Effect.promise(() => Bun.file(mark).text()))).toMatchObject({ - type, - name: "plug", - branch: "plug/main", - directory: space, - extra: { key: "value" }, - }) + expect(info.type).toBe(type) + expect(info.name).toBe("plug") + expect(info.branch).toBe("plug/main") + expect(info.directory).toBe(space) + expect(info.extra).toEqual({ key: "value" }) + expect(JSON.parse(yield* Effect.promise(() => Bun.file(mark).text()))).toMatchObject({ + type, + name: "plug", + branch: "plug/main", + directory: space, + extra: { key: "value" }, + }) }), ) }) diff --git a/packages/opencode/test/project/project.test.ts b/packages/opencode/test/project/project.test.ts index bbad81a713..c10c423374 100644 --- a/packages/opencode/test/project/project.test.ts +++ b/packages/opencode/test/project/project.test.ts @@ -238,10 +238,25 @@ describe("Project.fromDirectory", () => { const result = yield* projects.fromDirectory(tmp) expect(result.project.id).toBe(remoteID) - expect(yield* db.select().from(ProjectTable).where(eq(ProjectTable.id, rootProject.id)).get().pipe(Effect.orDie)).toBeUndefined() - expect((yield* db.select().from(SessionTable).where(eq(SessionTable.id, sessionID)).get().pipe(Effect.orDie))?.project_id).toBe(remoteID) - expect(yield* db.select().from(PermissionTable).where(eq(PermissionTable.project_id, remoteID)).get().pipe(Effect.orDie)).toBeDefined() - expect((yield* db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, workspaceID)).get().pipe(Effect.orDie))?.project_id).toBe(remoteID) + expect( + yield* db.select().from(ProjectTable).where(eq(ProjectTable.id, rootProject.id)).get().pipe(Effect.orDie), + ).toBeUndefined() + expect( + (yield* db.select().from(SessionTable).where(eq(SessionTable.id, sessionID)).get().pipe(Effect.orDie)) + ?.project_id, + ).toBe(remoteID) + expect( + yield* db + .select() + .from(PermissionTable) + .where(eq(PermissionTable.project_id, remoteID)) + .get() + .pipe(Effect.orDie), + ).toBeDefined() + expect( + (yield* db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, workspaceID)).get().pipe(Effect.orDie)) + ?.project_id, + ).toBe(remoteID) }), ) }) diff --git a/packages/opencode/test/project/vcs.test.ts b/packages/opencode/test/project/vcs.test.ts index be8d22331e..b309234820 100644 --- a/packages/opencode/test/project/vcs.test.ts +++ b/packages/opencode/test/project/vcs.test.ts @@ -5,7 +5,13 @@ import { Deferred, Effect, Layer } from "effect" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import fs from "fs/promises" import path from "path" -import { disposeAllInstances, provideInstance, testInstanceStoreLayer, TestInstance, tmpdirScoped } from "../fixture/fixture" +import { + disposeAllInstances, + provideInstance, + testInstanceStoreLayer, + TestInstance, + tmpdirScoped, +} from "../fixture/fixture" import { EventV2Bridge } from "../../src/event-v2-bridge" import { FileWatcher } from "../../src/file/watcher" import { Git } from "../../src/git" diff --git a/packages/opencode/test/project/worktree-remove.test.ts b/packages/opencode/test/project/worktree-remove.test.ts index 230ade33a8..c717578024 100644 --- a/packages/opencode/test/project/worktree-remove.test.ts +++ b/packages/opencode/test/project/worktree-remove.test.ts @@ -16,70 +16,70 @@ describe("Worktree.remove", () => { "continues when git remove exits non-zero after detaching", () => Effect.gen(function* () { - const root = (yield* TestInstance).directory - const svc = yield* Worktree.Service - const name = `remove-regression-${Date.now().toString(36)}` - const branch = `opencode/${name}` - const dir = path.join(root, "..", name) + const root = (yield* TestInstance).directory + const svc = yield* Worktree.Service + const name = `remove-regression-${Date.now().toString(36)}` + const branch = `opencode/${name}` + const dir = path.join(root, "..", name) - yield* Effect.promise(() => $`git worktree add --no-checkout -b ${branch} ${dir}`.cwd(root).quiet()) - yield* Effect.promise(() => $`git reset --hard`.cwd(dir).quiet()) + yield* Effect.promise(() => $`git worktree add --no-checkout -b ${branch} ${dir}`.cwd(root).quiet()) + yield* Effect.promise(() => $`git reset --hard`.cwd(dir).quiet()) - const real = (yield* Effect.promise(() => $`which git`.quiet().text())).trim() - expect(real).toBeTruthy() + const real = (yield* Effect.promise(() => $`which git`.quiet().text())).trim() + expect(real).toBeTruthy() - const bin = path.join(root, "bin") - const shim = path.join(bin, "git") - yield* Effect.promise(() => fs.mkdir(bin, { recursive: true })) - yield* Effect.promise(() => - Bun.write( - shim, - [ - "#!/bin/bash", - `REAL_GIT=${JSON.stringify(real)}`, - 'if [ "$1" = "worktree" ] && [ "$2" = "remove" ]; then', - ' "$REAL_GIT" "$@" >/dev/null 2>&1', - ' echo "fatal: failed to remove worktree: Directory not empty" >&2', - " exit 1", - "fi", - 'exec "$REAL_GIT" "$@"', - ].join("\n"), - ), - ) - yield* Effect.promise(() => fs.chmod(shim, 0o755)) + const bin = path.join(root, "bin") + const shim = path.join(bin, "git") + yield* Effect.promise(() => fs.mkdir(bin, { recursive: true })) + yield* Effect.promise(() => + Bun.write( + shim, + [ + "#!/bin/bash", + `REAL_GIT=${JSON.stringify(real)}`, + 'if [ "$1" = "worktree" ] && [ "$2" = "remove" ]; then', + ' "$REAL_GIT" "$@" >/dev/null 2>&1', + ' echo "fatal: failed to remove worktree: Directory not empty" >&2', + " exit 1", + "fi", + 'exec "$REAL_GIT" "$@"', + ].join("\n"), + ), + ) + yield* Effect.promise(() => fs.chmod(shim, 0o755)) - const prev = yield* Effect.acquireRelease( + const prev = yield* Effect.acquireRelease( + Effect.sync(() => { + const prev = process.env.PATH ?? "" + process.env.PATH = `${bin}${path.delimiter}${prev}` + return prev + }), + (prev) => Effect.sync(() => { - const prev = process.env.PATH ?? "" - process.env.PATH = `${bin}${path.delimiter}${prev}` - return prev + process.env.PATH = prev }), - (prev) => - Effect.sync(() => { - process.env.PATH = prev - }), - ) - void prev + ) + void prev - const ok = yield* svc.remove({ directory: dir }) + const ok = yield* svc.remove({ directory: dir }) - expect(ok).toBe(true) - expect( - yield* Effect.promise(() => - fs - .stat(dir) - .then(() => true) - .catch(() => false), - ), - ).toBe(false) + expect(ok).toBe(true) + expect( + yield* Effect.promise(() => + fs + .stat(dir) + .then(() => true) + .catch(() => false), + ), + ).toBe(false) - const list = yield* Effect.promise(() => $`git worktree list --porcelain`.cwd(root).quiet().text()) - expect(list).not.toContain(`worktree ${dir}`) + const list = yield* Effect.promise(() => $`git worktree list --porcelain`.cwd(root).quiet().text()) + expect(list).not.toContain(`worktree ${dir}`) - const ref = yield* Effect.promise(() => - $`git show-ref --verify --quiet refs/heads/${branch}`.cwd(root).quiet().nothrow(), - ) - expect(ref.exitCode).not.toBe(0) + const ref = yield* Effect.promise(() => + $`git show-ref --verify --quiet refs/heads/${branch}`.cwd(root).quiet().nothrow(), + ) + expect(ref.exitCode).not.toBe(0) }), { git: true }, ) @@ -88,38 +88,38 @@ describe("Worktree.remove", () => { "stops fsmonitor before removing a worktree", () => Effect.gen(function* () { - const root = (yield* TestInstance).directory - const svc = yield* Worktree.Service - const name = `remove-fsmonitor-${Date.now().toString(36)}` - const branch = `opencode/${name}` - const dir = path.join(root, "..", name) + const root = (yield* TestInstance).directory + const svc = yield* Worktree.Service + const name = `remove-fsmonitor-${Date.now().toString(36)}` + const branch = `opencode/${name}` + const dir = path.join(root, "..", name) - yield* Effect.promise(() => $`git worktree add --no-checkout -b ${branch} ${dir}`.cwd(root).quiet()) - yield* Effect.promise(() => $`git reset --hard`.cwd(dir).quiet()) - yield* Effect.promise(() => $`git config core.fsmonitor true`.cwd(dir).quiet()) - yield* Effect.promise(() => $`git fsmonitor--daemon stop`.cwd(dir).quiet().nothrow()) - yield* Effect.promise(() => Bun.write(path.join(dir, "tracked.txt"), "next\n")) - yield* Effect.promise(() => $`git diff`.cwd(dir).quiet()) + yield* Effect.promise(() => $`git worktree add --no-checkout -b ${branch} ${dir}`.cwd(root).quiet()) + yield* Effect.promise(() => $`git reset --hard`.cwd(dir).quiet()) + yield* Effect.promise(() => $`git config core.fsmonitor true`.cwd(dir).quiet()) + yield* Effect.promise(() => $`git fsmonitor--daemon stop`.cwd(dir).quiet().nothrow()) + yield* Effect.promise(() => Bun.write(path.join(dir, "tracked.txt"), "next\n")) + yield* Effect.promise(() => $`git diff`.cwd(dir).quiet()) - const before = yield* Effect.promise(() => $`git fsmonitor--daemon status`.cwd(dir).quiet().nothrow()) - expect(before.exitCode).toBe(0) + const before = yield* Effect.promise(() => $`git fsmonitor--daemon status`.cwd(dir).quiet().nothrow()) + expect(before.exitCode).toBe(0) - const ok = yield* svc.remove({ directory: dir }) + const ok = yield* svc.remove({ directory: dir }) - expect(ok).toBe(true) - expect( - yield* Effect.promise(() => - fs - .stat(dir) - .then(() => true) - .catch(() => false), - ), - ).toBe(false) + expect(ok).toBe(true) + expect( + yield* Effect.promise(() => + fs + .stat(dir) + .then(() => true) + .catch(() => false), + ), + ).toBe(false) - const ref = yield* Effect.promise(() => - $`git show-ref --verify --quiet refs/heads/${branch}`.cwd(root).quiet().nothrow(), - ) - expect(ref.exitCode).not.toBe(0) + const ref = yield* Effect.promise(() => + $`git show-ref --verify --quiet refs/heads/${branch}`.cwd(root).quiet().nothrow(), + ) + expect(ref.exitCode).not.toBe(0) }), { git: true }, ) diff --git a/packages/opencode/test/provider/amazon-bedrock.test.ts b/packages/opencode/test/provider/amazon-bedrock.test.ts index 084d28dd44..470d42fd1b 100644 --- a/packages/opencode/test/provider/amazon-bedrock.test.ts +++ b/packages/opencode/test/provider/amazon-bedrock.test.ts @@ -180,7 +180,9 @@ it.instance( yield* set("AWS_PROFILE", "default") const providers = yield* list expect(providers[ProviderV2.ID.amazonBedrock]).toBeDefined() - expect(providers[ProviderV2.ID.amazonBedrock].models["global.anthropic.claude-opus-4-5-20251101-v1:0"]).toBeDefined() + expect( + providers[ProviderV2.ID.amazonBedrock].models["global.anthropic.claude-opus-4-5-20251101-v1:0"], + ).toBeDefined() }), { config: { diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index c055476572..2492fb271d 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -245,9 +245,9 @@ it.instance( expect(provider.models["deepseek-r1"].capabilities.interleaved).toEqual({ field: "reasoning_content" }) expect(provider.models["deepseek-details"].capabilities.interleaved).toEqual({ field: "reasoning_details" }) expect(provider.models["custom-model"].capabilities.interleaved).toBe(false) - expect(providers[ProviderV2.ID.make("custom-anthropic-provider")].models["deepseek-r1"].capabilities.interleaved).toBe( - false, - ) + expect( + providers[ProviderV2.ID.make("custom-anthropic-provider")].models["deepseek-r1"].capabilities.interleaved, + ).toBe(false) }), { config: { @@ -305,7 +305,9 @@ it.instance("getModel returns model for valid provider/model", () => it.instance("getModel throws ModelNotFoundError for invalid model", () => Effect.gen(function* () { yield* set("ANTHROPIC_API_KEY", "test-api-key") - const exit = yield* Provider.use.getModel(ProviderV2.ID.anthropic, ProviderV2.ModelID.make("nonexistent-model")).pipe(Effect.exit) + const exit = yield* Provider.use + .getModel(ProviderV2.ID.anthropic, ProviderV2.ModelID.make("nonexistent-model")) + .pipe(Effect.exit) expect(exit._tag).toBe("Failure") }), ) @@ -977,8 +979,14 @@ it.instance( it.instance("getModel returns consistent results", () => Effect.gen(function* () { yield* set("ANTHROPIC_API_KEY", "test-api-key") - const model1 = yield* Provider.use.getModel(ProviderV2.ID.anthropic, ProviderV2.ModelID.make("claude-sonnet-4-20250514")) - const model2 = yield* Provider.use.getModel(ProviderV2.ID.anthropic, ProviderV2.ModelID.make("claude-sonnet-4-20250514")) + const model1 = yield* Provider.use.getModel( + ProviderV2.ID.anthropic, + ProviderV2.ModelID.make("claude-sonnet-4-20250514"), + ) + const model2 = yield* Provider.use.getModel( + ProviderV2.ID.anthropic, + ProviderV2.ModelID.make("claude-sonnet-4-20250514"), + ) expect(model1.providerID).toEqual(model2.providerID) expect(model1.id).toEqual(model2.id) expect(model1).toEqual(model2) @@ -1008,7 +1016,9 @@ it.instance( it.instance("ModelNotFoundError includes suggestions for typos", () => Effect.gen(function* () { yield* set("ANTHROPIC_API_KEY", "test-api-key") - const error = yield* Provider.use.getModel(ProviderV2.ID.anthropic, ProviderV2.ModelID.make("claude-sonet-4")).pipe(Effect.flip) + const error = yield* Provider.use + .getModel(ProviderV2.ID.anthropic, ProviderV2.ModelID.make("claude-sonet-4")) + .pipe(Effect.flip) expect(error.suggestions).toBeDefined() expect((error.suggestions ?? []).length).toBeGreaterThan(0) }), @@ -1565,7 +1575,10 @@ it.instance("Google Vertex: uses REP endpoint for Claude continental multi-regio yield* set("GOOGLE_CLOUD_PROJECT", "test-project") yield* set("VERTEX_LOCATION", "eu") const provider = yield* Provider.Service - const model = yield* provider.getModel(ProviderV2.ID.make("google-vertex"), ProviderV2.ModelID.make("claude-sonnet-4-6@default")) + const model = yield* provider.getModel( + ProviderV2.ID.make("google-vertex"), + ProviderV2.ModelID.make("claude-sonnet-4-6@default"), + ) const language = yield* provider.getLanguage(model) expect(languageBaseURL(language)).toBe( "https://aiplatform.eu.rep.googleapis.com/v1/projects/test-project/locations/eu/publishers/anthropic/models", @@ -1594,7 +1607,10 @@ it.instance("Google Vertex: keeps regional Claude endpoints unchanged", () => yield* set("GOOGLE_CLOUD_PROJECT", "test-project") yield* set("VERTEX_LOCATION", "europe-west1") const provider = yield* Provider.Service - const model = yield* provider.getModel(ProviderV2.ID.make("google-vertex"), ProviderV2.ModelID.make("claude-sonnet-4-6@default")) + const model = yield* provider.getModel( + ProviderV2.ID.make("google-vertex"), + ProviderV2.ModelID.make("claude-sonnet-4-6@default"), + ) const language = yield* provider.getLanguage(model) expect(languageBaseURL(language)).toBe( "https://europe-west1-aiplatform.googleapis.com/v1/projects/test-project/locations/europe-west1/publishers/anthropic/models", diff --git a/packages/opencode/test/pty/ticket.test.ts b/packages/opencode/test/pty/ticket.test.ts index 2a6124f5d6..fa7f9277de 100644 --- a/packages/opencode/test/pty/ticket.test.ts +++ b/packages/opencode/test/pty/ticket.test.ts @@ -50,7 +50,9 @@ describe("PTY websocket tickets", () => { const workspaceID = WorkspaceV2.ID.ascending() const issued = yield* tickets.issue({ ptyID, workspaceID }) - expect(yield* tickets.consume({ ptyID, workspaceID: WorkspaceV2.ID.ascending(), ticket: issued.ticket })).toBe(false) + expect(yield* tickets.consume({ ptyID, workspaceID: WorkspaceV2.ID.ascending(), ticket: issued.ticket })).toBe( + false, + ) expect(yield* tickets.consume({ ptyID, workspaceID, ticket: issued.ticket })).toBe(true) }), ) diff --git a/packages/opencode/test/question/question.test.ts b/packages/opencode/test/question/question.test.ts index 47c71e9e07..5ae076b439 100644 --- a/packages/opencode/test/question/question.test.ts +++ b/packages/opencode/test/question/question.test.ts @@ -14,7 +14,11 @@ const it = testEffect( Layer.mergeAll(Question.layer.pipe(Layer.provideMerge(EventV2Bridge.defaultLayer)), CrossSpawnSpawner.defaultLayer), ) const lifecycle = testEffect( - Layer.mergeAll(Question.layer.pipe(Layer.provideMerge(EventV2Bridge.defaultLayer)), CrossSpawnSpawner.defaultLayer, testInstanceStoreLayer), + Layer.mergeAll( + Question.layer.pipe(Layer.provideMerge(EventV2Bridge.defaultLayer)), + CrossSpawnSpawner.defaultLayer, + testInstanceStoreLayer, + ), ) const askEffect = Effect.fn("QuestionTest.ask")(function* (input: { diff --git a/packages/opencode/test/server/httpapi-event.test.ts b/packages/opencode/test/server/httpapi-event.test.ts index 14e246c2b2..6673415617 100644 --- a/packages/opencode/test/server/httpapi-event.test.ts +++ b/packages/opencode/test/server/httpapi-event.test.ts @@ -94,5 +94,4 @@ describe("event HttpApi", () => { }), { git: true, config: { formatter: false, lsp: false } }, ) - }) diff --git a/packages/opencode/test/server/httpapi-workspace-routing.test.ts b/packages/opencode/test/server/httpapi-workspace-routing.test.ts index f2f72dc5fc..ee27d3e3ac 100644 --- a/packages/opencode/test/server/httpapi-workspace-routing.test.ts +++ b/packages/opencode/test/server/httpapi-workspace-routing.test.ts @@ -170,7 +170,11 @@ const insertRemoteWorkspaceWithoutSync = (input: { const id = WorkspaceV2.ID.ascending() registerAdapter(input.projectID, input.type, remoteAdapter(path.join(input.dir, `.${input.type}`), input.url)) const { db } = yield* Database.Service - yield* db.insert(WorkspaceTable).values({ id, type: input.type, project_id: input.projectID }).run().pipe(Effect.orDie) + yield* db + .insert(WorkspaceTable) + .values({ id, type: input.type, project_id: input.projectID }) + .run() + .pipe(Effect.orDie) return id }) @@ -331,7 +335,9 @@ describe("HttpApi workspace routing middleware", () => { const project = yield* Project.use.fromDirectory(dir) const workspaceID = WorkspaceV2.ID.ascending() const type = "remote-http-fence-target" - const waited = yield* Ref.make<{ workspaceID: WorkspaceV2.ID; state: Record } | undefined>(undefined) + const waited = yield* Ref.make<{ workspaceID: WorkspaceV2.ID; state: Record } | undefined>( + undefined, + ) const remoteUrl = yield* startRemoteWorkspaceHttpServer(() => HttpServerResponse.json( diff --git a/packages/opencode/test/server/session-list.test.ts b/packages/opencode/test/server/session-list.test.ts index 3ae4c2f13e..f98f8e42a9 100644 --- a/packages/opencode/test/server/session-list.test.ts +++ b/packages/opencode/test/server/session-list.test.ts @@ -154,8 +154,18 @@ describe("session.list", () => { ) const { db } = yield* Database.Service - yield* db.update(SessionTable).set({ path: null }).where(eq(SessionTable.id, current.id)).run().pipe(Effect.orDie) - yield* db.update(SessionTable).set({ path: null }).where(eq(SessionTable.id, sibling.id)).run().pipe(Effect.orDie) + yield* db + .update(SessionTable) + .set({ path: null }) + .where(eq(SessionTable.id, current.id)) + .run() + .pipe(Effect.orDie) + yield* db + .update(SessionTable) + .set({ path: null }) + .where(eq(SessionTable.id, sibling.id)) + .run() + .pipe(Effect.orDie) const pathIDs = (yield* SessionNs.Service.use((session) => session.list({ diff --git a/packages/opencode/test/session/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts index c4e6510950..9bff89c348 100644 --- a/packages/opencode/test/session/compaction.test.ts +++ b/packages/opencode/test/session/compaction.test.ts @@ -247,7 +247,12 @@ const env = Layer.mergeAll( const it = testEffect(env) -const compactionEnv = Layer.mergeAll(SessionNs.defaultLayer, Database.defaultLayer, EventV2Bridge.defaultLayer, CrossSpawnSpawner.defaultLayer) +const compactionEnv = Layer.mergeAll( + SessionNs.defaultLayer, + Database.defaultLayer, + EventV2Bridge.defaultLayer, + CrossSpawnSpawner.defaultLayer, +) const itCompaction = testEffect(compactionEnv) type CompactionProcessOptions = { @@ -587,7 +592,6 @@ describe("session.compaction.create", () => { auto: true, overflow: true, }) - }), ), ) @@ -852,7 +856,8 @@ describe("session.compaction.process", () => { let seen = false const unsub = yield* events.listen((evt) => { if (evt.type !== SessionCompaction.Event.Compacted.type) return Effect.void - if ((evt.data as typeof SessionCompaction.Event.Compacted.data.Type).sessionID !== session.id) return Effect.void + if ((evt.data as typeof SessionCompaction.Event.Compacted.data.Type).sessionID !== session.id) + return Effect.void seen = true Deferred.doneUnsafe(done, Effect.void) return Effect.void diff --git a/packages/opencode/test/session/llm.test.ts b/packages/opencode/test/session/llm.test.ts index 8927003023..2376750eea 100644 --- a/packages/opencode/test/session/llm.test.ts +++ b/packages/opencode/test/session/llm.test.ts @@ -1617,7 +1617,10 @@ describe("session.llm.stream", () => { ] const request = waitRequest("/messages", createEventResponse(chunks)) - const resolved = yield* Provider.use.getModel(ProviderV2.ID.make("anthropic"), ProviderV2.ModelID.make(model.id)) + const resolved = yield* Provider.use.getModel( + ProviderV2.ID.make("anthropic"), + ProviderV2.ModelID.make(model.id), + ) const sessionID = SessionID.make("session-test-anthropic-tools") const agent = { name: "test", @@ -1816,7 +1819,10 @@ describe("session.llm.stream", () => { ] const request = waitRequest(pathSuffix, createEventResponse(chunks)) - const resolved = yield* Provider.use.getModel(ProviderV2.ID.make(geminiFixture.providerID), ProviderV2.ModelID.make(model.id)) + const resolved = yield* Provider.use.getModel( + ProviderV2.ID.make(geminiFixture.providerID), + ProviderV2.ModelID.make(model.id), + ) const sessionID = SessionID.make("session-test-4") const agent = { name: "test", diff --git a/packages/opencode/test/session/message-v2.test.ts b/packages/opencode/test/session/message-v2.test.ts index 09d19cda3d..75850e59fb 100644 --- a/packages/opencode/test/session/message-v2.test.ts +++ b/packages/opencode/test/session/message-v2.test.ts @@ -991,7 +991,9 @@ describe("session.message-v2.toModelMessage", () => { const assistantID1 = "m-assistant-1" const assistantID2 = "m-assistant-2" - const aborted = new SessionLegacy.AbortedError({ message: "aborted" }).toObject() as SessionLegacy.Assistant["error"] + const aborted = new SessionLegacy.AbortedError({ + message: "aborted", + }).toObject() as SessionLegacy.Assistant["error"] const input: SessionLegacy.WithParts[] = [ { diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 889d2a6d85..f04925b982 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -539,7 +539,12 @@ noLLMServer.instance.skip( Effect.provide(SessionV2.defaultLayer), ) const { db } = yield* Database.Service - const row = yield* db.select().from(SessionMessageTable).where(eq(SessionMessageTable.session_id, chat.id)).get().pipe(Effect.orDie) + const row = yield* db + .select() + .from(SessionMessageTable) + .where(eq(SessionMessageTable.session_id, chat.id)) + .get() + .pipe(Effect.orDie) expect(messages.find((message) => message.type === "user")).toMatchObject({ type: "user", text: "hello v2" }) expect(typeof row?.data.time.created).toBe("number") expect(messages).toEqual( diff --git a/packages/opencode/test/session/retry.test.ts b/packages/opencode/test/session/retry.test.ts index 32caaa1138..080db82bc0 100644 --- a/packages/opencode/test/session/retry.test.ts +++ b/packages/opencode/test/session/retry.test.ts @@ -87,31 +87,31 @@ describe("session.retry.delay", () => { it.instance("policy updates retry status and increments attempts", () => Effect.gen(function* () { - const sessionID = SessionID.make("session-retry-test") - const error = apiError({ "retry-after-ms": "0" }) - const status = yield* SessionStatus.Service + const sessionID = SessionID.make("session-retry-test") + const error = apiError({ "retry-after-ms": "0" }) + const status = yield* SessionStatus.Service - const step = yield* Schedule.toStepWithMetadata( - SessionRetry.policy({ - provider: "test", - parse: Schema.decodeUnknownSync(SessionLegacy.APIError.Schema), - set: (info) => - status.set(sessionID, { - type: "retry", - attempt: info.attempt, - message: info.message, - next: info.next, - }), - }), - ) - yield* step(error) - yield* step(error) + const step = yield* Schedule.toStepWithMetadata( + SessionRetry.policy({ + provider: "test", + parse: Schema.decodeUnknownSync(SessionLegacy.APIError.Schema), + set: (info) => + status.set(sessionID, { + type: "retry", + attempt: info.attempt, + message: info.message, + next: info.next, + }), + }), + ) + yield* step(error) + yield* step(error) - expect(yield* status.get(sessionID)).toMatchObject({ - type: "retry", - attempt: 2, - message: "boom", - }) + expect(yield* status.get(sessionID)).toMatchObject({ + type: "retry", + attempt: 2, + message: "boom", + }) }), ) }) diff --git a/packages/opencode/test/session/session.test.ts b/packages/opencode/test/session/session.test.ts index 2883a1ab29..06bd6f9528 100644 --- a/packages/opencode/test/session/session.test.ts +++ b/packages/opencode/test/session/session.test.ts @@ -49,7 +49,10 @@ describe("session.created event", () => { const unsub = yield* events.listen((event) => { if (event.type === SessionNs.Event.Created.type) - Deferred.doneUnsafe(received, Effect.succeed((event.data as typeof SessionNs.Event.Created.data.Type).info as SessionNs.Info)) + Deferred.doneUnsafe( + received, + Effect.succeed((event.data as typeof SessionNs.Event.Created.data.Type).info as SessionNs.Info), + ) return Effect.void }) yield* Effect.addFinalizer(() => unsub) @@ -127,7 +130,10 @@ describe("step-finish token propagation via event", () => { const received = yield* Deferred.make() const unsub = yield* events.listen((event) => { if (event.type === MessageV2.Event.PartUpdated.type) - Deferred.doneUnsafe(received, Effect.succeed((event.data as typeof MessageV2.Event.PartUpdated.data.Type).part as SessionLegacy.Part)) + Deferred.doneUnsafe( + received, + Effect.succeed((event.data as typeof MessageV2.Event.PartUpdated.data.Type).part as SessionLegacy.Part), + ) return Effect.void }) yield* Effect.addFinalizer(() => unsub) diff --git a/packages/opencode/test/share/share-next.test.ts b/packages/opencode/test/share/share-next.test.ts index a3662f60c8..feb070a09c 100644 --- a/packages/opencode/test/share/share-next.test.ts +++ b/packages/opencode/test/share/share-next.test.ts @@ -75,7 +75,12 @@ function wired(client: HttpClient.HttpClient) { const share = (id: SessionID) => Effect.gen(function* () { const { db } = yield* Database.Service - return yield* db.select().from(SessionShareTable).where(eq(SessionShareTable.session_id, id)).get().pipe(Effect.orDie) + return yield* db + .select() + .from(SessionShareTable) + .where(eq(SessionShareTable.session_id, id)) + .get() + .pipe(Effect.orDie) }) const seed = (url: string, org?: string) => diff --git a/packages/opencode/test/snapshot/snapshot.test.ts b/packages/opencode/test/snapshot/snapshot.test.ts index 81642f5744..b71577334d 100644 --- a/packages/opencode/test/snapshot/snapshot.test.ts +++ b/packages/opencode/test/snapshot/snapshot.test.ts @@ -6,7 +6,13 @@ import fs from "fs/promises" import path from "path" import { Effect, Fiber, Layer } from "effect" import { Snapshot } from "../../src/snapshot" -import { disposeAllInstances, provideInstance, testInstanceStoreLayer, TestInstance, tmpdirScoped } from "../fixture/fixture" +import { + disposeAllInstances, + provideInstance, + testInstanceStoreLayer, + TestInstance, + tmpdirScoped, +} from "../fixture/fixture" import { testEffect } from "../lib/effect" const it = testEffect(Layer.mergeAll(Snapshot.defaultLayer, AppFileSystem.defaultLayer, testInstanceStoreLayer)) diff --git a/packages/opencode/test/storage/workspace-time-migration.test.ts b/packages/opencode/test/storage/workspace-time-migration.test.ts index 29a09acb7b..a33d6d5e5a 100644 --- a/packages/opencode/test/storage/workspace-time-migration.test.ts +++ b/packages/opencode/test/storage/workspace-time-migration.test.ts @@ -17,7 +17,10 @@ function migrations() { .map((entry) => ({ name: entry.name, timestamp: Number(entry.name.split("_")[0]), - sql: readFileSync(path.join(import.meta.dirname, "../../../core/migration", entry.name, "migration.sql"), "utf-8"), + sql: readFileSync( + path.join(import.meta.dirname, "../../../core/migration", entry.name, "migration.sql"), + "utf-8", + ), })) .sort((a, b) => a.timestamp - b.timestamp) } diff --git a/packages/opencode/test/tool/lsp.test.ts b/packages/opencode/test/tool/lsp.test.ts index f3b1d7efd6..c456ae6cc7 100644 --- a/packages/opencode/test/tool/lsp.test.ts +++ b/packages/opencode/test/tool/lsp.test.ts @@ -102,22 +102,22 @@ describe("tool.lsp", () => { "keeps cursor details for position-based operations", () => Effect.gen(function* () { - const dir = (yield* TestInstance).directory - const file = path.join(dir, "test.ts") - yield* put(file) + const dir = (yield* TestInstance).directory + const file = path.join(dir, "test.ts") + yield* put(file) - const { items, next } = asks() - const result = yield* run({ operation: "goToDefinition", filePath: file, line: 3, character: 7 }, next) - const req = items.find((item) => item.permission === "lsp") + const { items, next } = asks() + const result = yield* run({ operation: "goToDefinition", filePath: file, line: 3, character: 7 }, next) + const req = items.find((item) => item.permission === "lsp") - expect(req).toBeDefined() - expect(req!.metadata).toEqual({ - operation: "goToDefinition", - filePath: file, - line: 3, - character: 7, - }) - expect(result.title).toBe("goToDefinition test.ts:3:7") + expect(req).toBeDefined() + expect(req!.metadata).toEqual({ + operation: "goToDefinition", + filePath: file, + line: 3, + character: 7, + }) + expect(result.title).toBe("goToDefinition test.ts:3:7") }), { git: true }, ) @@ -126,20 +126,20 @@ describe("tool.lsp", () => { "omits cursor details for documentSymbol", () => Effect.gen(function* () { - const dir = (yield* TestInstance).directory - const file = path.join(dir, "test.ts") - yield* put(file) + const dir = (yield* TestInstance).directory + const file = path.join(dir, "test.ts") + yield* put(file) - const { items, next } = asks() - const result = yield* run({ operation: "documentSymbol", filePath: file, line: 3, character: 7 }, next) - const req = items.find((item) => item.permission === "lsp") + const { items, next } = asks() + const result = yield* run({ operation: "documentSymbol", filePath: file, line: 3, character: 7 }, next) + const req = items.find((item) => item.permission === "lsp") - expect(req).toBeDefined() - expect(req!.metadata).toEqual({ - operation: "documentSymbol", - filePath: file, - }) - expect(result.title).toBe("documentSymbol test.ts") + expect(req).toBeDefined() + expect(req!.metadata).toEqual({ + operation: "documentSymbol", + filePath: file, + }) + expect(result.title).toBe("documentSymbol test.ts") }), { git: true }, ) @@ -148,20 +148,20 @@ describe("tool.lsp", () => { "omits file and cursor details for workspaceSymbol", () => Effect.gen(function* () { - const dir = (yield* TestInstance).directory - workspaceSymbolQueries.length = 0 - const file = path.join(dir, "test.ts") - yield* put(file) + const dir = (yield* TestInstance).directory + workspaceSymbolQueries.length = 0 + const file = path.join(dir, "test.ts") + yield* put(file) - const { items, next } = asks() - const result = yield* run({ operation: "workspaceSymbol", filePath: file, line: 3, character: 7 }, next) - const req = items.find((item) => item.permission === "lsp") + const { items, next } = asks() + const result = yield* run({ operation: "workspaceSymbol", filePath: file, line: 3, character: 7 }, next) + const req = items.find((item) => item.permission === "lsp") - expect(req).toBeDefined() - expect(req!.metadata).toEqual({ - operation: "workspaceSymbol", - }) - expect(result.title).toBe("workspaceSymbol") + expect(req).toBeDefined() + expect(req!.metadata).toEqual({ + operation: "workspaceSymbol", + }) + expect(result.title).toBe("workspaceSymbol") }), { git: true }, ) @@ -170,15 +170,15 @@ describe("tool.lsp", () => { "passes workspaceSymbol query to LSP", () => Effect.gen(function* () { - const dir = (yield* TestInstance).directory - workspaceSymbolQueries.length = 0 - const file = path.join(dir, "test.ts") - yield* put(file) + const dir = (yield* TestInstance).directory + workspaceSymbolQueries.length = 0 + const file = path.join(dir, "test.ts") + yield* put(file) - yield* run({ operation: "workspaceSymbol", filePath: file, line: 3, character: 7, query: "TestSymbol" }) - yield* run({ operation: "workspaceSymbol", filePath: file, line: 3, character: 7 }) + yield* run({ operation: "workspaceSymbol", filePath: file, line: 3, character: 7, query: "TestSymbol" }) + yield* run({ operation: "workspaceSymbol", filePath: file, line: 3, character: 7 }) - expect(workspaceSymbolQueries).toEqual(["TestSymbol", ""]) + expect(workspaceSymbolQueries).toEqual(["TestSymbol", ""]) }), { git: true }, ) diff --git a/packages/opencode/test/tool/read.test.ts b/packages/opencode/test/tool/read.test.ts index 4853cbe2ff..b42bd80e71 100644 --- a/packages/opencode/test/tool/read.test.ts +++ b/packages/opencode/test/tool/read.test.ts @@ -15,7 +15,13 @@ import { ReadTool } from "../../src/tool/read" import { Truncate } from "@/tool/truncate" import { Tool } from "@/tool/tool" import { Filesystem } from "@/util/filesystem" -import { disposeAllInstances, provideInstance, testInstanceStoreLayer, TestInstance, tmpdirScoped } from "../fixture/fixture" +import { + disposeAllInstances, + provideInstance, + testInstanceStoreLayer, + TestInstance, + tmpdirScoped, +} from "../fixture/fixture" import { testEffect } from "../lib/effect" import { Reference } from "@/reference/reference" import { RepositoryCache } from "@/reference/repository-cache" diff --git a/packages/opencode/test/tool/repo_clone.test.ts b/packages/opencode/test/tool/repo_clone.test.ts index 75b103e2f5..8b0072bac4 100644 --- a/packages/opencode/test/tool/repo_clone.test.ts +++ b/packages/opencode/test/tool/repo_clone.test.ts @@ -82,153 +82,153 @@ const githubBase = (url: string, self: Effect.Effect) => describe("tool.repo_clone", () => { it.instance("clones a repo into the managed cache and reuses it on subsequent calls", () => Effect.gen(function* () { - const fs = yield* AppFileSystem.Service - const source = yield* tmpdirScoped({ git: true }) - const remoteRoot = yield* tmpdirScoped() - const remoteDir = path.join(remoteRoot, "owner") - const remoteRepo = path.join(remoteDir, "repo.git") + const fs = yield* AppFileSystem.Service + const source = yield* tmpdirScoped({ git: true }) + const remoteRoot = yield* tmpdirScoped() + const remoteDir = path.join(remoteRoot, "owner") + const remoteRepo = path.join(remoteDir, "repo.git") - yield* Effect.promise(() => Bun.write(path.join(source, "README.md"), "v1\n")) - yield* git(source, ["add", "."]) - yield* git(source, ["commit", "-m", "add readme"]) - yield* fs.makeDirectory(remoteDir, { recursive: true }).pipe(Effect.orDie) - yield* git(remoteRoot, ["clone", "--bare", source, remoteRepo]) + yield* Effect.promise(() => Bun.write(path.join(source, "README.md"), "v1\n")) + yield* git(source, ["add", "."]) + yield* git(source, ["commit", "-m", "add readme"]) + yield* fs.makeDirectory(remoteDir, { recursive: true }).pipe(Effect.orDie) + yield* git(remoteRoot, ["clone", "--bare", source, remoteRepo]) - const tool = yield* init() - const cloned = yield* githubBase(`file://${remoteRoot}/`, tool.execute({ repository: "owner/repo" }, ctx)) - const cached = yield* githubBase( - `file://${remoteRoot}/`, - tool.execute({ repository: "https://github.com/owner/repo.git" }, ctx), - ) + const tool = yield* init() + const cloned = yield* githubBase(`file://${remoteRoot}/`, tool.execute({ repository: "owner/repo" }, ctx)) + const cached = yield* githubBase( + `file://${remoteRoot}/`, + tool.execute({ repository: "https://github.com/owner/repo.git" }, ctx), + ) - expect(cloned.metadata.status).toBe("cloned") - expect(cloned.metadata.localPath).toBe(path.join(Global.Path.repos, "github.com", "owner", "repo")) - expect(cached.metadata.status).toBe("cached") - expect(yield* fs.readFileString(path.join(cloned.metadata.localPath, "README.md"))).toBe("v1\n") + expect(cloned.metadata.status).toBe("cloned") + expect(cloned.metadata.localPath).toBe(path.join(Global.Path.repos, "github.com", "owner", "repo")) + expect(cached.metadata.status).toBe("cached") + expect(yield* fs.readFileString(path.join(cloned.metadata.localPath, "README.md"))).toBe("v1\n") }), ) it.instance("refresh updates an existing cached clone", () => Effect.gen(function* () { - const fs = yield* AppFileSystem.Service - const source = yield* tmpdirScoped({ git: true }) - const remoteRoot = yield* tmpdirScoped() - const remoteDir = path.join(remoteRoot, "owner") - const remoteRepo = path.join(remoteDir, "repo.git") + const fs = yield* AppFileSystem.Service + const source = yield* tmpdirScoped({ git: true }) + const remoteRoot = yield* tmpdirScoped() + const remoteDir = path.join(remoteRoot, "owner") + const remoteRepo = path.join(remoteDir, "repo.git") - yield* Effect.promise(() => Bun.write(path.join(source, "README.md"), "v1\n")) - yield* git(source, ["add", "."]) - yield* git(source, ["commit", "-m", "add readme"]) - yield* fs.makeDirectory(remoteDir, { recursive: true }).pipe(Effect.orDie) - yield* git(remoteRoot, ["clone", "--bare", source, remoteRepo]) + yield* Effect.promise(() => Bun.write(path.join(source, "README.md"), "v1\n")) + yield* git(source, ["add", "."]) + yield* git(source, ["commit", "-m", "add readme"]) + yield* fs.makeDirectory(remoteDir, { recursive: true }).pipe(Effect.orDie) + yield* git(remoteRoot, ["clone", "--bare", source, remoteRepo]) - const branch = yield* git(source, ["branch", "--show-current"]) - yield* git(source, ["remote", "add", "origin", remoteRepo]) - yield* git(source, ["push", "-u", "origin", `${branch}:${branch}`]) + const branch = yield* git(source, ["branch", "--show-current"]) + yield* git(source, ["remote", "add", "origin", remoteRepo]) + yield* git(source, ["push", "-u", "origin", `${branch}:${branch}`]) - const tool = yield* init() - const first = yield* githubBase(`file://${remoteRoot}/`, tool.execute({ repository: "owner/repo" }, ctx)) + const tool = yield* init() + const first = yield* githubBase(`file://${remoteRoot}/`, tool.execute({ repository: "owner/repo" }, ctx)) - yield* Effect.promise(() => Bun.write(path.join(source, "README.md"), "v2\n")) - yield* git(source, ["add", "."]) - yield* git(source, ["commit", "-m", "update readme"]) - yield* git(source, ["push", "origin", `${branch}:${branch}`]) + yield* Effect.promise(() => Bun.write(path.join(source, "README.md"), "v2\n")) + yield* git(source, ["add", "."]) + yield* git(source, ["commit", "-m", "update readme"]) + yield* git(source, ["push", "origin", `${branch}:${branch}`]) - const refreshed = yield* githubBase( - `file://${remoteRoot}/`, - tool.execute({ repository: "owner/repo", refresh: true }, ctx), - ) + const refreshed = yield* githubBase( + `file://${remoteRoot}/`, + tool.execute({ repository: "owner/repo", refresh: true }, ctx), + ) - expect(first.metadata.status).toBe("cloned") - expect(refreshed.metadata.status).toBe("refreshed") - expect(yield* fs.readFileString(path.join(first.metadata.localPath, "README.md"))).toBe("v2\n") + expect(first.metadata.status).toBe("cloned") + expect(refreshed.metadata.status).toBe("refreshed") + expect(yield* fs.readFileString(path.join(first.metadata.localPath, "README.md"))).toBe("v2\n") }), ) it.instance("clones a configured branch", () => Effect.gen(function* () { - const fs = yield* AppFileSystem.Service - const source = yield* tmpdirScoped({ git: true }) - const remoteRoot = yield* tmpdirScoped() - const remoteDir = path.join(remoteRoot, "owner") - const remoteRepo = path.join(remoteDir, "repo.git") + const fs = yield* AppFileSystem.Service + const source = yield* tmpdirScoped({ git: true }) + const remoteRoot = yield* tmpdirScoped() + const remoteDir = path.join(remoteRoot, "owner") + const remoteRepo = path.join(remoteDir, "repo.git") - yield* Effect.promise(() => Bun.write(path.join(source, "README.md"), "main\n")) - yield* git(source, ["add", "."]) - yield* git(source, ["commit", "-m", "add readme"]) - yield* git(source, ["checkout", "-b", "docs"]) - yield* Effect.promise(() => Bun.write(path.join(source, "DOCS.md"), "docs\n")) - yield* git(source, ["add", "."]) - yield* git(source, ["commit", "-m", "add docs"]) - yield* fs.makeDirectory(remoteDir, { recursive: true }).pipe(Effect.orDie) - yield* git(remoteRoot, ["clone", "--bare", source, remoteRepo]) + yield* Effect.promise(() => Bun.write(path.join(source, "README.md"), "main\n")) + yield* git(source, ["add", "."]) + yield* git(source, ["commit", "-m", "add readme"]) + yield* git(source, ["checkout", "-b", "docs"]) + yield* Effect.promise(() => Bun.write(path.join(source, "DOCS.md"), "docs\n")) + yield* git(source, ["add", "."]) + yield* git(source, ["commit", "-m", "add docs"]) + yield* fs.makeDirectory(remoteDir, { recursive: true }).pipe(Effect.orDie) + yield* git(remoteRoot, ["clone", "--bare", source, remoteRepo]) - const tool = yield* init() - const result = yield* githubBase( - `file://${remoteRoot}/`, - tool.execute({ repository: "owner/repo", branch: "docs" }, ctx), - ) + const tool = yield* init() + const result = yield* githubBase( + `file://${remoteRoot}/`, + tool.execute({ repository: "owner/repo", branch: "docs" }, ctx), + ) - expect(result.metadata.status).toBe("cloned") - expect(result.metadata.branch).toBe("docs") - expect(yield* fs.readFileString(path.join(result.metadata.localPath, "DOCS.md"))).toBe("docs\n") + expect(result.metadata.status).toBe("cloned") + expect(result.metadata.branch).toBe("docs") + expect(yield* fs.readFileString(path.join(result.metadata.localPath, "DOCS.md"))).toBe("docs\n") }), ) it.instance("rejects invalid repository inputs", () => Effect.gen(function* () { - const dir = (yield* TestInstance).directory - const tool = yield* init() - const inputs = [ - { repository: "not-a-repo", message: "git URL" }, - { repository: "git@github.com:../../../etc/passwd", message: "git URL" }, - { repository: "-u:foo/bar", message: "git URL" }, - { repository: pathToFileURL(path.join(dir, "local.git")).href, message: "Local file" }, - ] + const dir = (yield* TestInstance).directory + const tool = yield* init() + const inputs = [ + { repository: "not-a-repo", message: "git URL" }, + { repository: "git@github.com:../../../etc/passwd", message: "git URL" }, + { repository: "-u:foo/bar", message: "git URL" }, + { repository: pathToFileURL(path.join(dir, "local.git")).href, message: "Local file" }, + ] - yield* Effect.forEach( - inputs, - (input) => - Effect.gen(function* () { - const result = yield* tool.execute({ repository: input.repository }, ctx).pipe(Effect.exit) + yield* Effect.forEach( + inputs, + (input) => + Effect.gen(function* () { + const result = yield* tool.execute({ repository: input.repository }, ctx).pipe(Effect.exit) - expect(Exit.isFailure(result)).toBe(true) - if (Exit.isFailure(result)) { - const error = Cause.squash(result.cause) - expect(error instanceof Error ? error.message : String(error)).toContain(input.message) - } - }), - { discard: true }, - ) + expect(Exit.isFailure(result)).toBe(true) + if (Exit.isFailure(result)) { + const error = Cause.squash(result.cause) + expect(error instanceof Error ? error.message : String(error)).toContain(input.message) + } + }), + { discard: true }, + ) }), ) it.instance("rejects local file repository URLs", () => Effect.gen(function* () { - const source = yield* tmpdirScoped({ git: true }) - const tool = yield* init() - const result = yield* tool.execute({ repository: pathToFileURL(source).href }, ctx).pipe(Effect.exit) + const source = yield* tmpdirScoped({ git: true }) + const tool = yield* init() + const result = yield* tool.execute({ repository: pathToFileURL(source).href }, ctx).pipe(Effect.exit) - expect(Exit.isFailure(result)).toBe(true) - if (Exit.isFailure(result)) { - const error = Cause.squash(result.cause) - expect(error instanceof Error ? error.message : String(error)).toContain("Local file") - } + expect(Exit.isFailure(result)).toBe(true) + if (Exit.isFailure(result)) { + const error = Cause.squash(result.cause) + expect(error instanceof Error ? error.message : String(error)).toContain("Local file") + } }), ) it.instance("rejects invalid branch inputs", () => Effect.gen(function* () { - const tool = yield* init() - const result = yield* tool.execute({ repository: "owner/repo", branch: "bad..branch" }, ctx).pipe(Effect.exit) + const tool = yield* init() + const result = yield* tool.execute({ repository: "owner/repo", branch: "bad..branch" }, ctx).pipe(Effect.exit) - expect(Exit.isFailure(result)).toBe(true) - if (Exit.isFailure(result)) { - const error = Cause.squash(result.cause) - expect(error instanceof Error ? error.message : String(error)).toContain( - "Branch must contain only alphanumeric characters", - ) - } + expect(Exit.isFailure(result)).toBe(true) + if (Exit.isFailure(result)) { + const error = Cause.squash(result.cause) + expect(error instanceof Error ? error.message : String(error)).toContain( + "Branch must contain only alphanumeric characters", + ) + } }), ) }) diff --git a/packages/opencode/test/tool/repo_overview.test.ts b/packages/opencode/test/tool/repo_overview.test.ts index 34c29b717f..953703919f 100644 --- a/packages/opencode/test/tool/repo_overview.test.ts +++ b/packages/opencode/test/tool/repo_overview.test.ts @@ -45,112 +45,112 @@ const init = Effect.fn("RepoOverviewToolTest.init")(function* () { describe("tool.repo_overview", () => { it.instance("summarizes a local repository path", () => Effect.gen(function* () { - const repo = yield* tmpdirScoped({ git: true }) - const fs = yield* AppFileSystem.Service - yield* fs.writeWithDirs( - path.join(repo, "package.json"), - JSON.stringify( - { - name: "example-repo", - main: "dist/index.js", - module: "dist/index.mjs", - types: "dist/index.d.ts", - exports: { - ".": "./dist/index.js", - "./server": "./dist/server.js", - }, - bin: { - example: "./bin/example.js", - }, + const repo = yield* tmpdirScoped({ git: true }) + const fs = yield* AppFileSystem.Service + yield* fs.writeWithDirs( + path.join(repo, "package.json"), + JSON.stringify( + { + name: "example-repo", + main: "dist/index.js", + module: "dist/index.mjs", + types: "dist/index.d.ts", + exports: { + ".": "./dist/index.js", + "./server": "./dist/server.js", }, - null, - 2, - ), - ) - yield* fs.writeWithDirs(path.join(repo, "bun.lock"), "") - yield* fs.writeWithDirs(path.join(repo, "README.md"), "# Example\n") - yield* fs.writeWithDirs(path.join(repo, "src", "index.ts"), "export const value = 1\n") + bin: { + example: "./bin/example.js", + }, + }, + null, + 2, + ), + ) + yield* fs.writeWithDirs(path.join(repo, "bun.lock"), "") + yield* fs.writeWithDirs(path.join(repo, "README.md"), "# Example\n") + yield* fs.writeWithDirs(path.join(repo, "src", "index.ts"), "export const value = 1\n") - const tool = yield* init() - const result = yield* tool.execute({ path: repo }, ctx) + const tool = yield* init() + const result = yield* tool.execute({ path: repo }, ctx) - expect(result.metadata.path).toBe(repo) - expect(result.metadata.ecosystems).toContain("Node.js") - expect(result.metadata.package_manager).toBe("bun") - expect(result.metadata.dependency_files).toEqual(expect.arrayContaining(["package.json", "bun.lock"])) - expect(result.metadata.entrypoints).toEqual( - expect.arrayContaining([ - "main: dist/index.js", - "module: dist/index.mjs", - "types: dist/index.d.ts", - "exports: .", - "exports: ./server", - "bin: example", - "file: src/index.ts", - ]), - ) - expect(result.output).toContain("Top-level structure:") - expect(result.output).toContain("src/") - expect(result.output).toContain("README.md") + expect(result.metadata.path).toBe(repo) + expect(result.metadata.ecosystems).toContain("Node.js") + expect(result.metadata.package_manager).toBe("bun") + expect(result.metadata.dependency_files).toEqual(expect.arrayContaining(["package.json", "bun.lock"])) + expect(result.metadata.entrypoints).toEqual( + expect.arrayContaining([ + "main: dist/index.js", + "module: dist/index.mjs", + "types: dist/index.d.ts", + "exports: .", + "exports: ./server", + "bin: example", + "file: src/index.ts", + ]), + ) + expect(result.output).toContain("Top-level structure:") + expect(result.output).toContain("src/") + expect(result.output).toContain("README.md") }), ) it.instance("resolves relative paths from the instance directory", () => Effect.gen(function* () { - const dir = (yield* TestInstance).directory - const fs = yield* AppFileSystem.Service - yield* fs.writeWithDirs(path.join(dir, "nested", "README.md"), "# Nested\n") + const dir = (yield* TestInstance).directory + const fs = yield* AppFileSystem.Service + yield* fs.writeWithDirs(path.join(dir, "nested", "README.md"), "# Nested\n") - const tool = yield* init() - const result = yield* tool.execute({ path: "nested" }, ctx) + const tool = yield* init() + const result = yield* tool.execute({ path: "nested" }, ctx) - expect(result.metadata.path).toBe(path.join(dir, "nested")) - expect(result.output).toContain("README.md") + expect(result.metadata.path).toBe(path.join(dir, "nested")) + expect(result.output).toContain("README.md") }), ) it.instance("resolves a cached repository from repository shorthand", () => Effect.gen(function* () { - const fs = yield* AppFileSystem.Service - const cached = path.join(Global.Path.repos, "github.com", "owner", "repo") - yield* fs.writeWithDirs(path.join(cached, "package.json"), JSON.stringify({ name: "cached-repo" }, null, 2)) - yield* fs.writeWithDirs(path.join(cached, "README.md"), "cached\n") + const fs = yield* AppFileSystem.Service + const cached = path.join(Global.Path.repos, "github.com", "owner", "repo") + yield* fs.writeWithDirs(path.join(cached, "package.json"), JSON.stringify({ name: "cached-repo" }, null, 2)) + yield* fs.writeWithDirs(path.join(cached, "README.md"), "cached\n") - const tool = yield* init() - const result = yield* tool.execute({ repository: "owner/repo" }, ctx) + const tool = yield* init() + const result = yield* tool.execute({ repository: "owner/repo" }, ctx) - expect(result.metadata.path).toBe(cached) - expect(result.metadata.repository).toBe("owner/repo") - expect(result.output).toContain("Repository: owner/repo") - expect(result.output).toContain(`Path: ${cached}`) + expect(result.metadata.path).toBe(cached) + expect(result.metadata.repository).toBe("owner/repo") + expect(result.output).toContain("Repository: owner/repo") + expect(result.output).toContain(`Path: ${cached}`) }), ) it.instance("fails clearly when a repository is not cloned", () => Effect.gen(function* () { - const tool = yield* init() - const result = yield* tool.execute({ repository: "missing/repo" }, ctx).pipe(Effect.exit) + const tool = yield* init() + const result = yield* tool.execute({ repository: "missing/repo" }, ctx).pipe(Effect.exit) - expect(Exit.isFailure(result)).toBe(true) - if (Exit.isFailure(result)) { - const error = Cause.squash(result.cause) - expect(error instanceof Error ? error.message : String(error)).toContain("Use repo_clone first") - } + expect(Exit.isFailure(result)).toBe(true) + if (Exit.isFailure(result)) { + const error = Cause.squash(result.cause) + expect(error instanceof Error ? error.message : String(error)).toContain("Use repo_clone first") + } }), ) it.instance("resolves cached repositories from host/path references", () => Effect.gen(function* () { - const fs = yield* AppFileSystem.Service - const cached = path.join(Global.Path.repos, "gitlab.com", "group", "repo") - yield* fs.writeWithDirs(path.join(cached, "README.md"), "cached\n") + const fs = yield* AppFileSystem.Service + const cached = path.join(Global.Path.repos, "gitlab.com", "group", "repo") + yield* fs.writeWithDirs(path.join(cached, "README.md"), "cached\n") - const tool = yield* init() - const result = yield* tool.execute({ repository: "gitlab.com/group/repo" }, ctx) + const tool = yield* init() + const result = yield* tool.execute({ repository: "gitlab.com/group/repo" }, ctx) - expect(result.metadata.path).toBe(cached) - expect(result.metadata.repository).toBe("gitlab.com/group/repo") - expect(result.output).toContain("Repository: gitlab.com/group/repo") + expect(result.metadata.path).toBe(cached) + expect(result.metadata.repository).toBe("gitlab.com/group/repo") + expect(result.output).toContain("Repository: gitlab.com/group/repo") }), ) }) diff --git a/packages/opencode/test/tool/skill.test.ts b/packages/opencode/test/tool/skill.test.ts index f96730094f..73f1ae1805 100644 --- a/packages/opencode/test/tool/skill.test.ts +++ b/packages/opencode/test/tool/skill.test.ts @@ -32,12 +32,12 @@ const it = testEffect(Layer.mergeAll(ToolRegistry.defaultLayer, node)) describe("tool.skill", () => { it.instance("execute returns skill content block with files", () => Effect.gen(function* () { - const dir = (yield* TestInstance).directory - const skill = path.join(dir, ".opencode", "skill", "tool-skill") - yield* Effect.promise(() => - Bun.write( - path.join(skill, "SKILL.md"), - `--- + const dir = (yield* TestInstance).directory + const skill = path.join(dir, ".opencode", "skill", "tool-skill") + yield* Effect.promise(() => + Bun.write( + path.join(skill, "SKILL.md"), + `--- name: tool-skill description: Skill for tool tests. --- @@ -46,86 +46,86 @@ description: Skill for tool tests. Use this skill. `, - ), - ) - yield* Effect.promise(() => Bun.write(path.join(skill, "scripts", "demo.txt"), "demo")) + ), + ) + yield* Effect.promise(() => Bun.write(path.join(skill, "scripts", "demo.txt"), "demo")) - const home = process.env.OPENCODE_TEST_HOME - process.env.OPENCODE_TEST_HOME = dir - yield* Effect.addFinalizer(() => + const home = process.env.OPENCODE_TEST_HOME + process.env.OPENCODE_TEST_HOME = dir + yield* Effect.addFinalizer(() => + Effect.sync(() => { + process.env.OPENCODE_TEST_HOME = home + }), + ) + + const registry = yield* ToolRegistry.Service + const agent = { name: "build", mode: "primary" as const, permission: [], options: {} } + const tool = (yield* registry.tools({ + providerID: "opencode" as any, + modelID: "gpt-5" as any, + agent, + })).find((tool) => tool.id === SkillTool.id) + if (!tool) throw new Error("Skill tool not found") + + const requests: Array> = [] + const ctx: Tool.Context = { + ...baseCtx, + ask: (req) => Effect.sync(() => { - process.env.OPENCODE_TEST_HOME = home + requests.push(req) }), - ) + } - const registry = yield* ToolRegistry.Service - const agent = { name: "build", mode: "primary" as const, permission: [], options: {} } - const tool = (yield* registry.tools({ - providerID: "opencode" as any, - modelID: "gpt-5" as any, - agent, - })).find((tool) => tool.id === SkillTool.id) - if (!tool) throw new Error("Skill tool not found") + const result = yield* tool.execute({ name: "tool-skill" }, ctx) + const file = path.resolve(skill, "scripts", "demo.txt") - const requests: Array> = [] - const ctx: Tool.Context = { - ...baseCtx, - ask: (req) => - Effect.sync(() => { - requests.push(req) - }), - } - - const result = yield* tool.execute({ name: "tool-skill" }, ctx) - const file = path.resolve(skill, "scripts", "demo.txt") - - expect(requests.length).toBe(1) - expect(requests[0].permission).toBe("skill") - expect(requests[0].patterns).toContain("tool-skill") - expect(requests[0].always).toContain("tool-skill") - expect(result.metadata.dir).toBe(skill) - expect(result.output).toContain(``) - expect(result.output).toContain(`Base directory for this skill: ${pathToFileURL(skill).href}`) - expect(result.output).toContain(`${file}`) + expect(requests.length).toBe(1) + expect(requests[0].permission).toBe("skill") + expect(requests[0].patterns).toContain("tool-skill") + expect(requests[0].always).toContain("tool-skill") + expect(result.metadata.dir).toBe(skill) + expect(result.output).toContain(``) + expect(result.output).toContain(`Base directory for this skill: ${pathToFileURL(skill).href}`) + expect(result.output).toContain(`${file}`) }), ) it.instance("execute preserves not found message", () => Effect.gen(function* () { - const dir = (yield* TestInstance).directory - const home = process.env.OPENCODE_TEST_HOME - process.env.OPENCODE_TEST_HOME = dir - yield* Effect.addFinalizer(() => - Effect.sync(() => { - process.env.OPENCODE_TEST_HOME = home - }), + const dir = (yield* TestInstance).directory + const home = process.env.OPENCODE_TEST_HOME + process.env.OPENCODE_TEST_HOME = dir + yield* Effect.addFinalizer(() => + Effect.sync(() => { + process.env.OPENCODE_TEST_HOME = home + }), + ) + + const registry = yield* ToolRegistry.Service + const agent = { name: "build", mode: "primary" as const, permission: [], options: {} } + const tool = (yield* registry.tools({ + providerID: "opencode" as any, + modelID: "gpt-5" as any, + agent, + })).find((tool) => tool.id === SkillTool.id) + if (!tool) throw new Error("Skill tool not found") + + const exit = yield* tool + .execute( + { name: "missing-skill" }, + { + ...baseCtx, + ask: () => Effect.void, + }, ) + .pipe(Effect.exit) - const registry = yield* ToolRegistry.Service - const agent = { name: "build", mode: "primary" as const, permission: [], options: {} } - const tool = (yield* registry.tools({ - providerID: "opencode" as any, - modelID: "gpt-5" as any, - agent, - })).find((tool) => tool.id === SkillTool.id) - if (!tool) throw new Error("Skill tool not found") - - const exit = yield* tool - .execute( - { name: "missing-skill" }, - { - ...baseCtx, - ask: () => Effect.void, - }, - ) - .pipe(Effect.exit) - - expect(Exit.isFailure(exit)).toBe(true) - if (Exit.isFailure(exit)) { - const error = Cause.squash(exit.cause) - expect(error).toBeInstanceOf(Error) - if (error instanceof Error) expect(error.message).toContain('Skill "missing-skill" not found.') - } + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) { + const error = Cause.squash(exit.cause) + expect(error).toBeInstanceOf(Error) + if (error instanceof Error) expect(error.message).toContain('Skill "missing-skill" not found.') + } }), ) }) diff --git a/packages/opencode/test/v2/session-message-updater.test.ts b/packages/opencode/test/v2/session-message-updater.test.ts index a8d69c7bef..365a8af20f 100644 --- a/packages/opencode/test/v2/session-message-updater.test.ts +++ b/packages/opencode/test/v2/session-message-updater.test.ts @@ -12,39 +12,43 @@ test.skip("step snapshots carry over to assistant messages", () => { const state: SessionMessageUpdater.MemoryState = { messages: [] } const sessionID = SessionID.make("session") - Effect.runSync(SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { - id: EventV2.ID.create(), - type: "session.next.step.started", - data: { - sessionID, - timestamp: DateTime.makeUnsafe(1), - agent: "build", - model: { - id: ModelV2.ID.make("model"), - providerID: ProviderV2.ID.make("provider"), - variant: ModelV2.VariantID.make("default"), + Effect.runSync( + SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { + id: EventV2.ID.create(), + type: "session.next.step.started", + data: { + sessionID, + timestamp: DateTime.makeUnsafe(1), + agent: "build", + model: { + id: ModelV2.ID.make("model"), + providerID: ProviderV2.ID.make("provider"), + variant: ModelV2.VariantID.make("default"), + }, + snapshot: "before", }, - snapshot: "before", - }, - } satisfies SessionEvent.Event)) + } satisfies SessionEvent.Event), + ) - Effect.runSync(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 }, + Effect.runSync( + 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", }, - snapshot: "after", - }, - } satisfies SessionEvent.Event)) + } satisfies SessionEvent.Event), + ) expect(state.messages[0]?.type).toBe("assistant") if (state.messages[0]?.type !== "assistant") return @@ -56,39 +60,45 @@ test.skip("text ended populates assistant text content", () => { const state: SessionMessageUpdater.MemoryState = { messages: [] } const sessionID = SessionID.make("session") - Effect.runSync(SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { - id: EventV2.ID.create(), - type: "session.next.step.started", - data: { - sessionID, - timestamp: DateTime.makeUnsafe(1), - agent: "build", - model: { - id: ModelV2.ID.make("model"), - providerID: ProviderV2.ID.make("provider"), - variant: ModelV2.VariantID.make("default"), + Effect.runSync( + SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { + id: EventV2.ID.create(), + type: "session.next.step.started", + data: { + sessionID, + timestamp: DateTime.makeUnsafe(1), + agent: "build", + model: { + id: ModelV2.ID.make("model"), + providerID: ProviderV2.ID.make("provider"), + variant: ModelV2.VariantID.make("default"), + }, }, - }, - } satisfies SessionEvent.Event)) + } satisfies SessionEvent.Event), + ) - Effect.runSync(SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { - id: EventV2.ID.create(), - type: "session.next.text.started", - data: { - sessionID, - timestamp: DateTime.makeUnsafe(2), - }, - } satisfies SessionEvent.Event)) + Effect.runSync( + SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { + id: EventV2.ID.create(), + type: "session.next.text.started", + data: { + sessionID, + timestamp: DateTime.makeUnsafe(2), + }, + } satisfies SessionEvent.Event), + ) - Effect.runSync(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)) + Effect.runSync( + 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 @@ -100,57 +110,65 @@ test.skip("tool completion stores completed timestamp", () => { const sessionID = SessionID.make("session") const callID = "call" - Effect.runSync(SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { - id: EventV2.ID.create(), - type: "session.next.step.started", - data: { - sessionID, - timestamp: DateTime.makeUnsafe(1), - agent: "build", - model: { - id: ModelV2.ID.make("model"), - providerID: ProviderV2.ID.make("provider"), - variant: ModelV2.VariantID.make("default"), + Effect.runSync( + SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { + id: EventV2.ID.create(), + type: "session.next.step.started", + data: { + sessionID, + timestamp: DateTime.makeUnsafe(1), + agent: "build", + model: { + id: ModelV2.ID.make("model"), + providerID: ProviderV2.ID.make("provider"), + variant: ModelV2.VariantID.make("default"), + }, }, - }, - } satisfies SessionEvent.Event)) + } satisfies SessionEvent.Event), + ) - Effect.runSync(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)) + Effect.runSync( + 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), + ) - Effect.runSync(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)) + Effect.runSync( + 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), + ) - Effect.runSync(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)) + Effect.runSync( + 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 @@ -165,46 +183,54 @@ test.skip("compaction events reduce to compaction message", () => { const sessionID = SessionID.make("session") const id = EventV2.ID.create() - Effect.runSync(SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { - id, - type: "session.next.compaction.started", - data: { - sessionID, - timestamp: DateTime.makeUnsafe(1), - reason: "auto", - }, - } satisfies SessionEvent.Event)) + Effect.runSync( + SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { + id, + type: "session.next.compaction.started", + data: { + sessionID, + timestamp: DateTime.makeUnsafe(1), + reason: "auto", + }, + } satisfies SessionEvent.Event), + ) - Effect.runSync(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)) + Effect.runSync( + 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), + ) - Effect.runSync(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)) + Effect.runSync( + 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), + ) - Effect.runSync(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)) + Effect.runSync( + 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({ diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 6c5ea3725e..be1e4abc65 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -25,10 +25,10 @@ import type { ConfigUpdateErrors, ConfigUpdateResponses, EventSubscribeResponses, - EventTuiCommandExecute2, - EventTuiPromptAppend2, - EventTuiSessionSelect2, - EventTuiToastShow2, + EventTuiCommandExecute, + EventTuiPromptAppend, + EventTuiSessionSelect, + EventTuiToastShow, ExperimentalConsoleGetErrors, ExperimentalConsoleGetResponses, ExperimentalConsoleListOrgsErrors, @@ -4950,7 +4950,7 @@ export class Tui extends HeyApiClient { parameters?: { directory?: string workspace?: string - body?: EventTuiPromptAppend2 | EventTuiCommandExecute2 | EventTuiToastShow2 | EventTuiSessionSelect2 + body?: EventTuiPromptAppend | EventTuiCommandExecute | EventTuiToastShow | EventTuiSessionSelect }, options?: Options, ) { diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index a1a720688c..b19a5f2440 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -5,52 +5,10 @@ export type ClientOptions = { } export type Event = - | EventTuiPromptAppend - | EventTuiCommandExecute - | EventTuiToastShow1 - | EventTuiSessionSelect - | EventServerConnected - | EventGlobalDisposed - | EventServerInstanceDisposed + | EventModelsDevRefreshed + | EventPluginAdded + | EventCatalogModelUpdated | EventFileEdited - | EventFileWatcherUpdated - | EventLspClientDiagnostics - | EventLspUpdated - | EventMessagePartDelta - | EventPermissionAsked - | EventPermissionReplied - | EventSessionDiff - | EventSessionError - | EventQuestionAsked - | EventQuestionReplied - | EventQuestionRejected - | EventTodoUpdated - | EventSessionStatus - | EventSessionIdle - | EventMcpToolsChanged - | EventMcpBrowserOpenFailed - | EventCommandExecuted - | EventProjectUpdated - | EventSessionCompacted - | EventVcsBranchUpdated - | EventWorkspaceReady - | EventWorkspaceFailed - | EventWorkspaceStatus - | EventWorktreeReady - | EventWorktreeFailed - | EventPtyCreated - | EventPtyUpdated - | EventPtyExited - | EventPtyDeleted - | EventInstallationUpdated - | EventInstallationUpdateAvailable - | EventMessageUpdated - | EventMessageRemoved - | EventMessagePartUpdated - | EventMessagePartRemoved - | EventSessionCreated - | EventSessionUpdated - | EventSessionDeleted | EventSessionNextAgentSwitched | EventSessionNextModelSwitched | EventSessionNextPrompted @@ -77,9 +35,49 @@ export type Event = | EventSessionNextCompactionStarted | EventSessionNextCompactionDelta | EventSessionNextCompactionEnded - | EventPluginAdded - | EventCatalogModelUpdated - | EventModelsDevRefreshed + | EventFileWatcherUpdated + | EventSessionCreated + | EventSessionUpdated + | EventSessionDeleted + | EventMessageUpdated + | EventMessageRemoved + | EventMessagePartUpdated + | EventMessagePartRemoved + | EventMessagePartDelta + | EventPermissionAsked + | EventPermissionReplied + | EventSessionDiff + | EventSessionError + | EventQuestionAsked + | EventQuestionReplied + | EventQuestionRejected + | EventTodoUpdated + | EventSessionStatus + | EventSessionIdle + | EventSessionCompacted + | EventLspUpdated + | EventTuiPromptAppend2 + | EventTuiCommandExecute2 + | EventTuiToastShow2 + | EventTuiSessionSelect2 + | EventMcpToolsChanged + | EventMcpBrowserOpenFailed + | EventCommandExecuted + | EventProjectUpdated + | EventVcsBranchUpdated + | EventWorkspaceReady + | EventWorkspaceFailed + | EventWorkspaceStatus + | EventWorktreeReady + | EventWorktreeFailed + | EventPtyCreated + | EventPtyUpdated + | EventPtyExited + | EventPtyDeleted + | EventInstallationUpdated + | EventInstallationUpdateAvailable + | EventServerConnected + | EventGlobalDisposed | EventAccountAdded | EventAccountRemoved | EventAccountSwitched @@ -120,74 +118,11 @@ export type InvalidRequestError = { field?: string } -export type EventTuiPromptAppend = { - id: string - type: "tui.prompt.append" - properties: { - text: string - } -} - -export type EventTuiCommandExecute = { - id: string - 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 EventTuiToastShow = { - id: string - type: "tui.toast.show" - properties: { - title?: string - message: string - variant: "info" | "success" | "warning" | "error" - duration?: number - } -} - -export type EventTuiSessionSelect = { - id: string - type: "tui.session.select" - properties: { - /** - * Session ID to navigate to - */ - sessionID: string - } -} - -export type PermissionRequest = { - id: string - sessionID: string - permission: string - patterns: Array - metadata: { - [key: string]: unknown - } - always: Array - tool?: { - messageID: string - callID: string - } +export type Prompt = { + text: string + files?: Array + agents?: Array + references?: Array } export type SnapshotFileDiff = { @@ -198,6 +133,100 @@ export type SnapshotFileDiff = { status?: "added" | "deleted" | "modified" } +export type Session = { + id: string + slug: string + projectID: string + workspaceID?: string + directory: string + path?: string + parentID?: string + summary?: { + additions: number + deletions: number + files: number + diffs?: Array + } + cost?: number + tokens?: { + input: number + output: number + reasoning: number + cache: { + read: number + write: number + } + } + share?: { + url: string + } + title: string + agent?: string + model?: { + id: string + providerID: string + variant?: string + } + version: string + metadata?: { + [key: string]: unknown + } + time: { + created: number + updated: number + compacting?: number + archived?: number + } + permission?: PermissionV2Ruleset + revert?: { + messageID: string + partID?: string + snapshot?: string + diff?: string + } +} + +export type OutputFormatText = { + type: "text" +} + +export type JsonSchema = { + [key: string]: unknown +} + +export type OutputFormatJsonSchema = { + type: "json_schema" + schema: JsonSchema + retryCount?: number +} + +export type OutputFormat = OutputFormatText | OutputFormatJsonSchema + +export type UserMessage = { + id: string + sessionID: string + role: "user" + time: { + created: number + } + format?: OutputFormat + summary?: { + title?: string + body?: string + diffs: Array + } + agent: string + model: { + providerID: string + modelID: string + variant?: string + } + system?: string + tools?: { + [key: string]: boolean + } +} + export type ProviderAuthError = { name: "ProviderAuthError" data: { @@ -260,174 +289,6 @@ export type ApiError = { } } -export type QuestionOption = { - /** - * Display text (1-5 words, concise) - */ - label: string - /** - * Explanation of choice - */ - description: string -} - -export type QuestionInfo = { - /** - * Complete question - */ - question: string - /** - * Very short label (max 30 chars) - */ - header: string - /** - * Available choices - */ - options: Array - multiple?: boolean - custom?: boolean -} - -export type QuestionTool = { - messageID: string - callID: string -} - -export type QuestionRequest = { - id: string - sessionID: string - /** - * Questions to ask - */ - questions: Array - tool?: QuestionTool -} - -export type QuestionAnswer = Array - -export type QuestionReplied = { - sessionID: string - requestID: string - answers: Array -} - -export type QuestionRejected = { - sessionID: string - requestID: string -} - -export type Todo = { - /** - * Brief description of the task - */ - content: string - /** - * Current status of the task: pending, in_progress, completed, cancelled - */ - status: string - /** - * Priority level of the task: high, medium, low - */ - priority: string -} - -export type SessionStatus = - | { - type: "idle" - } - | { - type: "retry" - attempt: number - message: string - action?: { - reason: string - provider: string - title: string - message: string - label: string - link?: string - } - next: number - } - | { - type: "busy" - } - -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 Pty = { - id: string - title: string - command: string - args: Array - cwd: string - status: "running" | "exited" - pid: number -} - -export type OutputFormatText = { - type: "text" -} - -export type JsonSchema = { - [key: string]: unknown -} - -export type OutputFormatJsonSchema = { - type: "json_schema" - schema: JsonSchema - retryCount?: number -} - -export type OutputFormat = OutputFormatText | OutputFormatJsonSchema - -export type UserMessage = { - id: string - sessionID: string - role: "user" - time: { - created: number - } - format?: OutputFormat - summary?: { - title?: string - body?: string - diffs: Array - } - agent: string - model: { - providerID: string - modelID: string - variant?: string - } - system?: string - tools?: { - [key: string]: boolean - } -} - export type AssistantMessage = { id: string sessionID: string @@ -735,74 +596,86 @@ export type Part = | RetryPart | CompactionPart -export type PermissionAction = "allow" | "deny" | "ask" - -export type PermissionRule = { - permission: string - pattern: string - action: PermissionAction +export type QuestionOption = { + /** + * Display text (1-5 words, concise) + */ + label: string + /** + * Explanation of choice + */ + description: string } -export type PermissionRuleset = Array +export type QuestionInfo = { + /** + * Complete question + */ + question: string + /** + * Very short label (max 30 chars) + */ + header: string + /** + * Available choices + */ + options: Array + multiple?: boolean + custom?: boolean +} -export type Session = { - id: string - slug: string - projectID: string - workspaceID?: string - directory: string - path?: string - parentID?: string - summary?: { - additions: number - deletions: number - files: number - diffs?: Array - } - cost?: number - tokens?: { - input: number - output: number - reasoning: number - cache: { - read: number - write: number +export type QuestionTool = { + messageID: string + callID: string +} + +export type QuestionAnswer = Array + +export type Todo = { + /** + * Brief description of the task + */ + content: string + /** + * Current status of the task: pending, in_progress, completed, cancelled + */ + status: string + /** + * Priority level of the task: high, medium, low + */ + priority: string +} + +export type SessionStatus = + | { + type: "idle" + } + | { + type: "retry" + attempt: number + message: string + action?: { + reason: string + provider: string + title: string + message: string + label: string + link?: string + } + next: number + } + | { + type: "busy" } - } - share?: { - url: string - } - title: string - agent?: string - model?: { - id: string - providerID: string - variant?: string - } - version: string - metadata?: { - [key: string]: unknown - } - time: { - created: number - updated: number - compacting?: number - archived?: number - } - permission?: PermissionRuleset - revert?: { - messageID: string - partID?: string - snapshot?: string - diff?: string - } -} -export type Prompt = { - text: string - files?: Array - agents?: Array - references?: Array +export type Pty = { + id: string + title: string + command: string + args: Array + cwd: string + status: "running" | "exited" + pid: number } export type GlobalEvent = { @@ -810,117 +683,726 @@ export type GlobalEvent = { project?: string workspace?: string payload: - | EventTuiPromptAppend - | EventTuiCommandExecute - | EventTuiToastShow - | EventTuiSessionSelect - | EventServerConnected - | EventGlobalDisposed - | EventServerInstanceDisposed - | EventFileEdited - | EventFileWatcherUpdated - | EventLspClientDiagnostics - | EventLspUpdated - | EventMessagePartDelta - | EventPermissionAsked - | EventPermissionReplied - | EventSessionDiff - | EventSessionError - | EventQuestionAsked - | EventQuestionReplied - | EventQuestionRejected - | EventTodoUpdated - | EventSessionStatus - | EventSessionIdle - | EventMcpToolsChanged - | EventMcpBrowserOpenFailed - | EventCommandExecuted - | EventProjectUpdated - | EventSessionCompacted - | EventVcsBranchUpdated - | EventWorkspaceReady - | EventWorkspaceFailed - | EventWorkspaceStatus - | EventWorktreeReady - | EventWorktreeFailed - | EventPtyCreated - | EventPtyUpdated - | EventPtyExited - | EventPtyDeleted - | EventInstallationUpdated - | EventInstallationUpdateAvailable - | EventMessageUpdated - | EventMessageRemoved - | EventMessagePartUpdated - | EventMessagePartRemoved - | EventSessionCreated - | EventSessionUpdated - | EventSessionDeleted - | EventSessionNextAgentSwitched - | EventSessionNextModelSwitched - | EventSessionNextPrompted - | EventSessionNextSynthetic - | EventSessionNextShellStarted - | EventSessionNextShellEnded - | EventSessionNextStepStarted - | EventSessionNextStepEnded - | EventSessionNextStepFailed - | EventSessionNextTextStarted - | EventSessionNextTextDelta - | EventSessionNextTextEnded - | EventSessionNextReasoningStarted - | EventSessionNextReasoningDelta - | EventSessionNextReasoningEnded - | EventSessionNextToolInputStarted - | EventSessionNextToolInputDelta - | EventSessionNextToolInputEnded - | EventSessionNextToolCalled - | EventSessionNextToolProgress - | EventSessionNextToolSuccess - | EventSessionNextToolFailed - | EventSessionNextRetried - | EventSessionNextCompactionStarted - | EventSessionNextCompactionDelta - | EventSessionNextCompactionEnded - | EventPluginAdded - | EventCatalogModelUpdated - | EventModelsDevRefreshed - | EventAccountAdded - | EventAccountRemoved - | EventAccountSwitched - | SyncEventMessageUpdated - | SyncEventMessageRemoved - | SyncEventMessagePartUpdated - | SyncEventMessagePartRemoved - | SyncEventSessionCreated - | SyncEventSessionUpdated - | SyncEventSessionDeleted - | SyncEventSessionNextAgentSwitched - | SyncEventSessionNextModelSwitched - | SyncEventSessionNextPrompted - | SyncEventSessionNextSynthetic - | SyncEventSessionNextShellStarted - | SyncEventSessionNextShellEnded - | SyncEventSessionNextStepStarted - | SyncEventSessionNextStepEnded - | SyncEventSessionNextStepFailed - | SyncEventSessionNextTextStarted - | SyncEventSessionNextTextDelta - | SyncEventSessionNextTextEnded - | SyncEventSessionNextReasoningStarted - | SyncEventSessionNextReasoningDelta - | SyncEventSessionNextReasoningEnded - | SyncEventSessionNextToolInputStarted - | SyncEventSessionNextToolInputDelta - | SyncEventSessionNextToolInputEnded - | SyncEventSessionNextToolCalled - | SyncEventSessionNextToolProgress - | SyncEventSessionNextToolSuccess - | SyncEventSessionNextToolFailed - | SyncEventSessionNextRetried - | SyncEventSessionNextCompactionStarted - | SyncEventSessionNextCompactionDelta - | SyncEventSessionNextCompactionEnded + | { + id: string + type: "models-dev.refreshed" + properties: { + [key: string]: unknown + } + } + | { + id: string + type: "plugin.added" + properties: { + id: string + } + } + | { + id: string + type: "catalog.model.updated" + properties: { + model: ModelV2Info + } + } + | { + id: string + type: "file.edited" + properties: { + file: string + } + } + | { + id: string + type: "session.next.agent.switched" + properties: { + timestamp: number + sessionID: string + agent: string + } + } + | { + id: string + type: "session.next.model.switched" + properties: { + timestamp: number + sessionID: string + model: { + id: string + providerID: string + variant?: string + } + } + } + | { + id: string + type: "session.next.prompted" + properties: { + timestamp: number + sessionID: string + prompt: Prompt + } + } + | { + id: string + type: "session.next.synthetic" + properties: { + timestamp: number + sessionID: string + text: string + } + } + | { + id: string + type: "session.next.shell.started" + properties: { + timestamp: number + sessionID: string + callID: string + command: string + } + } + | { + id: string + type: "session.next.shell.ended" + properties: { + timestamp: number + sessionID: string + callID: string + output: string + } + } + | { + id: string + type: "session.next.step.started" + properties: { + timestamp: number + sessionID: string + agent: string + model: { + id: string + providerID: string + variant?: string + } + snapshot?: string + } + } + | { + 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 + } + } + | { + id: string + type: "session.next.step.failed" + properties: { + timestamp: number + sessionID: string + error: SessionErrorUnknown + } + } + | { + id: string + type: "session.next.text.started" + properties: { + timestamp: number + sessionID: string + } + } + | { + id: string + type: "session.next.text.delta" + properties: { + timestamp: number + sessionID: string + delta: string + } + } + | { + id: string + type: "session.next.text.ended" + properties: { + timestamp: number + sessionID: string + text: string + } + } + | { + id: string + type: "session.next.reasoning.started" + properties: { + timestamp: number + sessionID: string + reasoningID: string + } + } + | { + id: string + type: "session.next.reasoning.delta" + properties: { + timestamp: number + sessionID: string + reasoningID: string + delta: string + } + } + | { + id: string + type: "session.next.reasoning.ended" + properties: { + timestamp: number + sessionID: string + reasoningID: string + text: string + } + } + | { + id: string + type: "session.next.tool.input.started" + properties: { + timestamp: number + sessionID: string + callID: string + name: string + } + } + | { + id: string + type: "session.next.tool.input.delta" + properties: { + timestamp: number + sessionID: string + callID: string + delta: string + } + } + | { + id: string + type: "session.next.tool.input.ended" + properties: { + timestamp: number + sessionID: string + callID: string + text: string + } + } + | { + 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 + } + } + } + } + | { + id: string + type: "session.next.tool.progress" + properties: { + timestamp: number + sessionID: string + callID: string + structured: { + [key: string]: unknown + } + content: Array + } + } + | { + 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 + } + } + } + } + | { + id: string + type: "session.next.tool.failed" + properties: { + timestamp: number + sessionID: string + callID: string + error: SessionErrorUnknown + provider: { + executed: boolean + metadata?: { + [key: string]: unknown + } + } + } + } + | { + id: string + type: "session.next.retried" + properties: { + timestamp: number + sessionID: string + attempt: number + error: SessionNextRetryError + } + } + | { + id: string + type: "session.next.compaction.started" + properties: { + timestamp: number + sessionID: string + reason: "auto" | "manual" + } + } + | { + id: string + type: "session.next.compaction.delta" + properties: { + timestamp: number + sessionID: string + text: string + } + } + | { + id: string + type: "session.next.compaction.ended" + properties: { + timestamp: number + sessionID: string + text: string + include?: string + } + } + | { + id: string + type: "file.watcher.updated" + properties: { + file: string + event: "add" | "change" | "unlink" + } + } + | { + id: string + type: "session.created" + properties: { + sessionID: string + info: Session + } + } + | { + id: string + type: "session.updated" + properties: { + sessionID: string + info: Session + } + } + | { + id: string + type: "session.deleted" + properties: { + sessionID: string + info: Session + } + } + | { + id: string + type: "message.updated" + properties: { + sessionID: string + info: Message + } + } + | { + id: string + type: "message.removed" + properties: { + sessionID: string + messageID: string + } + } + | { + id: string + type: "message.part.updated" + properties: { + sessionID: string + part: Part + time: number + } + } + | { + id: string + type: "message.part.removed" + properties: { + sessionID: string + messageID: string + partID: string + } + } + | { + id: string + type: "message.part.delta" + properties: { + sessionID: string + messageID: string + partID: string + field: string + delta: string + } + } + | { + id: string + type: "permission.asked" + properties: { + id: string + sessionID: string + permission: string + patterns: Array + metadata: { + [key: string]: unknown + } + always: Array + tool?: { + messageID: string + callID: string + } + } + } + | { + id: string + type: "permission.replied" + properties: { + sessionID: string + requestID: string + reply: "once" | "always" | "reject" + } + } + | { + id: string + type: "session.diff" + properties: { + sessionID: string + diff: Array + } + } + | { + id: string + type: "session.error" + properties: { + sessionID?: string + error?: + | ProviderAuthError + | UnknownError + | MessageOutputLengthError + | MessageAbortedError + | StructuredOutputError + | ContextOverflowError + | ApiError + } + } + | { + id: string + type: "question.asked" + properties: { + id: string + sessionID: string + /** + * Questions to ask + */ + questions: Array + tool?: QuestionTool + } + } + | { + id: string + type: "question.replied" + properties: { + sessionID: string + requestID: string + answers: Array + } + } + | { + id: string + type: "question.rejected" + properties: { + sessionID: string + requestID: string + } + } + | { + id: string + type: "todo.updated" + properties: { + sessionID: string + todos: Array + } + } + | { + id: string + type: "session.status" + properties: { + sessionID: string + status: SessionStatus + } + } + | { + id: string + type: "session.idle" + properties: { + sessionID: string + } + } + | { + id: string + type: "session.compacted" + properties: { + sessionID: string + } + } + | { + id: string + type: "lsp.updated" + properties: { + [key: string]: unknown + } + } + | { + id: string + type: "tui.prompt.append" + properties: { + text: string + } + } + | { + id: string + 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 + } + } + | { + id: string + type: "tui.toast.show" + properties: { + title?: string + message: string + variant: "info" | "success" | "warning" | "error" + duration?: number + } + } + | { + id: string + type: "tui.session.select" + properties: { + /** + * Session ID to navigate to + */ + sessionID: string + } + } + | { + id: string + type: "mcp.tools.changed" + properties: { + server: string + } + } + | { + id: string + type: "mcp.browser.open.failed" + properties: { + mcpName: string + url: string + } + } + | { + id: string + type: "command.executed" + properties: { + name: string + sessionID: string + arguments: string + messageID: string + } + } + | { + id: string + type: "project.updated" + properties: { + 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 + } + } + | { + id: string + type: "vcs.branch.updated" + properties: { + branch?: string + } + } + | { + id: string + type: "workspace.ready" + properties: { + name: string + } + } + | { + id: string + type: "workspace.failed" + properties: { + message: string + } + } + | { + id: string + type: "workspace.status" + properties: { + workspaceID: string + status: "connected" | "connecting" | "disconnected" | "error" + } + } + | { + id: string + type: "worktree.ready" + properties: { + name: string + branch?: string + } + } + | { + id: string + type: "worktree.failed" + properties: { + message: string + } + } + | { + id: string + type: "pty.created" + properties: { + info: Pty + } + } + | { + id: string + type: "pty.updated" + properties: { + info: Pty + } + } + | { + id: string + type: "pty.exited" + properties: { + id: string + exitCode: number + } + } + | { + id: string + type: "pty.deleted" + properties: { + id: string + } + } + | { + id: string + type: "installation.updated" + properties: { + version: string + } + } + | { + id: string + type: "installation.update-available" + properties: { + version: string + } + } + | { + id: string + type: "server.connected" + properties: { + [key: string]: unknown + } + } + | { + id: string + type: "global.disposed" + properties: { + [key: string]: unknown + } + } } /** @@ -1478,6 +1960,16 @@ export type WorktreeResetInput = { directory: string } +export type PermissionAction = "allow" | "deny" | "ask" + +export type PermissionRule = { + permission: string + pattern: string + action: PermissionAction +} + +export type PermissionRuleset = Array + export type ProjectSummary = { id: string name?: string @@ -1713,6 +2205,30 @@ export type McpServerNotFoundError = { message: 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 ProjectNotFoundError = { _tag: "ProjectNotFoundError" projectID: string @@ -1730,12 +2246,37 @@ export type PtyForbiddenError = { message: string } +export type QuestionRequest = { + id: string + sessionID: string + /** + * Questions to ask + */ + questions: Array + tool?: QuestionTool +} + export type QuestionNotFoundError = { _tag: "QuestionNotFoundError" requestID: string message: string } +export type PermissionRequest = { + id: string + sessionID: string + permission: string + patterns: Array + metadata: { + [key: string]: unknown + } + always: Array + tool?: { + messageID: string + callID: string + } +} + export type PermissionNotFoundError = { _tag: "PermissionNotFoundError" requestID: string @@ -1796,6 +2337,112 @@ export type ProviderAuthError1 = { } } +export type Session1 = { + id: string + slug: string + projectID: string + workspaceID?: string + directory: string + path?: string + parentID?: string + summary?: { + additions: number + deletions: number + files: number + diffs?: Array + } + cost?: number + tokens?: { + input: number + output: number + reasoning: number + cache: { + read: number + write: number + } + } + share?: { + url: string + } + title: string + agent?: string + model?: { + id: string + providerID: string + variant?: string + } + version: string + metadata?: { + [key: string]: unknown + } + time: { + created: number + updated: number + compacting?: number + archived?: number + } + permission?: PermissionRuleset + revert?: { + messageID: string + partID?: string + snapshot?: string + diff?: string + } +} + +export type Session2 = { + id: string + slug: string + projectID: string + workspaceID?: string + directory: string + path?: string + parentID?: string + summary?: { + additions: number + deletions: number + files: number + diffs?: Array + } + cost?: number + tokens?: { + input: number + output: number + reasoning: number + cache: { + read: number + write: number + } + } + share?: { + url: string + } + title: string + agent?: string + model?: { + id: string + providerID: string + variant?: string + } + version: string + metadata?: { + [key: string]: unknown + } + time: { + created: number + updated: number + compacting?: number + archived?: number + } + permission?: PermissionRuleset + revert?: { + messageID: string + partID?: string + snapshot?: string + diff?: string + } +} + export type NotFoundError = { name: "NotFoundError" data: { @@ -1803,6 +2450,271 @@ export type NotFoundError = { } } +export type Session3 = { + id: string + slug: string + projectID: string + workspaceID?: string + directory: string + path?: string + parentID?: string + summary?: { + additions: number + deletions: number + files: number + diffs?: Array + } + cost?: number + tokens?: { + input: number + output: number + reasoning: number + cache: { + read: number + write: number + } + } + share?: { + url: string + } + title: string + agent?: string + model?: { + id: string + providerID: string + variant?: string + } + version: string + metadata?: { + [key: string]: unknown + } + time: { + created: number + updated: number + compacting?: number + archived?: number + } + permission?: PermissionRuleset + revert?: { + messageID: string + partID?: string + snapshot?: string + diff?: string + } +} + +export type Session4 = { + id: string + slug: string + projectID: string + workspaceID?: string + directory: string + path?: string + parentID?: string + summary?: { + additions: number + deletions: number + files: number + diffs?: Array + } + cost?: number + tokens?: { + input: number + output: number + reasoning: number + cache: { + read: number + write: number + } + } + share?: { + url: string + } + title: string + agent?: string + model?: { + id: string + providerID: string + variant?: string + } + version: string + metadata?: { + [key: string]: unknown + } + time: { + created: number + updated: number + compacting?: number + archived?: number + } + permission?: PermissionRuleset + revert?: { + messageID: string + partID?: string + snapshot?: string + diff?: string + } +} + +export type Session5 = { + id: string + slug: string + projectID: string + workspaceID?: string + directory: string + path?: string + parentID?: string + summary?: { + additions: number + deletions: number + files: number + diffs?: Array + } + cost?: number + tokens?: { + input: number + output: number + reasoning: number + cache: { + read: number + write: number + } + } + share?: { + url: string + } + title: string + agent?: string + model?: { + id: string + providerID: string + variant?: string + } + version: string + metadata?: { + [key: string]: unknown + } + time: { + created: number + updated: number + compacting?: number + archived?: number + } + permission?: PermissionRuleset + revert?: { + messageID: string + partID?: string + snapshot?: string + diff?: string + } +} + +export type Session6 = { + id: string + slug: string + projectID: string + workspaceID?: string + directory: string + path?: string + parentID?: string + summary?: { + additions: number + deletions: number + files: number + diffs?: Array + } + cost?: number + tokens?: { + input: number + output: number + reasoning: number + cache: { + read: number + write: number + } + } + share?: { + url: string + } + title: string + agent?: string + model?: { + id: string + providerID: string + variant?: string + } + version: string + metadata?: { + [key: string]: unknown + } + time: { + created: number + updated: number + compacting?: number + archived?: number + } + permission?: PermissionRuleset + revert?: { + messageID: string + partID?: string + snapshot?: string + diff?: string + } +} + +export type Session7 = { + id: string + slug: string + projectID: string + workspaceID?: string + directory: string + path?: string + parentID?: string + summary?: { + additions: number + deletions: number + files: number + diffs?: Array + } + cost?: number + tokens?: { + input: number + output: number + reasoning: number + cache: { + read: number + write: number + } + } + share?: { + url: string + } + title: string + agent?: string + model?: { + id: string + providerID: string + variant?: string + } + version: string + metadata?: { + [key: string]: unknown + } + time: { + created: number + updated: number + compacting?: number + archived?: number + } + permission?: PermissionRuleset + revert?: { + messageID: string + partID?: string + snapshot?: string + diff?: string + } +} + export type TextPartInput = { id?: string type: "text" @@ -1857,6 +2769,112 @@ export type SessionBusyError = { message: string } +export type Session8 = { + id: string + slug: string + projectID: string + workspaceID?: string + directory: string + path?: string + parentID?: string + summary?: { + additions: number + deletions: number + files: number + diffs?: Array + } + cost?: number + tokens?: { + input: number + output: number + reasoning: number + cache: { + read: number + write: number + } + } + share?: { + url: string + } + title: string + agent?: string + model?: { + id: string + providerID: string + variant?: string + } + version: string + metadata?: { + [key: string]: unknown + } + time: { + created: number + updated: number + compacting?: number + archived?: number + } + permission?: PermissionRuleset + revert?: { + messageID: string + partID?: string + snapshot?: string + diff?: string + } +} + +export type Session9 = { + id: string + slug: string + projectID: string + workspaceID?: string + directory: string + path?: string + parentID?: string + summary?: { + additions: number + deletions: number + files: number + diffs?: Array + } + cost?: number + tokens?: { + input: number + output: number + reasoning: number + cache: { + read: number + write: number + } + } + share?: { + url: string + } + title: string + agent?: string + model?: { + id: string + providerID: string + variant?: string + } + version: string + metadata?: { + [key: string]: unknown + } + time: { + created: number + updated: number + compacting?: number + archived?: number + } + permission?: PermissionRuleset + revert?: { + messageID: string + partID?: string + snapshot?: string + diff?: string + } +} + export type V2SessionsResponse = { items: Array cursor: { @@ -1907,14 +2925,14 @@ export type ProviderNotFoundError = { message: string } -export type EventTuiPromptAppend2 = { +export type EventTuiPromptAppend = { type: "tui.prompt.append" properties: { text: string } } -export type EventTuiCommandExecute2 = { +export type EventTuiCommandExecute = { type: "tui.command.execute" properties: { command: @@ -1938,7 +2956,7 @@ export type EventTuiCommandExecute2 = { } } -export type EventTuiToastShow2 = { +export type EventTuiToastShow = { type: "tui.toast.show" properties: { title?: string @@ -1948,7 +2966,7 @@ export type EventTuiToastShow2 = { } } -export type EventTuiSessionSelect2 = { +export type EventTuiSessionSelect = { type: "tui.session.select" properties: { /** @@ -1987,1288 +3005,58 @@ export type EffectHttpApiErrorForbidden = { _tag: "Forbidden" } -export type SyncEventMessageUpdated = { - type: "sync" - name: "message.updated.1" +export type EventTuiPromptAppend2 = { 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 - cost?: number | null - tokens?: { - input: number - output: number - reasoning: number - cache: { - read: number - write: number - } - } | null - share?: { - url?: string | null - } - title?: string | null - agent?: string | null - model?: { - id: string - providerID: string - variant?: string - } | null - version?: string | null - metadata?: { - [key: string]: unknown - } | 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 - model: { - 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 + type: "tui.prompt.append" + properties: { text: string } } -export type SyncEventSessionNextShellStarted = { - type: "sync" - name: "session.next.shell.started.1" +export type EventTuiCommandExecute2 = { 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 SyncEventSessionNextStepFailed = { - type: "sync" - name: "session.next.step.failed.1" - id: string - seq: number - aggregateID: "sessionID" - data: { - timestamp: number - sessionID: string - error: SessionErrorUnknown - } -} - -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 SyncEventSessionNextToolFailed = { - type: "sync" - name: "session.next.tool.failed.1" - id: string - seq: number - aggregateID: "sessionID" - data: { - timestamp: number - sessionID: string - callID: string - error: SessionErrorUnknown - 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 EventServerConnected = { - id: string - type: "server.connected" + type: "tui.command.execute" properties: { - [key: string]: unknown + 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 EventGlobalDisposed = { +export type EventTuiToastShow2 = { id: string - type: "global.disposed" - properties: { - [key: string]: unknown - } -} - -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 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 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 EventSessionCompacted = { - id: string - type: "session.compacted" - properties: { - sessionID: string - } -} - -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" + type: "tui.toast.show" properties: { + title?: string message: string + variant: "info" | "success" | "warning" | "error" + duration?: number } } -export type EventWorkspaceStatus = { +export type EventTuiSessionSelect2 = { 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 EventInstallationUpdated = { - id: string - type: "installation.updated" - properties: { - version: string - } -} - -export type EventInstallationUpdateAvailable = { - id: string - type: "installation.update-available" - properties: { - version: string - } -} - -export type EventMessageUpdated = { - id: string - type: "message.updated" + type: "tui.session.select" properties: { + /** + * Session ID to navigate to + */ 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 - model: { - 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 PromptReferenceAttachment = { - name: string - kind: "local" | "git" | "invalid" - uri?: string - repository?: string - branch?: string - target?: string - targetUri?: string - problem?: 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 SessionErrorUnknown = { - type: "unknown" - message: string -} - -export type EventSessionNextStepFailed = { - id: string - type: "session.next.step.failed" - properties: { - timestamp: number - sessionID: string - error: SessionErrorUnknown - } -} - -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 EventSessionNextToolFailed = { - id: string - type: "session.next.tool.failed" - properties: { - timestamp: number - sessionID: string - callID: string - error: SessionErrorUnknown - 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 EventPluginAdded = { - id: string - type: "plugin.added" - properties: { - id: string } } @@ -3370,71 +3158,76 @@ export type ModelV2Info = { } } -export type EventCatalogModelUpdated = { - id: string - type: "catalog.model.updated" - properties: { - model: ModelV2Info +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 PromptReferenceAttachment = { + name: string + kind: "local" | "git" | "invalid" + uri?: string + repository?: string + branch?: string + target?: string + targetUri?: string + problem?: string + source?: PromptSource +} + +export type SessionErrorUnknown = { + type: "unknown" + message: string +} + +export type ToolTextContent = { + type: "text" + text: string +} + +export type ToolFileContent = { + type: "file" + uri: string + mime: string + name?: string +} + +export type SessionNextRetryError = { + message: string + statusCode?: number + isRetryable: boolean + responseHeaders?: { + [key: string]: string } -} - -export type EventModelsDevRefreshed = { - id: string - type: "models-dev.refreshed" - properties: { - [key: string]: unknown - } -} - -export type AccountV2oAuthCredential = { - type: "oauth" - refresh: string - access: string - expires: number -} - -export type AccountV2ApiKeyCredential = { - type: "api" - key: string + responseBody?: string metadata?: { [key: string]: string } } -export type AccountV2Credential = AccountV2oAuthCredential | AccountV2ApiKeyCredential +export type PermissionV2Action = "allow" | "deny" | "ask" -export type AccountV2Info = { - id: string - serviceID: string - description: string - credential: AccountV2Credential +export type PermissionV2Rule = { + permission: string + pattern: string + action: PermissionV2Action } -export type EventAccountAdded = { - id: string - type: "account.added" - properties: { - account: AccountV2Info - } -} - -export type EventAccountRemoved = { - id: string - type: "account.removed" - properties: { - account: AccountV2Info - } -} - -export type EventAccountSwitched = { - id: string - type: "account.switched" - properties: { - serviceID: string - from?: string - to?: string - } -} +export type PermissionV2Ruleset = Array export type PolicyEffect = "allow" | "deny" @@ -3746,14 +3539,19 @@ export type ProviderV2Info = { } } -export type EventTuiToastShow1 = { +export type EventModelsDevRefreshed = { id: string - type: "tui.toast.show" + type: "models-dev.refreshed" properties: { - title?: string - message: string - variant: "info" | "success" | "warning" | "error" - duration?: number + [key: string]: unknown + } +} + +export type EventPluginAdded = { + id: string + type: "plugin.added" + properties: { + id: string } } @@ -3855,6 +3653,778 @@ export type ModelV2Info1 = { } } +export type EventCatalogModelUpdated = { + id: string + type: "catalog.model.updated" + properties: { + model: ModelV2Info1 + } +} + +export type EventFileEdited = { + id: string + type: "file.edited" + properties: { + file: string + } +} + +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 + model: { + id: string + providerID: string + variant?: string + } + } +} + +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 EventSessionNextStepFailed = { + id: string + type: "session.next.step.failed" + properties: { + timestamp: number + sessionID: string + error: SessionErrorUnknown + } +} + +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 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 EventSessionNextToolFailed = { + id: string + type: "session.next.tool.failed" + properties: { + timestamp: number + sessionID: string + callID: string + error: SessionErrorUnknown + provider: { + executed: boolean + metadata?: { + [key: string]: unknown + } + } + } +} + +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 EventFileWatcherUpdated = { + id: string + type: "file.watcher.updated" + properties: { + file: string + event: "add" | "change" | "unlink" + } +} + +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 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 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: { + id: string + sessionID: string + permission: string + patterns: Array + metadata: { + [key: string]: unknown + } + always: Array + tool?: { + messageID: string + callID: string + } + } +} + +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 EventQuestionAsked = { + id: string + type: "question.asked" + properties: { + id: string + sessionID: string + /** + * Questions to ask + */ + questions: Array + tool?: QuestionTool + } +} + +export type EventQuestionReplied = { + id: string + type: "question.replied" + properties: { + sessionID: string + requestID: string + answers: Array + } +} + +export type EventQuestionRejected = { + id: string + type: "question.rejected" + properties: { + sessionID: string + requestID: string + } +} + +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 EventLspUpdated = { + id: string + type: "lsp.updated" + properties: { + [key: string]: unknown + } +} + +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: { + 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 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 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 EventInstallationUpdated = { + id: string + type: "installation.updated" + properties: { + version: string + } +} + +export type EventInstallationUpdateAvailable = { + id: string + type: "installation.update-available" + properties: { + version: 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 AuthOAuthCredential = { + type: "oauth" + refresh: string + access: string + expires: number +} + +export type AuthApiKeyCredential = { + type: "api" + key: string + metadata?: { + [key: string]: string + } +} + +export type AuthCredential = AuthOAuthCredential | AuthApiKeyCredential + +export type AuthInfo = { + id: string + serviceID: string + description: string + credential: AuthCredential +} + +export type EventAccountAdded = { + id: string + type: "account.added" + properties: { + account: AuthInfo + } +} + +export type EventAccountRemoved = { + id: string + type: "account.removed" + properties: { + account: AuthInfo + } +} + +export type EventAccountSwitched = { + id: string + type: "account.switched" + properties: { + serviceID: string + from?: string + to?: string + } +} + export type BadRequestError = { name: "BadRequest" data: { @@ -6087,7 +6657,7 @@ export type SessionListResponses = { /** * List of sessions */ - 200: Array + 200: Array } export type SessionListResponse = SessionListResponses[keyof SessionListResponses] @@ -6129,7 +6699,7 @@ export type SessionCreateResponses = { /** * Successfully created session */ - 200: Session + 200: Session3 } export type SessionCreateResponse = SessionCreateResponses[keyof SessionCreateResponses] @@ -6227,7 +6797,7 @@ export type SessionGetResponses = { /** * Get session */ - 200: Session + 200: Session2 } export type SessionGetResponse = SessionGetResponses[keyof SessionGetResponses] @@ -6270,7 +6840,7 @@ export type SessionUpdateResponses = { /** * Successfully updated session */ - 200: Session + 200: Session4 } export type SessionUpdateResponse = SessionUpdateResponses[keyof SessionUpdateResponses] @@ -6304,7 +6874,7 @@ export type SessionChildrenResponses = { /** * List of children */ - 200: Array + 200: Array } export type SessionChildrenResponse = SessionChildrenResponses[keyof SessionChildrenResponses] @@ -6573,7 +7143,7 @@ export type SessionForkResponses = { /** * 200 */ - 200: Session + 200: Session5 } export type SessionForkResponse = SessionForkResponses[keyof SessionForkResponses] @@ -6679,7 +7249,7 @@ export type SessionUnshareResponses = { /** * Successfully unshared session */ - 200: Session + 200: Session7 } export type SessionUnshareResponse = SessionUnshareResponses[keyof SessionUnshareResponses] @@ -6717,7 +7287,7 @@ export type SessionShareResponses = { /** * Successfully shared session */ - 200: Session + 200: Session6 } export type SessionShareResponse = SessionShareResponses[keyof SessionShareResponses] @@ -6946,7 +7516,7 @@ export type SessionRevertResponses = { /** * Updated session */ - 200: Session + 200: Session8 } export type SessionRevertResponse = SessionRevertResponses[keyof SessionRevertResponses] @@ -6984,7 +7554,7 @@ export type SessionUnrevertResponses = { /** * Updated session */ - 200: Session + 200: Session9 } export type SessionUnrevertResponse = SessionUnrevertResponses[keyof SessionUnrevertResponses] @@ -7880,7 +8450,7 @@ export type TuiShowToastResponses = { export type TuiShowToastResponse = TuiShowToastResponses[keyof TuiShowToastResponses] export type TuiPublishData = { - body?: EventTuiPromptAppend2 | EventTuiCommandExecute2 | EventTuiToastShow2 | EventTuiSessionSelect2 + body?: EventTuiPromptAppend | EventTuiCommandExecute | EventTuiToastShow | EventTuiSessionSelect path?: never query?: { directory?: string diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index ad4eb350ed..06b923bd7d 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -5074,7 +5074,7 @@ "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/Session" + "$ref": "#/components/schemas/Session1" }, "description": "List of sessions" } @@ -5128,7 +5128,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Session" + "$ref": "#/components/schemas/Session3" } } } @@ -5311,7 +5311,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Session" + "$ref": "#/components/schemas/Session2" } } } @@ -5468,7 +5468,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Session" + "$ref": "#/components/schemas/Session4" } } } @@ -5580,7 +5580,7 @@ "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/Session" + "$ref": "#/components/schemas/Session1" }, "description": "List of children" } @@ -6298,7 +6298,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Session" + "$ref": "#/components/schemas/Session5" } } } @@ -6569,7 +6569,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Session" + "$ref": "#/components/schemas/Session6" } } } @@ -6650,7 +6650,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Session" + "$ref": "#/components/schemas/Session7" } } } @@ -7273,7 +7273,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Session" + "$ref": "#/components/schemas/Session8" } } } @@ -7384,7 +7384,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Session" + "$ref": "#/components/schemas/Session9" } } } @@ -10489,37 +10489,118 @@ "Event": { "anyOf": [ { - "$ref": "#/components/schemas/Event.tui.prompt.append" + "$ref": "#/components/schemas/EventModels-devRefreshed" }, { - "$ref": "#/components/schemas/Event.tui.command.execute" + "$ref": "#/components/schemas/EventPluginAdded" }, { - "$ref": "#/components/schemas/EventTuiToastShow1" - }, - { - "$ref": "#/components/schemas/Event.tui.session.select" - }, - { - "$ref": "#/components/schemas/EventServerConnected" - }, - { - "$ref": "#/components/schemas/EventGlobalDisposed" - }, - { - "$ref": "#/components/schemas/EventServerInstanceDisposed" + "$ref": "#/components/schemas/EventCatalogModelUpdated" }, { "$ref": "#/components/schemas/EventFileEdited" }, + { + "$ref": "#/components/schemas/EventSessionNextAgentSwitched" + }, + { + "$ref": "#/components/schemas/EventSessionNextModelSwitched" + }, + { + "$ref": "#/components/schemas/EventSessionNextPrompted" + }, + { + "$ref": "#/components/schemas/EventSessionNextSynthetic" + }, + { + "$ref": "#/components/schemas/EventSessionNextShellStarted" + }, + { + "$ref": "#/components/schemas/EventSessionNextShellEnded" + }, + { + "$ref": "#/components/schemas/EventSessionNextStepStarted" + }, + { + "$ref": "#/components/schemas/EventSessionNextStepEnded" + }, + { + "$ref": "#/components/schemas/EventSessionNextStepFailed" + }, + { + "$ref": "#/components/schemas/EventSessionNextTextStarted" + }, + { + "$ref": "#/components/schemas/EventSessionNextTextDelta" + }, + { + "$ref": "#/components/schemas/EventSessionNextTextEnded" + }, + { + "$ref": "#/components/schemas/EventSessionNextReasoningStarted" + }, + { + "$ref": "#/components/schemas/EventSessionNextReasoningDelta" + }, + { + "$ref": "#/components/schemas/EventSessionNextReasoningEnded" + }, + { + "$ref": "#/components/schemas/EventSessionNextToolInputStarted" + }, + { + "$ref": "#/components/schemas/EventSessionNextToolInputDelta" + }, + { + "$ref": "#/components/schemas/EventSessionNextToolInputEnded" + }, + { + "$ref": "#/components/schemas/EventSessionNextToolCalled" + }, + { + "$ref": "#/components/schemas/EventSessionNextToolProgress" + }, + { + "$ref": "#/components/schemas/EventSessionNextToolSuccess" + }, + { + "$ref": "#/components/schemas/EventSessionNextToolFailed" + }, + { + "$ref": "#/components/schemas/EventSessionNextRetried" + }, + { + "$ref": "#/components/schemas/EventSessionNextCompactionStarted" + }, + { + "$ref": "#/components/schemas/EventSessionNextCompactionDelta" + }, + { + "$ref": "#/components/schemas/EventSessionNextCompactionEnded" + }, { "$ref": "#/components/schemas/EventFileWatcherUpdated" }, { - "$ref": "#/components/schemas/EventLspClientDiagnostics" + "$ref": "#/components/schemas/EventSessionCreated" }, { - "$ref": "#/components/schemas/EventLspUpdated" + "$ref": "#/components/schemas/EventSessionUpdated" + }, + { + "$ref": "#/components/schemas/EventSessionDeleted" + }, + { + "$ref": "#/components/schemas/EventMessageUpdated" + }, + { + "$ref": "#/components/schemas/EventMessageRemoved" + }, + { + "$ref": "#/components/schemas/EventMessagePartUpdated" + }, + { + "$ref": "#/components/schemas/EventMessagePartRemoved" }, { "$ref": "#/components/schemas/EventMessagePartDelta" @@ -10554,6 +10635,24 @@ { "$ref": "#/components/schemas/EventSessionIdle" }, + { + "$ref": "#/components/schemas/EventSessionCompacted" + }, + { + "$ref": "#/components/schemas/EventLspUpdated" + }, + { + "$ref": "#/components/schemas/Event.tui.prompt.append" + }, + { + "$ref": "#/components/schemas/Event.tui.command.execute" + }, + { + "$ref": "#/components/schemas/Event.tui.toast.show" + }, + { + "$ref": "#/components/schemas/Event.tui.session.select" + }, { "$ref": "#/components/schemas/EventMcpToolsChanged" }, @@ -10566,9 +10665,6 @@ { "$ref": "#/components/schemas/EventProjectUpdated" }, - { - "$ref": "#/components/schemas/EventSessionCompacted" - }, { "$ref": "#/components/schemas/EventVcsBranchUpdated" }, @@ -10606,190 +10702,10 @@ "$ref": "#/components/schemas/EventInstallationUpdate-available" }, { - "$ref": "#/components/schemas/EventMessageUpdated" + "$ref": "#/components/schemas/EventServerConnected" }, { - "$ref": "#/components/schemas/EventMessageRemoved" - }, - { - "$ref": "#/components/schemas/EventMessagePartUpdated" - }, - { - "$ref": "#/components/schemas/EventMessagePartRemoved" - }, - { - "$ref": "#/components/schemas/EventSessionCreated" - }, - { - "$ref": "#/components/schemas/EventSessionUpdated" - }, - { - "$ref": "#/components/schemas/EventSessionDeleted" - }, - { - "$ref": "#/components/schemas/EventSessionNextAgentSwitched" - }, - { - "$ref": "#/components/schemas/EventSessionNextModelSwitched" - }, - { - "$ref": "#/components/schemas/EventSessionNextPrompted" - }, - { - "$ref": "#/components/schemas/EventSessionNextSynthetic" - }, - { - "$ref": "#/components/schemas/EventSessionNextShellStarted" - }, - { - "$ref": "#/components/schemas/EventSessionNextShellEnded" - }, - { - "$ref": "#/components/schemas/EventSessionNextStepStarted" - }, - { - "$ref": "#/components/schemas/EventSessionNextStepEnded" - }, - { - "$ref": "#/components/schemas/EventSessionNextStepFailed" - }, - { - "$ref": "#/components/schemas/EventSessionNextTextStarted" - }, - { - "$ref": "#/components/schemas/EventSessionNextTextDelta" - }, - { - "$ref": "#/components/schemas/EventSessionNextTextEnded" - }, - { - "$ref": "#/components/schemas/EventSessionNextReasoningStarted" - }, - { - "$ref": "#/components/schemas/EventSessionNextReasoningDelta" - }, - { - "$ref": "#/components/schemas/EventSessionNextReasoningEnded" - }, - { - "$ref": "#/components/schemas/EventSessionNextToolInputStarted" - }, - { - "$ref": "#/components/schemas/EventSessionNextToolInputDelta" - }, - { - "$ref": "#/components/schemas/EventSessionNextToolInputEnded" - }, - { - "$ref": "#/components/schemas/EventSessionNextToolCalled" - }, - { - "$ref": "#/components/schemas/EventSessionNextToolProgress" - }, - { - "$ref": "#/components/schemas/EventSessionNextToolSuccess" - }, - { - "$ref": "#/components/schemas/EventSessionNextToolFailed" - }, - { - "$ref": "#/components/schemas/EventSessionNextRetried" - }, - { - "$ref": "#/components/schemas/EventSessionNextCompactionStarted" - }, - { - "$ref": "#/components/schemas/EventSessionNextCompactionDelta" - }, - { - "$ref": "#/components/schemas/EventSessionNextCompactionEnded" - }, - { - "$ref": "#/components/schemas/EventPluginAdded" - }, - { - "$ref": "#/components/schemas/EventCatalogModelUpdated" - }, - { - "$ref": "#/components/schemas/EventSessionNextAgentSwitched" - }, - { - "$ref": "#/components/schemas/EventSessionNextModelSwitched" - }, - { - "$ref": "#/components/schemas/EventSessionNextPrompted" - }, - { - "$ref": "#/components/schemas/EventSessionNextSynthetic" - }, - { - "$ref": "#/components/schemas/EventSessionNextShellStarted" - }, - { - "$ref": "#/components/schemas/EventSessionNextShellEnded" - }, - { - "$ref": "#/components/schemas/EventSessionNextStepStarted" - }, - { - "$ref": "#/components/schemas/EventSessionNextStepEnded" - }, - { - "$ref": "#/components/schemas/EventSessionNextStepFailed" - }, - { - "$ref": "#/components/schemas/EventSessionNextTextStarted" - }, - { - "$ref": "#/components/schemas/EventSessionNextTextDelta" - }, - { - "$ref": "#/components/schemas/EventSessionNextTextEnded" - }, - { - "$ref": "#/components/schemas/EventSessionNextReasoningStarted" - }, - { - "$ref": "#/components/schemas/EventSessionNextReasoningDelta" - }, - { - "$ref": "#/components/schemas/EventSessionNextReasoningEnded" - }, - { - "$ref": "#/components/schemas/EventSessionNextToolInputStarted" - }, - { - "$ref": "#/components/schemas/EventSessionNextToolInputDelta" - }, - { - "$ref": "#/components/schemas/EventSessionNextToolInputEnded" - }, - { - "$ref": "#/components/schemas/EventSessionNextToolCalled" - }, - { - "$ref": "#/components/schemas/EventSessionNextToolProgress" - }, - { - "$ref": "#/components/schemas/EventSessionNextToolSuccess" - }, - { - "$ref": "#/components/schemas/EventSessionNextToolFailed" - }, - { - "$ref": "#/components/schemas/EventSessionNextRetried" - }, - { - "$ref": "#/components/schemas/EventSessionNextCompactionStarted" - }, - { - "$ref": "#/components/schemas/EventSessionNextCompactionDelta" - }, - { - "$ref": "#/components/schemas/EventSessionNextCompactionEnded" - }, - { - "$ref": "#/components/schemas/EventModels-devRefreshed" + "$ref": "#/components/schemas/EventGlobalDisposed" }, { "$ref": "#/components/schemas/EventAccountAdded" @@ -10910,185 +10826,32 @@ "required": ["_tag", "message"], "additionalProperties": false }, - "Event.tui.prompt.append": { + "Prompt": { "type": "object", "properties": { - "id": { + "text": { "type": "string" }, - "type": { - "type": "string", - "enum": ["tui.prompt.append"] - }, - "properties": { - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "Event.tui.command.execute": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["tui.command.execute"] - }, - "properties": { - "type": "object", - "properties": { - "command": { - "anyOf": [ - { - "type": "string", - "enum": [ - "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" - ] - }, - { - "type": "string" - } - ] - } - }, - "required": ["command"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "Event.tui.toast.show": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["tui.toast.show"] - }, - "properties": { - "type": "object", - "properties": { - "title": { - "type": "string" - }, - "message": { - "type": "string" - }, - "variant": { - "type": "string", - "enum": ["info", "success", "warning", "error"] - }, - "duration": { - "type": "integer", - "exclusiveMinimum": 0 - } - }, - "required": ["message", "variant"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "Event.tui.session.select": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["tui.session.select"] - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses", - "description": "Session ID to navigate to" - } - }, - "required": ["sessionID"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "PermissionRequest": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^per" - }, - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "permission": { - "type": "string" - }, - "patterns": { + "files": { "type": "array", "items": { - "type": "string" + "$ref": "#/components/schemas/PromptFileAttachment" } }, - "metadata": { - "type": "object" - }, - "always": { + "agents": { "type": "array", "items": { - "type": "string" + "$ref": "#/components/schemas/PromptAgentAttachment" } }, - "tool": { - "type": "object", - "properties": { - "messageID": { - "type": "string", - "pattern": "^msg" - }, - "callID": { - "type": "string" - } - }, - "required": ["messageID", "callID"], - "additionalProperties": false + "references": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PromptReferenceAttachment" + } } }, - "required": ["id", "sessionID", "permission", "patterns", "metadata", "always"], + "required": ["text"], "additionalProperties": false }, "SnapshotFileDiff": { @@ -11114,6 +10877,297 @@ "required": ["additions", "deletions"], "additionalProperties": false }, + "Session": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^ses" + }, + "slug": { + "type": "string" + }, + "projectID": { + "type": "string" + }, + "workspaceID": { + "type": "string", + "pattern": "^wrk" + }, + "directory": { + "type": "string" + }, + "path": { + "type": "string" + }, + "parentID": { + "type": "string", + "pattern": "^ses" + }, + "summary": { + "type": "object", + "properties": { + "additions": { + "type": "number" + }, + "deletions": { + "type": "number" + }, + "files": { + "type": "number" + }, + "diffs": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SnapshotFileDiff" + } + } + }, + "required": ["additions", "deletions", "files"], + "additionalProperties": false + }, + "cost": { + "type": "number" + }, + "tokens": { + "type": "object", + "properties": { + "input": { + "type": "number" + }, + "output": { + "type": "number" + }, + "reasoning": { + "type": "number" + }, + "cache": { + "type": "object", + "properties": { + "read": { + "type": "number" + }, + "write": { + "type": "number" + } + }, + "required": ["read", "write"], + "additionalProperties": false + } + }, + "required": ["input", "output", "reasoning", "cache"], + "additionalProperties": false + }, + "share": { + "type": "object", + "properties": { + "url": { + "type": "string" + } + }, + "required": ["url"], + "additionalProperties": false + }, + "title": { + "type": "string" + }, + "agent": { + "type": "string" + }, + "model": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "providerID": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "required": ["id", "providerID"], + "additionalProperties": false + }, + "version": { + "type": "string" + }, + "metadata": { + "type": "object" + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "integer", + "minimum": 0 + }, + "updated": { + "type": "integer", + "minimum": 0 + }, + "compacting": { + "type": "integer", + "minimum": 0 + }, + "archived": { + "type": "number" + } + }, + "required": ["created", "updated"], + "additionalProperties": false + }, + "permission": { + "$ref": "#/components/schemas/PermissionV2Ruleset" + }, + "revert": { + "type": "object", + "properties": { + "messageID": { + "type": "string", + "pattern": "^msg" + }, + "partID": { + "type": "string", + "pattern": "^prt" + }, + "snapshot": { + "type": "string" + }, + "diff": { + "type": "string" + } + }, + "required": ["messageID"], + "additionalProperties": false + } + }, + "required": ["id", "slug", "projectID", "directory", "title", "version", "time"], + "additionalProperties": false + }, + "OutputFormatText": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["text"] + } + }, + "required": ["type"], + "additionalProperties": false + }, + "JSONSchema": { + "type": "object" + }, + "OutputFormatJsonSchema": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["json_schema"] + }, + "schema": { + "$ref": "#/components/schemas/JSONSchema" + }, + "retryCount": { + "type": "integer", + "minimum": 0 + } + }, + "required": ["type", "schema"], + "additionalProperties": false + }, + "OutputFormat": { + "anyOf": [ + { + "$ref": "#/components/schemas/OutputFormatText" + }, + { + "$ref": "#/components/schemas/OutputFormatJsonSchema" + } + ] + }, + "UserMessage": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^msg" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "role": { + "type": "string", + "enum": ["user"] + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "integer", + "minimum": 0 + } + }, + "required": ["created"], + "additionalProperties": false + }, + "format": { + "$ref": "#/components/schemas/OutputFormat" + }, + "summary": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "body": { + "type": "string" + }, + "diffs": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SnapshotFileDiff" + } + } + }, + "required": ["diffs"], + "additionalProperties": false + }, + "agent": { + "type": "string" + }, + "model": { + "type": "object", + "properties": { + "providerID": { + "type": "string" + }, + "modelID": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "required": ["providerID", "modelID"], + "additionalProperties": false + }, + "system": { + "type": "string" + }, + "tools": { + "type": "object", + "additionalProperties": { + "type": "boolean" + } + } + }, + "required": ["id", "sessionID", "role", "time", "agent", "model"], + "additionalProperties": false + }, "ProviderAuthError": { "type": "object", "properties": { @@ -11290,448 +11344,6 @@ "required": ["name", "data"], "additionalProperties": false }, - "QuestionOption": { - "type": "object", - "properties": { - "label": { - "type": "string", - "description": "Display text (1-5 words, concise)" - }, - "description": { - "type": "string", - "description": "Explanation of choice" - } - }, - "required": ["label", "description"], - "additionalProperties": false - }, - "QuestionInfo": { - "type": "object", - "properties": { - "question": { - "type": "string", - "description": "Complete question" - }, - "header": { - "type": "string", - "description": "Very short label (max 30 chars)" - }, - "options": { - "type": "array", - "items": { - "$ref": "#/components/schemas/QuestionOption" - }, - "description": "Available choices" - }, - "multiple": { - "type": "boolean" - }, - "custom": { - "type": "boolean" - } - }, - "required": ["question", "header", "options"], - "additionalProperties": false - }, - "QuestionTool": { - "type": "object", - "properties": { - "messageID": { - "type": "string", - "pattern": "^msg" - }, - "callID": { - "type": "string" - } - }, - "required": ["messageID", "callID"], - "additionalProperties": false - }, - "QuestionRequest": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^que" - }, - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "questions": { - "type": "array", - "items": { - "$ref": "#/components/schemas/QuestionInfo" - }, - "description": "Questions to ask" - }, - "tool": { - "$ref": "#/components/schemas/QuestionTool" - } - }, - "required": ["id", "sessionID", "questions"], - "additionalProperties": false - }, - "QuestionAnswer": { - "type": "array", - "items": { - "type": "string" - } - }, - "QuestionReplied": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "requestID": { - "type": "string", - "pattern": "^que" - }, - "answers": { - "type": "array", - "items": { - "$ref": "#/components/schemas/QuestionAnswer" - } - } - }, - "required": ["sessionID", "requestID", "answers"], - "additionalProperties": false - }, - "QuestionRejected": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "requestID": { - "type": "string", - "pattern": "^que" - } - }, - "required": ["sessionID", "requestID"], - "additionalProperties": false - }, - "Todo": { - "type": "object", - "properties": { - "content": { - "type": "string", - "description": "Brief description of the task" - }, - "status": { - "type": "string", - "description": "Current status of the task: pending, in_progress, completed, cancelled" - }, - "priority": { - "type": "string", - "description": "Priority level of the task: high, medium, low" - } - }, - "required": ["content", "status", "priority"], - "additionalProperties": false - }, - "SessionStatus": { - "anyOf": [ - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["idle"] - } - }, - "required": ["type"], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["retry"] - }, - "attempt": { - "type": "integer", - "minimum": 0 - }, - "message": { - "type": "string" - }, - "action": { - "type": "object", - "properties": { - "reason": { - "type": "string" - }, - "provider": { - "type": "string" - }, - "title": { - "type": "string" - }, - "message": { - "type": "string" - }, - "label": { - "type": "string" - }, - "link": { - "type": "string" - } - }, - "required": ["reason", "provider", "title", "message", "label"], - "additionalProperties": false - }, - "next": { - "type": "integer", - "minimum": 0 - } - }, - "required": ["type", "attempt", "message", "next"], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["busy"] - } - }, - "required": ["type"], - "additionalProperties": false - } - ] - }, - "Project": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "worktree": { - "type": "string" - }, - "vcs": { - "type": "string", - "enum": ["git"] - }, - "name": { - "type": "string" - }, - "icon": { - "type": "object", - "properties": { - "url": { - "type": "string" - }, - "override": { - "type": "string" - }, - "color": { - "type": "string" - } - }, - "additionalProperties": false - }, - "commands": { - "type": "object", - "properties": { - "start": { - "type": "string", - "description": "Startup script to run when creating a new workspace (worktree)" - } - }, - "additionalProperties": false - }, - "time": { - "type": "object", - "properties": { - "created": { - "type": "integer", - "minimum": 0 - }, - "updated": { - "type": "integer", - "minimum": 0 - }, - "initialized": { - "type": "integer", - "minimum": 0 - } - }, - "required": ["created", "updated"], - "additionalProperties": false - }, - "sandboxes": { - "type": "array", - "items": { - "type": "string" - } - } - }, - "required": ["id", "worktree", "time", "sandboxes"], - "additionalProperties": false - }, - "Pty": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^pty" - }, - "title": { - "type": "string" - }, - "command": { - "type": "string" - }, - "args": { - "type": "array", - "items": { - "type": "string" - } - }, - "cwd": { - "type": "string" - }, - "status": { - "type": "string", - "enum": ["running", "exited"] - }, - "pid": { - "type": "integer", - "minimum": 0 - } - }, - "required": ["id", "title", "command", "args", "cwd", "status", "pid"], - "additionalProperties": false - }, - "OutputFormatText": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["text"] - } - }, - "required": ["type"], - "additionalProperties": false - }, - "JSONSchema": { - "type": "object" - }, - "OutputFormatJsonSchema": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["json_schema"] - }, - "schema": { - "$ref": "#/components/schemas/JSONSchema" - }, - "retryCount": { - "type": "integer", - "minimum": 0 - } - }, - "required": ["type", "schema"], - "additionalProperties": false - }, - "OutputFormat": { - "anyOf": [ - { - "$ref": "#/components/schemas/OutputFormatText" - }, - { - "$ref": "#/components/schemas/OutputFormatJsonSchema" - } - ] - }, - "UserMessage": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^msg" - }, - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "role": { - "type": "string", - "enum": ["user"] - }, - "time": { - "type": "object", - "properties": { - "created": { - "type": "integer", - "minimum": 0 - } - }, - "required": ["created"], - "additionalProperties": false - }, - "format": { - "$ref": "#/components/schemas/OutputFormat" - }, - "summary": { - "type": "object", - "properties": { - "title": { - "type": "string" - }, - "body": { - "type": "string" - }, - "diffs": { - "type": "array", - "items": { - "$ref": "#/components/schemas/SnapshotFileDiff" - } - } - }, - "required": ["diffs"], - "additionalProperties": false - }, - "agent": { - "type": "string" - }, - "model": { - "type": "object", - "properties": { - "providerID": { - "type": "string" - }, - "modelID": { - "type": "string" - }, - "variant": { - "type": "string" - } - }, - "required": ["providerID", "modelID"], - "additionalProperties": false - }, - "system": { - "type": "string" - }, - "tools": { - "type": "object", - "additionalProperties": { - "type": "boolean" - } - } - }, - "required": ["id", "sessionID", "role", "time", "agent", "model"], - "additionalProperties": false - }, "AssistantMessage": { "type": "object", "properties": { @@ -12683,227 +12295,193 @@ } ] }, - "PermissionAction": { - "type": "string", - "enum": ["allow", "deny", "ask"] - }, - "PermissionRule": { + "QuestionOption": { "type": "object", "properties": { - "permission": { - "type": "string" + "label": { + "type": "string", + "description": "Display text (1-5 words, concise)" }, - "pattern": { - "type": "string" - }, - "action": { - "$ref": "#/components/schemas/PermissionAction" + "description": { + "type": "string", + "description": "Explanation of choice" } }, - "required": ["permission", "pattern", "action"], + "required": ["label", "description"], "additionalProperties": false }, - "PermissionRuleset": { + "QuestionInfo": { + "type": "object", + "properties": { + "question": { + "type": "string", + "description": "Complete question" + }, + "header": { + "type": "string", + "description": "Very short label (max 30 chars)" + }, + "options": { + "type": "array", + "items": { + "$ref": "#/components/schemas/QuestionOption" + }, + "description": "Available choices" + }, + "multiple": { + "type": "boolean" + }, + "custom": { + "type": "boolean" + } + }, + "required": ["question", "header", "options"], + "additionalProperties": false + }, + "QuestionTool": { + "type": "object", + "properties": { + "messageID": { + "type": "string", + "pattern": "^msg" + }, + "callID": { + "type": "string" + } + }, + "required": ["messageID", "callID"], + "additionalProperties": false + }, + "QuestionAnswer": { "type": "array", "items": { - "$ref": "#/components/schemas/PermissionRule" + "type": "string" } }, - "Session": { + "Todo": { + "type": "object", + "properties": { + "content": { + "type": "string", + "description": "Brief description of the task" + }, + "status": { + "type": "string", + "description": "Current status of the task: pending, in_progress, completed, cancelled" + }, + "priority": { + "type": "string", + "description": "Priority level of the task: high, medium, low" + } + }, + "required": ["content", "status", "priority"], + "additionalProperties": false + }, + "SessionStatus": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["idle"] + } + }, + "required": ["type"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["retry"] + }, + "attempt": { + "type": "integer", + "minimum": 0 + }, + "message": { + "type": "string" + }, + "action": { + "type": "object", + "properties": { + "reason": { + "type": "string" + }, + "provider": { + "type": "string" + }, + "title": { + "type": "string" + }, + "message": { + "type": "string" + }, + "label": { + "type": "string" + }, + "link": { + "type": "string" + } + }, + "required": ["reason", "provider", "title", "message", "label"], + "additionalProperties": false + }, + "next": { + "type": "integer", + "minimum": 0 + } + }, + "required": ["type", "attempt", "message", "next"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["busy"] + } + }, + "required": ["type"], + "additionalProperties": false + } + ] + }, + "Pty": { "type": "object", "properties": { "id": { "type": "string", - "pattern": "^ses" - }, - "slug": { - "type": "string" - }, - "projectID": { - "type": "string" - }, - "workspaceID": { - "type": "string", - "pattern": "^wrk" - }, - "directory": { - "type": "string" - }, - "path": { - "type": "string" - }, - "parentID": { - "type": "string", - "pattern": "^ses" - }, - "summary": { - "type": "object", - "properties": { - "additions": { - "type": "number" - }, - "deletions": { - "type": "number" - }, - "files": { - "type": "number" - }, - "diffs": { - "type": "array", - "items": { - "$ref": "#/components/schemas/SnapshotFileDiff" - } - } - }, - "required": ["additions", "deletions", "files"], - "additionalProperties": false - }, - "cost": { - "type": "number" - }, - "tokens": { - "type": "object", - "properties": { - "input": { - "type": "number" - }, - "output": { - "type": "number" - }, - "reasoning": { - "type": "number" - }, - "cache": { - "type": "object", - "properties": { - "read": { - "type": "number" - }, - "write": { - "type": "number" - } - }, - "required": ["read", "write"], - "additionalProperties": false - } - }, - "required": ["input", "output", "reasoning", "cache"], - "additionalProperties": false - }, - "share": { - "type": "object", - "properties": { - "url": { - "type": "string" - } - }, - "required": ["url"], - "additionalProperties": false + "pattern": "^pty" }, "title": { "type": "string" }, - "agent": { + "command": { "type": "string" }, - "model": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "providerID": { - "type": "string" - }, - "variant": { - "type": "string" - } - }, - "required": ["id", "providerID"], - "additionalProperties": false + "args": { + "type": "array", + "items": { + "type": "string" + } }, - "version": { + "cwd": { "type": "string" }, - "metadata": { - "type": "object" + "status": { + "type": "string", + "enum": ["running", "exited"] }, - "time": { - "type": "object", - "properties": { - "created": { - "type": "integer", - "minimum": 0 - }, - "updated": { - "type": "integer", - "minimum": 0 - }, - "compacting": { - "type": "integer", - "minimum": 0 - }, - "archived": { - "type": "number" - } - }, - "required": ["created", "updated"], - "additionalProperties": false - }, - "permission": { - "$ref": "#/components/schemas/PermissionRuleset" - }, - "revert": { - "type": "object", - "properties": { - "messageID": { - "type": "string", - "pattern": "^msg" - }, - "partID": { - "type": "string", - "pattern": "^prt" - }, - "snapshot": { - "type": "string" - }, - "diff": { - "type": "string" - } - }, - "required": ["messageID"], - "additionalProperties": false + "pid": { + "type": "integer", + "minimum": 0 } }, - "required": ["id", "slug", "projectID", "directory", "title", "version", "time"], - "additionalProperties": false - }, - "Prompt": { - "type": "object", - "properties": { - "text": { - "type": "string" - }, - "files": { - "type": "array", - "items": { - "$ref": "#/components/schemas/PromptFileAttachment" - } - }, - "agents": { - "type": "array", - "items": { - "$ref": "#/components/schemas/PromptAgentAttachment" - } - }, - "references": { - "type": "array", - "items": { - "$ref": "#/components/schemas/PromptReferenceAttachment" - } - } - }, - "required": ["text"], + "required": ["id", "title", "command", "args", "cwd", "status", "pid"], "additionalProperties": false }, "GlobalEvent": { @@ -12921,415 +12499,2389 @@ "payload": { "anyOf": [ { - "$ref": "#/components/schemas/Event.tui.prompt.append" - }, - { - "$ref": "#/components/schemas/Event.tui.command.execute" - }, - { - "$ref": "#/components/schemas/Event.tui.toast.show" - }, - { - "$ref": "#/components/schemas/Event.tui.session.select" - }, - { - "$ref": "#/components/schemas/EventServerConnected" - }, - { - "$ref": "#/components/schemas/EventGlobalDisposed" - }, - { - "$ref": "#/components/schemas/EventServerInstanceDisposed" - }, - { - "$ref": "#/components/schemas/EventFileEdited" - }, - { - "$ref": "#/components/schemas/EventFileWatcherUpdated" - }, - { - "$ref": "#/components/schemas/EventLspClientDiagnostics" - }, - { - "$ref": "#/components/schemas/EventLspUpdated" - }, - { - "$ref": "#/components/schemas/EventMessagePartDelta" - }, - { - "$ref": "#/components/schemas/EventPermissionAsked" - }, - { - "$ref": "#/components/schemas/EventPermissionReplied" - }, - { - "$ref": "#/components/schemas/EventSessionDiff" - }, - { - "$ref": "#/components/schemas/EventSessionError" - }, - { - "$ref": "#/components/schemas/EventQuestionAsked" - }, - { - "$ref": "#/components/schemas/EventQuestionReplied" - }, - { - "$ref": "#/components/schemas/EventQuestionRejected" - }, - { - "$ref": "#/components/schemas/EventTodoUpdated" - }, - { - "$ref": "#/components/schemas/EventSessionStatus" - }, - { - "$ref": "#/components/schemas/EventSessionIdle" - }, - { - "$ref": "#/components/schemas/EventMcpToolsChanged" - }, - { - "$ref": "#/components/schemas/EventMcpBrowserOpenFailed" - }, - { - "$ref": "#/components/schemas/EventCommandExecuted" - }, - { - "$ref": "#/components/schemas/EventProjectUpdated" - }, - { - "$ref": "#/components/schemas/EventSessionCompacted" - }, - { - "$ref": "#/components/schemas/EventVcsBranchUpdated" - }, - { - "$ref": "#/components/schemas/EventWorkspaceReady" - }, - { - "$ref": "#/components/schemas/EventWorkspaceFailed" - }, - { - "$ref": "#/components/schemas/EventWorkspaceStatus" - }, - { - "$ref": "#/components/schemas/EventWorktreeReady" - }, - { - "$ref": "#/components/schemas/EventWorktreeFailed" - }, - { - "$ref": "#/components/schemas/EventPtyCreated" - }, - { - "$ref": "#/components/schemas/EventPtyUpdated" - }, - { - "$ref": "#/components/schemas/EventPtyExited" - }, - { - "$ref": "#/components/schemas/EventPtyDeleted" - }, - { - "$ref": "#/components/schemas/EventInstallationUpdated" - }, - { - "$ref": "#/components/schemas/EventInstallationUpdate-available" - }, - { - "$ref": "#/components/schemas/EventMessageUpdated" - }, - { - "$ref": "#/components/schemas/EventMessageRemoved" - }, - { - "$ref": "#/components/schemas/EventMessagePartUpdated" - }, - { - "$ref": "#/components/schemas/EventMessagePartRemoved" - }, - { - "$ref": "#/components/schemas/EventSessionCreated" - }, - { - "$ref": "#/components/schemas/EventSessionUpdated" - }, - { - "$ref": "#/components/schemas/EventSessionDeleted" - }, - { - "$ref": "#/components/schemas/EventSessionNextAgentSwitched" - }, - { - "$ref": "#/components/schemas/EventSessionNextModelSwitched" - }, - { - "$ref": "#/components/schemas/EventSessionNextPrompted" - }, - { - "$ref": "#/components/schemas/EventSessionNextSynthetic" - }, - { - "$ref": "#/components/schemas/EventSessionNextShellStarted" - }, - { - "$ref": "#/components/schemas/EventSessionNextShellEnded" - }, - { - "$ref": "#/components/schemas/EventSessionNextStepStarted" - }, - { - "$ref": "#/components/schemas/EventSessionNextStepEnded" - }, - { - "$ref": "#/components/schemas/EventSessionNextStepFailed" - }, - { - "$ref": "#/components/schemas/EventSessionNextTextStarted" - }, - { - "$ref": "#/components/schemas/EventSessionNextTextDelta" - }, - { - "$ref": "#/components/schemas/EventSessionNextTextEnded" - }, - { - "$ref": "#/components/schemas/EventSessionNextReasoningStarted" - }, - { - "$ref": "#/components/schemas/EventSessionNextReasoningDelta" - }, - { - "$ref": "#/components/schemas/EventSessionNextReasoningEnded" - }, - { - "$ref": "#/components/schemas/EventSessionNextToolInputStarted" - }, - { - "$ref": "#/components/schemas/EventSessionNextToolInputDelta" - }, - { - "$ref": "#/components/schemas/EventSessionNextToolInputEnded" - }, - { - "$ref": "#/components/schemas/EventSessionNextToolCalled" - }, - { - "$ref": "#/components/schemas/EventSessionNextToolProgress" - }, - { - "$ref": "#/components/schemas/EventSessionNextToolSuccess" - }, - { - "$ref": "#/components/schemas/EventSessionNextToolFailed" - }, - { - "$ref": "#/components/schemas/EventSessionNextRetried" - }, - { - "$ref": "#/components/schemas/EventSessionNextCompactionStarted" - }, - { - "$ref": "#/components/schemas/EventSessionNextCompactionDelta" - }, - { - "$ref": "#/components/schemas/EventSessionNextCompactionEnded" - }, - { - "$ref": "#/components/schemas/EventPluginAdded" - }, - { - "$ref": "#/components/schemas/EventCatalogModelUpdated" - }, - { - "$ref": "#/components/schemas/EventSessionNextAgentSwitched" - }, - { - "$ref": "#/components/schemas/EventSessionNextModelSwitched" - }, - { - "$ref": "#/components/schemas/EventSessionNextPrompted" - }, - { - "$ref": "#/components/schemas/EventSessionNextSynthetic" - }, - { - "$ref": "#/components/schemas/EventSessionNextShellStarted" - }, - { - "$ref": "#/components/schemas/EventSessionNextShellEnded" - }, - { - "$ref": "#/components/schemas/EventSessionNextStepStarted" - }, - { - "$ref": "#/components/schemas/EventSessionNextStepEnded" - }, - { - "$ref": "#/components/schemas/EventSessionNextStepFailed" - }, - { - "$ref": "#/components/schemas/EventSessionNextTextStarted" - }, - { - "$ref": "#/components/schemas/EventSessionNextTextDelta" - }, - { - "$ref": "#/components/schemas/EventSessionNextTextEnded" - }, - { - "$ref": "#/components/schemas/EventSessionNextReasoningStarted" - }, - { - "$ref": "#/components/schemas/EventSessionNextReasoningDelta" - }, - { - "$ref": "#/components/schemas/EventSessionNextReasoningEnded" - }, - { - "$ref": "#/components/schemas/EventSessionNextToolInputStarted" - }, - { - "$ref": "#/components/schemas/EventSessionNextToolInputDelta" - }, - { - "$ref": "#/components/schemas/EventSessionNextToolInputEnded" - }, - { - "$ref": "#/components/schemas/EventSessionNextToolCalled" - }, - { - "$ref": "#/components/schemas/EventSessionNextToolProgress" - }, - { - "$ref": "#/components/schemas/EventSessionNextToolSuccess" - }, - { - "$ref": "#/components/schemas/EventSessionNextToolFailed" - }, - { - "$ref": "#/components/schemas/EventSessionNextRetried" - }, - { - "$ref": "#/components/schemas/EventSessionNextCompactionStarted" - }, - { - "$ref": "#/components/schemas/EventSessionNextCompactionDelta" - }, - { - "$ref": "#/components/schemas/EventSessionNextCompactionEnded" - }, - { - "$ref": "#/components/schemas/EventModels-devRefreshed" - }, - { - "$ref": "#/components/schemas/EventAccountAdded" - }, - { - "$ref": "#/components/schemas/EventAccountRemoved" - }, - { - "$ref": "#/components/schemas/EventAccountSwitched" - }, - { - "$ref": "#/components/schemas/SyncEventMessageUpdated" - }, - { - "$ref": "#/components/schemas/SyncEventMessageRemoved" - }, - { - "$ref": "#/components/schemas/SyncEventMessagePartUpdated" - }, - { - "$ref": "#/components/schemas/SyncEventMessagePartRemoved" - }, - { - "$ref": "#/components/schemas/SyncEventSessionCreated" - }, - { - "$ref": "#/components/schemas/SyncEventSessionUpdated" - }, - { - "$ref": "#/components/schemas/SyncEventSessionDeleted" - }, - { - "$ref": "#/components/schemas/SyncEventSessionNextAgentSwitched" - }, - { - "$ref": "#/components/schemas/SyncEventSessionNextModelSwitched" - }, - { - "$ref": "#/components/schemas/SyncEventSessionNextPrompted" - }, - { - "$ref": "#/components/schemas/SyncEventSessionNextSynthetic" - }, - { - "$ref": "#/components/schemas/SyncEventSessionNextShellStarted" - }, - { - "$ref": "#/components/schemas/SyncEventSessionNextShellEnded" - }, - { - "$ref": "#/components/schemas/SyncEventSessionNextStepStarted" - }, - { - "$ref": "#/components/schemas/SyncEventSessionNextStepEnded" - }, - { - "$ref": "#/components/schemas/SyncEventSessionNextStepFailed" - }, - { - "$ref": "#/components/schemas/SyncEventSessionNextTextStarted" - }, - { - "$ref": "#/components/schemas/SyncEventSessionNextTextDelta" - }, - { - "$ref": "#/components/schemas/SyncEventSessionNextTextEnded" - }, - { - "$ref": "#/components/schemas/SyncEventSessionNextReasoningStarted" - }, - { - "$ref": "#/components/schemas/SyncEventSessionNextReasoningDelta" - }, - { - "$ref": "#/components/schemas/SyncEventSessionNextReasoningEnded" - }, - { - "$ref": "#/components/schemas/SyncEventSessionNextToolInputStarted" - }, - { - "$ref": "#/components/schemas/SyncEventSessionNextToolInputDelta" - }, - { - "$ref": "#/components/schemas/SyncEventSessionNextToolInputEnded" - }, - { - "$ref": "#/components/schemas/SyncEventSessionNextToolCalled" - }, - { - "$ref": "#/components/schemas/SyncEventSessionNextToolProgress" - }, - { - "$ref": "#/components/schemas/SyncEventSessionNextToolSuccess" - }, - { - "$ref": "#/components/schemas/SyncEventSessionNextToolFailed" - }, - { - "$ref": "#/components/schemas/SyncEventSessionNextRetried" - }, - { - "$ref": "#/components/schemas/SyncEventSessionNextCompactionStarted" - }, - { - "$ref": "#/components/schemas/SyncEventSessionNextCompactionDelta" - }, - { - "$ref": "#/components/schemas/SyncEventSessionNextCompactionEnded" + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["models-dev.refreshed"] + }, + "properties": { + "type": "object", + "properties": {} + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["plugin.added"] + }, + "properties": { + "type": "object", + "properties": { + "id": { + "type": "string" + } + }, + "required": ["id"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["catalog.model.updated"] + }, + "properties": { + "type": "object", + "properties": { + "model": { + "$ref": "#/components/schemas/ModelV2Info" + } + }, + "required": ["model"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["file.edited"] + }, + "properties": { + "type": "object", + "properties": { + "file": { + "type": "string" + } + }, + "required": ["file"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.agent.switched"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "agent": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "agent"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.model.switched"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "model": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "providerID": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "required": ["id", "providerID"], + "additionalProperties": false + } + }, + "required": ["timestamp", "sessionID", "model"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.prompted"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "prompt": { + "$ref": "#/components/schemas/Prompt" + } + }, + "required": ["timestamp", "sessionID", "prompt"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.synthetic"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "text": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "text"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["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"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["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"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["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"], + "additionalProperties": false + }, + "snapshot": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "agent", "model"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["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": "number" + }, + "output": { + "type": "number" + }, + "reasoning": { + "type": "number" + }, + "cache": { + "type": "object", + "properties": { + "read": { + "type": "number" + }, + "write": { + "type": "number" + } + }, + "required": ["read", "write"], + "additionalProperties": false + } + }, + "required": ["input", "output", "reasoning", "cache"], + "additionalProperties": false + }, + "snapshot": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "finish", "cost", "tokens"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.step.failed"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "error": { + "$ref": "#/components/schemas/SessionErrorUnknown" + } + }, + "required": ["timestamp", "sessionID", "error"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.text.started"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + } + }, + "required": ["timestamp", "sessionID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.text.delta"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "delta": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "delta"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.text.ended"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "text": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "text"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.reasoning.started"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "reasoningID": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "reasoningID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["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"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["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"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["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"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["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"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["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"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["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" + }, + "provider": { + "type": "object", + "properties": { + "executed": { + "type": "boolean" + }, + "metadata": { + "type": "object" + } + }, + "required": ["executed"], + "additionalProperties": false + } + }, + "required": ["timestamp", "sessionID", "callID", "tool", "input", "provider"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.tool.progress"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "callID": { + "type": "string" + }, + "structured": { + "type": "object" + }, + "content": { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/components/schemas/ToolTextContent" + }, + { + "$ref": "#/components/schemas/ToolFileContent" + } + ] + } + } + }, + "required": ["timestamp", "sessionID", "callID", "structured", "content"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.tool.success"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "callID": { + "type": "string" + }, + "structured": { + "type": "object" + }, + "content": { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/components/schemas/ToolTextContent" + }, + { + "$ref": "#/components/schemas/ToolFileContent" + } + ] + } + }, + "provider": { + "type": "object", + "properties": { + "executed": { + "type": "boolean" + }, + "metadata": { + "type": "object" + } + }, + "required": ["executed"], + "additionalProperties": false + } + }, + "required": ["timestamp", "sessionID", "callID", "structured", "content", "provider"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.tool.failed"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "callID": { + "type": "string" + }, + "error": { + "$ref": "#/components/schemas/SessionErrorUnknown" + }, + "provider": { + "type": "object", + "properties": { + "executed": { + "type": "boolean" + }, + "metadata": { + "type": "object" + } + }, + "required": ["executed"], + "additionalProperties": false + } + }, + "required": ["timestamp", "sessionID", "callID", "error", "provider"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.retried"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "attempt": { + "type": "number" + }, + "error": { + "$ref": "#/components/schemas/SessionNextRetry_error" + } + }, + "required": ["timestamp", "sessionID", "attempt", "error"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["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"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.compaction.delta"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "text": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "text"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["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"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["file.watcher.updated"] + }, + "properties": { + "type": "object", + "properties": { + "file": { + "type": "string" + }, + "event": { + "type": "string", + "enum": ["add", "change", "unlink"] + } + }, + "required": ["file", "event"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.created"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "info": { + "$ref": "#/components/schemas/Session" + } + }, + "required": ["sessionID", "info"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.updated"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "info": { + "$ref": "#/components/schemas/Session" + } + }, + "required": ["sessionID", "info"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.deleted"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "info": { + "$ref": "#/components/schemas/Session" + } + }, + "required": ["sessionID", "info"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["message.updated"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "info": { + "$ref": "#/components/schemas/Message" + } + }, + "required": ["sessionID", "info"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["message.removed"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "messageID": { + "type": "string", + "pattern": "^msg" + } + }, + "required": ["sessionID", "messageID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["message.part.updated"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "part": { + "$ref": "#/components/schemas/Part" + }, + "time": { + "type": "number" + } + }, + "required": ["sessionID", "part", "time"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["message.part.removed"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "messageID": { + "type": "string", + "pattern": "^msg" + }, + "partID": { + "type": "string", + "pattern": "^prt" + } + }, + "required": ["sessionID", "messageID", "partID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["message.part.delta"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "messageID": { + "type": "string", + "pattern": "^msg" + }, + "partID": { + "type": "string", + "pattern": "^prt" + }, + "field": { + "type": "string" + }, + "delta": { + "type": "string" + } + }, + "required": ["sessionID", "messageID", "partID", "field", "delta"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["permission.asked"] + }, + "properties": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^per" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "permission": { + "type": "string" + }, + "patterns": { + "type": "array", + "items": { + "type": "string" + } + }, + "metadata": { + "type": "object" + }, + "always": { + "type": "array", + "items": { + "type": "string" + } + }, + "tool": { + "type": "object", + "properties": { + "messageID": { + "type": "string", + "pattern": "^msg" + }, + "callID": { + "type": "string" + } + }, + "required": ["messageID", "callID"], + "additionalProperties": false + } + }, + "required": ["id", "sessionID", "permission", "patterns", "metadata", "always"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["permission.replied"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "requestID": { + "type": "string", + "pattern": "^per" + }, + "reply": { + "type": "string", + "enum": ["once", "always", "reject"] + } + }, + "required": ["sessionID", "requestID", "reply"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.diff"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "diff": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SnapshotFileDiff" + } + } + }, + "required": ["sessionID", "diff"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.error"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "error": { + "anyOf": [ + { + "$ref": "#/components/schemas/ProviderAuthError" + }, + { + "$ref": "#/components/schemas/UnknownError" + }, + { + "$ref": "#/components/schemas/MessageOutputLengthError" + }, + { + "$ref": "#/components/schemas/MessageAbortedError" + }, + { + "$ref": "#/components/schemas/StructuredOutputError" + }, + { + "$ref": "#/components/schemas/ContextOverflowError" + }, + { + "$ref": "#/components/schemas/APIError" + } + ] + } + }, + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["question.asked"] + }, + "properties": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^que" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "questions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/QuestionInfo" + }, + "description": "Questions to ask" + }, + "tool": { + "$ref": "#/components/schemas/QuestionTool" + } + }, + "required": ["id", "sessionID", "questions"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["question.replied"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "requestID": { + "type": "string", + "pattern": "^que" + }, + "answers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/QuestionAnswer" + } + } + }, + "required": ["sessionID", "requestID", "answers"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["question.rejected"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "requestID": { + "type": "string", + "pattern": "^que" + } + }, + "required": ["sessionID", "requestID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["todo.updated"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "todos": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Todo" + } + } + }, + "required": ["sessionID", "todos"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.status"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "status": { + "$ref": "#/components/schemas/SessionStatus" + } + }, + "required": ["sessionID", "status"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.idle"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + } + }, + "required": ["sessionID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.compacted"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + } + }, + "required": ["sessionID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["lsp.updated"] + }, + "properties": { + "type": "object", + "properties": {} + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["tui.prompt.append"] + }, + "properties": { + "type": "object", + "properties": { + "text": { + "type": "string" + } + }, + "required": ["text"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["tui.command.execute"] + }, + "properties": { + "type": "object", + "properties": { + "command": { + "anyOf": [ + { + "type": "string", + "enum": [ + "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" + ] + }, + { + "type": "string" + } + ] + } + }, + "required": ["command"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["tui.toast.show"] + }, + "properties": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "message": { + "type": "string" + }, + "variant": { + "type": "string", + "enum": ["info", "success", "warning", "error"] + }, + "duration": { + "type": "integer", + "exclusiveMinimum": 0 + } + }, + "required": ["message", "variant"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["tui.session.select"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses", + "description": "Session ID to navigate to" + } + }, + "required": ["sessionID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["mcp.tools.changed"] + }, + "properties": { + "type": "object", + "properties": { + "server": { + "type": "string" + } + }, + "required": ["server"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["mcp.browser.open.failed"] + }, + "properties": { + "type": "object", + "properties": { + "mcpName": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": ["mcpName", "url"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["command.executed"] + }, + "properties": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "arguments": { + "type": "string" + }, + "messageID": { + "type": "string", + "pattern": "^msg" + } + }, + "required": ["name", "sessionID", "arguments", "messageID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["project.updated"] + }, + "properties": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "worktree": { + "type": "string" + }, + "vcs": { + "type": "string", + "enum": ["git"] + }, + "name": { + "type": "string" + }, + "icon": { + "type": "object", + "properties": { + "url": { + "type": "string" + }, + "override": { + "type": "string" + }, + "color": { + "type": "string" + } + }, + "additionalProperties": false + }, + "commands": { + "type": "object", + "properties": { + "start": { + "type": "string", + "description": "Startup script to run when creating a new workspace (worktree)" + } + }, + "additionalProperties": false + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "integer", + "minimum": 0 + }, + "updated": { + "type": "integer", + "minimum": 0 + }, + "initialized": { + "type": "integer", + "minimum": 0 + } + }, + "required": ["created", "updated"], + "additionalProperties": false + }, + "sandboxes": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["id", "worktree", "time", "sandboxes"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["vcs.branch.updated"] + }, + "properties": { + "type": "object", + "properties": { + "branch": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["workspace.ready"] + }, + "properties": { + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "required": ["name"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["workspace.failed"] + }, + "properties": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + }, + "required": ["message"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["workspace.status"] + }, + "properties": { + "type": "object", + "properties": { + "workspaceID": { + "type": "string", + "pattern": "^wrk" + }, + "status": { + "type": "string", + "enum": ["connected", "connecting", "disconnected", "error"] + } + }, + "required": ["workspaceID", "status"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["worktree.ready"] + }, + "properties": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "branch": { + "type": "string" + } + }, + "required": ["name"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["worktree.failed"] + }, + "properties": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + }, + "required": ["message"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["pty.created"] + }, + "properties": { + "type": "object", + "properties": { + "info": { + "$ref": "#/components/schemas/Pty" + } + }, + "required": ["info"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["pty.updated"] + }, + "properties": { + "type": "object", + "properties": { + "info": { + "$ref": "#/components/schemas/Pty" + } + }, + "required": ["info"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["pty.exited"] + }, + "properties": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^pty" + }, + "exitCode": { + "type": "integer", + "minimum": 0 + } + }, + "required": ["id", "exitCode"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["pty.deleted"] + }, + "properties": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^pty" + } + }, + "required": ["id"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["installation.updated"] + }, + "properties": { + "type": "object", + "properties": { + "version": { + "type": "string" + } + }, + "required": ["version"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["installation.update-available"] + }, + "properties": { + "type": "object", + "properties": { + "version": { + "type": "string" + } + }, + "required": ["version"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["server.connected"] + }, + "properties": { + "type": "object", + "properties": {} + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["global.disposed"] + }, + "properties": { + "type": "object", + "properties": {} + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false } ] } @@ -14789,6 +16341,32 @@ "required": ["directory"], "additionalProperties": false }, + "PermissionAction": { + "type": "string", + "enum": ["allow", "deny", "ask"] + }, + "PermissionRule": { + "type": "object", + "properties": { + "permission": { + "type": "string" + }, + "pattern": { + "type": "string" + }, + "action": { + "$ref": "#/components/schemas/PermissionAction" + } + }, + "required": ["permission", "pattern", "action"], + "additionalProperties": false + }, + "PermissionRuleset": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PermissionRule" + } + }, "ProjectSummary": { "type": "object", "properties": { @@ -15499,6 +17077,76 @@ "required": ["_tag", "name", "message"], "additionalProperties": false }, + "Project": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "worktree": { + "type": "string" + }, + "vcs": { + "type": "string", + "enum": ["git"] + }, + "name": { + "type": "string" + }, + "icon": { + "type": "object", + "properties": { + "url": { + "type": "string" + }, + "override": { + "type": "string" + }, + "color": { + "type": "string" + } + }, + "additionalProperties": false + }, + "commands": { + "type": "object", + "properties": { + "start": { + "type": "string", + "description": "Startup script to run when creating a new workspace (worktree)" + } + }, + "additionalProperties": false + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "integer", + "minimum": 0 + }, + "updated": { + "type": "integer", + "minimum": 0 + }, + "initialized": { + "type": "integer", + "minimum": 0 + } + }, + "required": ["created", "updated"], + "additionalProperties": false + }, + "sandboxes": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["id", "worktree", "time", "sandboxes"], + "additionalProperties": false + }, "ProjectNotFoundError": { "type": "object", "properties": { @@ -15547,6 +17195,31 @@ "required": ["_tag", "message"], "additionalProperties": false }, + "QuestionRequest": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^que" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "questions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/QuestionInfo" + }, + "description": "Questions to ask" + }, + "tool": { + "$ref": "#/components/schemas/QuestionTool" + } + }, + "required": ["id", "sessionID", "questions"], + "additionalProperties": false + }, "QuestionNotFoundError": { "type": "object", "properties": { @@ -15564,6 +17237,53 @@ "required": ["_tag", "requestID", "message"], "additionalProperties": false }, + "PermissionRequest": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^per" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "permission": { + "type": "string" + }, + "patterns": { + "type": "array", + "items": { + "type": "string" + } + }, + "metadata": { + "type": "object" + }, + "always": { + "type": "array", + "items": { + "type": "string" + } + }, + "tool": { + "type": "object", + "properties": { + "messageID": { + "type": "string", + "pattern": "^msg" + }, + "callID": { + "type": "string" + } + }, + "required": ["messageID", "callID"], + "additionalProperties": false + } + }, + "required": ["id", "sessionID", "permission", "patterns", "metadata", "always"], + "additionalProperties": false + }, "PermissionNotFoundError": { "type": "object", "properties": { @@ -15744,6 +17464,344 @@ "required": ["name", "data"], "additionalProperties": false }, + "Session1": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^ses" + }, + "slug": { + "type": "string" + }, + "projectID": { + "type": "string" + }, + "workspaceID": { + "type": "string", + "pattern": "^wrk" + }, + "directory": { + "type": "string" + }, + "path": { + "type": "string" + }, + "parentID": { + "type": "string", + "pattern": "^ses" + }, + "summary": { + "type": "object", + "properties": { + "additions": { + "type": "number" + }, + "deletions": { + "type": "number" + }, + "files": { + "type": "number" + }, + "diffs": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SnapshotFileDiff" + } + } + }, + "required": ["additions", "deletions", "files"], + "additionalProperties": false + }, + "cost": { + "type": "number" + }, + "tokens": { + "type": "object", + "properties": { + "input": { + "type": "number" + }, + "output": { + "type": "number" + }, + "reasoning": { + "type": "number" + }, + "cache": { + "type": "object", + "properties": { + "read": { + "type": "number" + }, + "write": { + "type": "number" + } + }, + "required": ["read", "write"], + "additionalProperties": false + } + }, + "required": ["input", "output", "reasoning", "cache"], + "additionalProperties": false + }, + "share": { + "type": "object", + "properties": { + "url": { + "type": "string" + } + }, + "required": ["url"], + "additionalProperties": false + }, + "title": { + "type": "string" + }, + "agent": { + "type": "string" + }, + "model": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "providerID": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "required": ["id", "providerID"], + "additionalProperties": false + }, + "version": { + "type": "string" + }, + "metadata": { + "type": "object" + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "integer", + "minimum": 0 + }, + "updated": { + "type": "integer", + "minimum": 0 + }, + "compacting": { + "type": "integer", + "minimum": 0 + }, + "archived": { + "type": "number" + } + }, + "required": ["created", "updated"], + "additionalProperties": false + }, + "permission": { + "$ref": "#/components/schemas/PermissionRuleset" + }, + "revert": { + "type": "object", + "properties": { + "messageID": { + "type": "string", + "pattern": "^msg" + }, + "partID": { + "type": "string", + "pattern": "^prt" + }, + "snapshot": { + "type": "string" + }, + "diff": { + "type": "string" + } + }, + "required": ["messageID"], + "additionalProperties": false + } + }, + "required": ["id", "slug", "projectID", "directory", "title", "version", "time"], + "additionalProperties": false + }, + "Session2": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^ses" + }, + "slug": { + "type": "string" + }, + "projectID": { + "type": "string" + }, + "workspaceID": { + "type": "string", + "pattern": "^wrk" + }, + "directory": { + "type": "string" + }, + "path": { + "type": "string" + }, + "parentID": { + "type": "string", + "pattern": "^ses" + }, + "summary": { + "type": "object", + "properties": { + "additions": { + "type": "number" + }, + "deletions": { + "type": "number" + }, + "files": { + "type": "number" + }, + "diffs": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SnapshotFileDiff" + } + } + }, + "required": ["additions", "deletions", "files"], + "additionalProperties": false + }, + "cost": { + "type": "number" + }, + "tokens": { + "type": "object", + "properties": { + "input": { + "type": "number" + }, + "output": { + "type": "number" + }, + "reasoning": { + "type": "number" + }, + "cache": { + "type": "object", + "properties": { + "read": { + "type": "number" + }, + "write": { + "type": "number" + } + }, + "required": ["read", "write"], + "additionalProperties": false + } + }, + "required": ["input", "output", "reasoning", "cache"], + "additionalProperties": false + }, + "share": { + "type": "object", + "properties": { + "url": { + "type": "string" + } + }, + "required": ["url"], + "additionalProperties": false + }, + "title": { + "type": "string" + }, + "agent": { + "type": "string" + }, + "model": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "providerID": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "required": ["id", "providerID"], + "additionalProperties": false + }, + "version": { + "type": "string" + }, + "metadata": { + "type": "object" + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "integer", + "minimum": 0 + }, + "updated": { + "type": "integer", + "minimum": 0 + }, + "compacting": { + "type": "integer", + "minimum": 0 + }, + "archived": { + "type": "number" + } + }, + "required": ["created", "updated"], + "additionalProperties": false + }, + "permission": { + "$ref": "#/components/schemas/PermissionRuleset" + }, + "revert": { + "type": "object", + "properties": { + "messageID": { + "type": "string", + "pattern": "^msg" + }, + "partID": { + "type": "string", + "pattern": "^prt" + }, + "snapshot": { + "type": "string" + }, + "diff": { + "type": "string" + } + }, + "required": ["messageID"], + "additionalProperties": false + } + }, + "required": ["id", "slug", "projectID", "directory", "title", "version", "time"], + "additionalProperties": false + }, "NotFoundError": { "type": "object", "required": ["name", "data"], @@ -15763,6 +17821,851 @@ } } }, + "Session3": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^ses" + }, + "slug": { + "type": "string" + }, + "projectID": { + "type": "string" + }, + "workspaceID": { + "type": "string", + "pattern": "^wrk" + }, + "directory": { + "type": "string" + }, + "path": { + "type": "string" + }, + "parentID": { + "type": "string", + "pattern": "^ses" + }, + "summary": { + "type": "object", + "properties": { + "additions": { + "type": "number" + }, + "deletions": { + "type": "number" + }, + "files": { + "type": "number" + }, + "diffs": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SnapshotFileDiff" + } + } + }, + "required": ["additions", "deletions", "files"], + "additionalProperties": false + }, + "cost": { + "type": "number" + }, + "tokens": { + "type": "object", + "properties": { + "input": { + "type": "number" + }, + "output": { + "type": "number" + }, + "reasoning": { + "type": "number" + }, + "cache": { + "type": "object", + "properties": { + "read": { + "type": "number" + }, + "write": { + "type": "number" + } + }, + "required": ["read", "write"], + "additionalProperties": false + } + }, + "required": ["input", "output", "reasoning", "cache"], + "additionalProperties": false + }, + "share": { + "type": "object", + "properties": { + "url": { + "type": "string" + } + }, + "required": ["url"], + "additionalProperties": false + }, + "title": { + "type": "string" + }, + "agent": { + "type": "string" + }, + "model": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "providerID": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "required": ["id", "providerID"], + "additionalProperties": false + }, + "version": { + "type": "string" + }, + "metadata": { + "type": "object" + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "integer", + "minimum": 0 + }, + "updated": { + "type": "integer", + "minimum": 0 + }, + "compacting": { + "type": "integer", + "minimum": 0 + }, + "archived": { + "type": "number" + } + }, + "required": ["created", "updated"], + "additionalProperties": false + }, + "permission": { + "$ref": "#/components/schemas/PermissionRuleset" + }, + "revert": { + "type": "object", + "properties": { + "messageID": { + "type": "string", + "pattern": "^msg" + }, + "partID": { + "type": "string", + "pattern": "^prt" + }, + "snapshot": { + "type": "string" + }, + "diff": { + "type": "string" + } + }, + "required": ["messageID"], + "additionalProperties": false + } + }, + "required": ["id", "slug", "projectID", "directory", "title", "version", "time"], + "additionalProperties": false + }, + "Session4": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^ses" + }, + "slug": { + "type": "string" + }, + "projectID": { + "type": "string" + }, + "workspaceID": { + "type": "string", + "pattern": "^wrk" + }, + "directory": { + "type": "string" + }, + "path": { + "type": "string" + }, + "parentID": { + "type": "string", + "pattern": "^ses" + }, + "summary": { + "type": "object", + "properties": { + "additions": { + "type": "number" + }, + "deletions": { + "type": "number" + }, + "files": { + "type": "number" + }, + "diffs": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SnapshotFileDiff" + } + } + }, + "required": ["additions", "deletions", "files"], + "additionalProperties": false + }, + "cost": { + "type": "number" + }, + "tokens": { + "type": "object", + "properties": { + "input": { + "type": "number" + }, + "output": { + "type": "number" + }, + "reasoning": { + "type": "number" + }, + "cache": { + "type": "object", + "properties": { + "read": { + "type": "number" + }, + "write": { + "type": "number" + } + }, + "required": ["read", "write"], + "additionalProperties": false + } + }, + "required": ["input", "output", "reasoning", "cache"], + "additionalProperties": false + }, + "share": { + "type": "object", + "properties": { + "url": { + "type": "string" + } + }, + "required": ["url"], + "additionalProperties": false + }, + "title": { + "type": "string" + }, + "agent": { + "type": "string" + }, + "model": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "providerID": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "required": ["id", "providerID"], + "additionalProperties": false + }, + "version": { + "type": "string" + }, + "metadata": { + "type": "object" + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "integer", + "minimum": 0 + }, + "updated": { + "type": "integer", + "minimum": 0 + }, + "compacting": { + "type": "integer", + "minimum": 0 + }, + "archived": { + "type": "number" + } + }, + "required": ["created", "updated"], + "additionalProperties": false + }, + "permission": { + "$ref": "#/components/schemas/PermissionRuleset" + }, + "revert": { + "type": "object", + "properties": { + "messageID": { + "type": "string", + "pattern": "^msg" + }, + "partID": { + "type": "string", + "pattern": "^prt" + }, + "snapshot": { + "type": "string" + }, + "diff": { + "type": "string" + } + }, + "required": ["messageID"], + "additionalProperties": false + } + }, + "required": ["id", "slug", "projectID", "directory", "title", "version", "time"], + "additionalProperties": false + }, + "Session5": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^ses" + }, + "slug": { + "type": "string" + }, + "projectID": { + "type": "string" + }, + "workspaceID": { + "type": "string", + "pattern": "^wrk" + }, + "directory": { + "type": "string" + }, + "path": { + "type": "string" + }, + "parentID": { + "type": "string", + "pattern": "^ses" + }, + "summary": { + "type": "object", + "properties": { + "additions": { + "type": "number" + }, + "deletions": { + "type": "number" + }, + "files": { + "type": "number" + }, + "diffs": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SnapshotFileDiff" + } + } + }, + "required": ["additions", "deletions", "files"], + "additionalProperties": false + }, + "cost": { + "type": "number" + }, + "tokens": { + "type": "object", + "properties": { + "input": { + "type": "number" + }, + "output": { + "type": "number" + }, + "reasoning": { + "type": "number" + }, + "cache": { + "type": "object", + "properties": { + "read": { + "type": "number" + }, + "write": { + "type": "number" + } + }, + "required": ["read", "write"], + "additionalProperties": false + } + }, + "required": ["input", "output", "reasoning", "cache"], + "additionalProperties": false + }, + "share": { + "type": "object", + "properties": { + "url": { + "type": "string" + } + }, + "required": ["url"], + "additionalProperties": false + }, + "title": { + "type": "string" + }, + "agent": { + "type": "string" + }, + "model": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "providerID": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "required": ["id", "providerID"], + "additionalProperties": false + }, + "version": { + "type": "string" + }, + "metadata": { + "type": "object" + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "integer", + "minimum": 0 + }, + "updated": { + "type": "integer", + "minimum": 0 + }, + "compacting": { + "type": "integer", + "minimum": 0 + }, + "archived": { + "type": "number" + } + }, + "required": ["created", "updated"], + "additionalProperties": false + }, + "permission": { + "$ref": "#/components/schemas/PermissionRuleset" + }, + "revert": { + "type": "object", + "properties": { + "messageID": { + "type": "string", + "pattern": "^msg" + }, + "partID": { + "type": "string", + "pattern": "^prt" + }, + "snapshot": { + "type": "string" + }, + "diff": { + "type": "string" + } + }, + "required": ["messageID"], + "additionalProperties": false + } + }, + "required": ["id", "slug", "projectID", "directory", "title", "version", "time"], + "additionalProperties": false + }, + "Session6": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^ses" + }, + "slug": { + "type": "string" + }, + "projectID": { + "type": "string" + }, + "workspaceID": { + "type": "string", + "pattern": "^wrk" + }, + "directory": { + "type": "string" + }, + "path": { + "type": "string" + }, + "parentID": { + "type": "string", + "pattern": "^ses" + }, + "summary": { + "type": "object", + "properties": { + "additions": { + "type": "number" + }, + "deletions": { + "type": "number" + }, + "files": { + "type": "number" + }, + "diffs": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SnapshotFileDiff" + } + } + }, + "required": ["additions", "deletions", "files"], + "additionalProperties": false + }, + "cost": { + "type": "number" + }, + "tokens": { + "type": "object", + "properties": { + "input": { + "type": "number" + }, + "output": { + "type": "number" + }, + "reasoning": { + "type": "number" + }, + "cache": { + "type": "object", + "properties": { + "read": { + "type": "number" + }, + "write": { + "type": "number" + } + }, + "required": ["read", "write"], + "additionalProperties": false + } + }, + "required": ["input", "output", "reasoning", "cache"], + "additionalProperties": false + }, + "share": { + "type": "object", + "properties": { + "url": { + "type": "string" + } + }, + "required": ["url"], + "additionalProperties": false + }, + "title": { + "type": "string" + }, + "agent": { + "type": "string" + }, + "model": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "providerID": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "required": ["id", "providerID"], + "additionalProperties": false + }, + "version": { + "type": "string" + }, + "metadata": { + "type": "object" + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "integer", + "minimum": 0 + }, + "updated": { + "type": "integer", + "minimum": 0 + }, + "compacting": { + "type": "integer", + "minimum": 0 + }, + "archived": { + "type": "number" + } + }, + "required": ["created", "updated"], + "additionalProperties": false + }, + "permission": { + "$ref": "#/components/schemas/PermissionRuleset" + }, + "revert": { + "type": "object", + "properties": { + "messageID": { + "type": "string", + "pattern": "^msg" + }, + "partID": { + "type": "string", + "pattern": "^prt" + }, + "snapshot": { + "type": "string" + }, + "diff": { + "type": "string" + } + }, + "required": ["messageID"], + "additionalProperties": false + } + }, + "required": ["id", "slug", "projectID", "directory", "title", "version", "time"], + "additionalProperties": false + }, + "Session7": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^ses" + }, + "slug": { + "type": "string" + }, + "projectID": { + "type": "string" + }, + "workspaceID": { + "type": "string", + "pattern": "^wrk" + }, + "directory": { + "type": "string" + }, + "path": { + "type": "string" + }, + "parentID": { + "type": "string", + "pattern": "^ses" + }, + "summary": { + "type": "object", + "properties": { + "additions": { + "type": "number" + }, + "deletions": { + "type": "number" + }, + "files": { + "type": "number" + }, + "diffs": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SnapshotFileDiff" + } + } + }, + "required": ["additions", "deletions", "files"], + "additionalProperties": false + }, + "cost": { + "type": "number" + }, + "tokens": { + "type": "object", + "properties": { + "input": { + "type": "number" + }, + "output": { + "type": "number" + }, + "reasoning": { + "type": "number" + }, + "cache": { + "type": "object", + "properties": { + "read": { + "type": "number" + }, + "write": { + "type": "number" + } + }, + "required": ["read", "write"], + "additionalProperties": false + } + }, + "required": ["input", "output", "reasoning", "cache"], + "additionalProperties": false + }, + "share": { + "type": "object", + "properties": { + "url": { + "type": "string" + } + }, + "required": ["url"], + "additionalProperties": false + }, + "title": { + "type": "string" + }, + "agent": { + "type": "string" + }, + "model": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "providerID": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "required": ["id", "providerID"], + "additionalProperties": false + }, + "version": { + "type": "string" + }, + "metadata": { + "type": "object" + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "integer", + "minimum": 0 + }, + "updated": { + "type": "integer", + "minimum": 0 + }, + "compacting": { + "type": "integer", + "minimum": 0 + }, + "archived": { + "type": "number" + } + }, + "required": ["created", "updated"], + "additionalProperties": false + }, + "permission": { + "$ref": "#/components/schemas/PermissionRuleset" + }, + "revert": { + "type": "object", + "properties": { + "messageID": { + "type": "string", + "pattern": "^msg" + }, + "partID": { + "type": "string", + "pattern": "^prt" + }, + "snapshot": { + "type": "string" + }, + "diff": { + "type": "string" + } + }, + "required": ["messageID"], + "additionalProperties": false + } + }, + "required": ["id", "slug", "projectID", "directory", "title", "version", "time"], + "additionalProperties": false + }, "TextPartInput": { "type": "object", "properties": { @@ -15925,6 +18828,344 @@ "required": ["_tag", "sessionID", "message"], "additionalProperties": false }, + "Session8": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^ses" + }, + "slug": { + "type": "string" + }, + "projectID": { + "type": "string" + }, + "workspaceID": { + "type": "string", + "pattern": "^wrk" + }, + "directory": { + "type": "string" + }, + "path": { + "type": "string" + }, + "parentID": { + "type": "string", + "pattern": "^ses" + }, + "summary": { + "type": "object", + "properties": { + "additions": { + "type": "number" + }, + "deletions": { + "type": "number" + }, + "files": { + "type": "number" + }, + "diffs": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SnapshotFileDiff" + } + } + }, + "required": ["additions", "deletions", "files"], + "additionalProperties": false + }, + "cost": { + "type": "number" + }, + "tokens": { + "type": "object", + "properties": { + "input": { + "type": "number" + }, + "output": { + "type": "number" + }, + "reasoning": { + "type": "number" + }, + "cache": { + "type": "object", + "properties": { + "read": { + "type": "number" + }, + "write": { + "type": "number" + } + }, + "required": ["read", "write"], + "additionalProperties": false + } + }, + "required": ["input", "output", "reasoning", "cache"], + "additionalProperties": false + }, + "share": { + "type": "object", + "properties": { + "url": { + "type": "string" + } + }, + "required": ["url"], + "additionalProperties": false + }, + "title": { + "type": "string" + }, + "agent": { + "type": "string" + }, + "model": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "providerID": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "required": ["id", "providerID"], + "additionalProperties": false + }, + "version": { + "type": "string" + }, + "metadata": { + "type": "object" + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "integer", + "minimum": 0 + }, + "updated": { + "type": "integer", + "minimum": 0 + }, + "compacting": { + "type": "integer", + "minimum": 0 + }, + "archived": { + "type": "number" + } + }, + "required": ["created", "updated"], + "additionalProperties": false + }, + "permission": { + "$ref": "#/components/schemas/PermissionRuleset" + }, + "revert": { + "type": "object", + "properties": { + "messageID": { + "type": "string", + "pattern": "^msg" + }, + "partID": { + "type": "string", + "pattern": "^prt" + }, + "snapshot": { + "type": "string" + }, + "diff": { + "type": "string" + } + }, + "required": ["messageID"], + "additionalProperties": false + } + }, + "required": ["id", "slug", "projectID", "directory", "title", "version", "time"], + "additionalProperties": false + }, + "Session9": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^ses" + }, + "slug": { + "type": "string" + }, + "projectID": { + "type": "string" + }, + "workspaceID": { + "type": "string", + "pattern": "^wrk" + }, + "directory": { + "type": "string" + }, + "path": { + "type": "string" + }, + "parentID": { + "type": "string", + "pattern": "^ses" + }, + "summary": { + "type": "object", + "properties": { + "additions": { + "type": "number" + }, + "deletions": { + "type": "number" + }, + "files": { + "type": "number" + }, + "diffs": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SnapshotFileDiff" + } + } + }, + "required": ["additions", "deletions", "files"], + "additionalProperties": false + }, + "cost": { + "type": "number" + }, + "tokens": { + "type": "object", + "properties": { + "input": { + "type": "number" + }, + "output": { + "type": "number" + }, + "reasoning": { + "type": "number" + }, + "cache": { + "type": "object", + "properties": { + "read": { + "type": "number" + }, + "write": { + "type": "number" + } + }, + "required": ["read", "write"], + "additionalProperties": false + } + }, + "required": ["input", "output", "reasoning", "cache"], + "additionalProperties": false + }, + "share": { + "type": "object", + "properties": { + "url": { + "type": "string" + } + }, + "required": ["url"], + "additionalProperties": false + }, + "title": { + "type": "string" + }, + "agent": { + "type": "string" + }, + "model": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "providerID": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "required": ["id", "providerID"], + "additionalProperties": false + }, + "version": { + "type": "string" + }, + "metadata": { + "type": "object" + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "integer", + "minimum": 0 + }, + "updated": { + "type": "integer", + "minimum": 0 + }, + "compacting": { + "type": "integer", + "minimum": 0 + }, + "archived": { + "type": "number" + } + }, + "required": ["created", "updated"], + "additionalProperties": false + }, + "permission": { + "$ref": "#/components/schemas/PermissionRuleset" + }, + "revert": { + "type": "object", + "properties": { + "messageID": { + "type": "string", + "pattern": "^msg" + }, + "partID": { + "type": "string", + "pattern": "^prt" + }, + "snapshot": { + "type": "string" + }, + "diff": { + "type": "string" + } + }, + "required": ["messageID"], + "additionalProperties": false + } + }, + "required": ["id", "slug", "projectID", "directory", "title", "version", "time"], + "additionalProperties": false + }, "V2SessionsResponse": { "type": "object", "properties": { @@ -16317,2197 +19558,80 @@ "required": ["_tag"], "additionalProperties": false }, - "SyncEventMessageUpdated": { + "Event.tui.prompt.append": { "type": "object", "properties": { - "type": { - "type": "string", - "enum": ["sync"] - }, - "name": { - "type": "string", - "enum": ["message.updated.1"] - }, "id": { "type": "string" }, - "seq": { - "type": "number" - }, - "aggregateID": { - "type": "string", - "enum": ["sessionID"] - }, - "data": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "info": { - "$ref": "#/components/schemas/Message" - } - }, - "required": ["sessionID", "info"], - "additionalProperties": false - } - }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"], - "additionalProperties": false - }, - "SyncEventMessageRemoved": { - "type": "object", - "properties": { "type": { "type": "string", - "enum": ["sync"] + "enum": ["tui.prompt.append"] }, - "name": { - "type": "string", - "enum": ["message.removed.1"] - }, - "id": { - "type": "string" - }, - "seq": { - "type": "number" - }, - "aggregateID": { - "type": "string", - "enum": ["sessionID"] - }, - "data": { + "properties": { "type": "object", "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "messageID": { - "type": "string", - "pattern": "^msg" - } - }, - "required": ["sessionID", "messageID"], - "additionalProperties": false - } - }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"], - "additionalProperties": false - }, - "SyncEventMessagePartUpdated": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["sync"] - }, - "name": { - "type": "string", - "enum": ["message.part.updated.1"] - }, - "id": { - "type": "string" - }, - "seq": { - "type": "number" - }, - "aggregateID": { - "type": "string", - "enum": ["sessionID"] - }, - "data": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "part": { - "$ref": "#/components/schemas/Part" - }, - "time": { - "type": "integer", - "minimum": 0 - } - }, - "required": ["sessionID", "part", "time"], - "additionalProperties": false - } - }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"], - "additionalProperties": false - }, - "SyncEventMessagePartRemoved": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["sync"] - }, - "name": { - "type": "string", - "enum": ["message.part.removed.1"] - }, - "id": { - "type": "string" - }, - "seq": { - "type": "number" - }, - "aggregateID": { - "type": "string", - "enum": ["sessionID"] - }, - "data": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "messageID": { - "type": "string", - "pattern": "^msg" - }, - "partID": { - "type": "string", - "pattern": "^prt" - } - }, - "required": ["sessionID", "messageID", "partID"], - "additionalProperties": false - } - }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"], - "additionalProperties": false - }, - "SyncEventSessionCreated": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["sync"] - }, - "name": { - "type": "string", - "enum": ["session.created.1"] - }, - "id": { - "type": "string" - }, - "seq": { - "type": "number" - }, - "aggregateID": { - "type": "string", - "enum": ["sessionID"] - }, - "data": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "info": { - "$ref": "#/components/schemas/Session" - } - }, - "required": ["sessionID", "info"], - "additionalProperties": false - } - }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"], - "additionalProperties": false - }, - "SyncEventSessionUpdated": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["sync"] - }, - "name": { - "type": "string", - "enum": ["session.updated.1"] - }, - "id": { - "type": "string" - }, - "seq": { - "type": "number" - }, - "aggregateID": { - "type": "string", - "enum": ["sessionID"] - }, - "data": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "info": { - "type": "object", - "properties": { - "id": { - "anyOf": [ - { - "type": "string", - "pattern": "^ses" - }, - { - "type": "null" - } - ] - }, - "slug": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "projectID": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "workspaceID": { - "anyOf": [ - { - "type": "string", - "pattern": "^wrk" - }, - { - "type": "null" - } - ] - }, - "directory": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "path": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "parentID": { - "anyOf": [ - { - "type": "string", - "pattern": "^ses" - }, - { - "type": "null" - } - ] - }, - "summary": { - "anyOf": [ - { - "type": "object", - "properties": { - "additions": { - "type": "number" - }, - "deletions": { - "type": "number" - }, - "files": { - "type": "number" - }, - "diffs": { - "type": "array", - "items": { - "$ref": "#/components/schemas/SnapshotFileDiff" - } - } - }, - "required": ["additions", "deletions", "files"], - "additionalProperties": false - }, - { - "type": "null" - } - ] - }, - "cost": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } - ] - }, - "tokens": { - "anyOf": [ - { - "type": "object", - "properties": { - "input": { - "type": "number" - }, - "output": { - "type": "number" - }, - "reasoning": { - "type": "number" - }, - "cache": { - "type": "object", - "properties": { - "read": { - "type": "number" - }, - "write": { - "type": "number" - } - }, - "required": ["read", "write"], - "additionalProperties": false - } - }, - "required": ["input", "output", "reasoning", "cache"], - "additionalProperties": false - }, - { - "type": "null" - } - ] - }, - "share": { - "type": "object", - "properties": { - "url": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - } - }, - "additionalProperties": false - }, - "title": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "agent": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "model": { - "anyOf": [ - { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "providerID": { - "type": "string" - }, - "variant": { - "type": "string" - } - }, - "required": ["id", "providerID"], - "additionalProperties": false - }, - { - "type": "null" - } - ] - }, - "version": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "metadata": { - "anyOf": [ - { - "type": "object" - }, - { - "type": "null" - } - ] - }, - "time": { - "type": "object", - "properties": { - "created": { - "anyOf": [ - { - "type": "integer", - "minimum": 0 - }, - { - "type": "null" - } - ] - }, - "updated": { - "anyOf": [ - { - "type": "integer", - "minimum": 0 - }, - { - "type": "null" - } - ] - }, - "compacting": { - "anyOf": [ - { - "type": "integer", - "minimum": 0 - }, - { - "type": "null" - } - ] - }, - "archived": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } - ] - } - }, - "additionalProperties": false - }, - "permission": { - "anyOf": [ - { - "$ref": "#/components/schemas/PermissionRuleset" - }, - { - "type": "null" - } - ] - }, - "revert": { - "anyOf": [ - { - "type": "object", - "properties": { - "messageID": { - "type": "string", - "pattern": "^msg" - }, - "partID": { - "type": "string", - "pattern": "^prt" - }, - "snapshot": { - "type": "string" - }, - "diff": { - "type": "string" - } - }, - "required": ["messageID"], - "additionalProperties": false - }, - { - "type": "null" - } - ] - } - }, - "additionalProperties": false - } - }, - "required": ["sessionID", "info"], - "additionalProperties": false - } - }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"], - "additionalProperties": false - }, - "SyncEventSessionDeleted": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["sync"] - }, - "name": { - "type": "string", - "enum": ["session.deleted.1"] - }, - "id": { - "type": "string" - }, - "seq": { - "type": "number" - }, - "aggregateID": { - "type": "string", - "enum": ["sessionID"] - }, - "data": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "info": { - "$ref": "#/components/schemas/Session" - } - }, - "required": ["sessionID", "info"], - "additionalProperties": false - } - }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"], - "additionalProperties": false - }, - "SyncEventSessionNextAgentSwitched": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["sync"] - }, - "name": { - "type": "string", - "enum": ["session.next.agent.switched.1"] - }, - "id": { - "type": "string" - }, - "seq": { - "type": "number" - }, - "aggregateID": { - "type": "string", - "enum": ["sessionID"] - }, - "data": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "agent": { - "type": "string" - } - }, - "required": ["timestamp", "sessionID", "agent"], - "additionalProperties": false - } - }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"], - "additionalProperties": false - }, - "SyncEventSessionNextModelSwitched": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["sync"] - }, - "name": { - "type": "string", - "enum": ["session.next.model.switched.1"] - }, - "id": { - "type": "string" - }, - "seq": { - "type": "number" - }, - "aggregateID": { - "type": "string", - "enum": ["sessionID"] - }, - "data": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "model": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "providerID": { - "type": "string" - }, - "variant": { - "type": "string" - } - }, - "required": ["id", "providerID"], - "additionalProperties": false - } - }, - "required": ["timestamp", "sessionID", "model"], - "additionalProperties": false - } - }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"], - "additionalProperties": false - }, - "SyncEventSessionNextPrompted": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["sync"] - }, - "name": { - "type": "string", - "enum": ["session.next.prompted.1"] - }, - "id": { - "type": "string" - }, - "seq": { - "type": "number" - }, - "aggregateID": { - "type": "string", - "enum": ["sessionID"] - }, - "data": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "prompt": { - "$ref": "#/components/schemas/Prompt" - } - }, - "required": ["timestamp", "sessionID", "prompt"], - "additionalProperties": false - } - }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"], - "additionalProperties": false - }, - "SyncEventSessionNextSynthetic": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["sync"] - }, - "name": { - "type": "string", - "enum": ["session.next.synthetic.1"] - }, - "id": { - "type": "string" - }, - "seq": { - "type": "number" - }, - "aggregateID": { - "type": "string", - "enum": ["sessionID"] - }, - "data": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses" - }, "text": { "type": "string" } }, - "required": ["timestamp", "sessionID", "text"], + "required": ["text"], "additionalProperties": false } }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "required": ["id", "type", "properties"], "additionalProperties": false }, - "SyncEventSessionNextShellStarted": { + "Event.tui.command.execute": { "type": "object", "properties": { - "type": { - "type": "string", - "enum": ["sync"] - }, - "name": { - "type": "string", - "enum": ["session.next.shell.started.1"] - }, "id": { "type": "string" }, - "seq": { - "type": "number" - }, - "aggregateID": { + "type": { "type": "string", - "enum": ["sessionID"] + "enum": ["tui.command.execute"] }, - "data": { + "properties": { "type": "object", "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "callID": { - "type": "string" - }, "command": { - "type": "string" - } - }, - "required": ["timestamp", "sessionID", "callID", "command"], - "additionalProperties": false - } - }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"], - "additionalProperties": false - }, - "SyncEventSessionNextShellEnded": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["sync"] - }, - "name": { - "type": "string", - "enum": ["session.next.shell.ended.1"] - }, - "id": { - "type": "string" - }, - "seq": { - "type": "number" - }, - "aggregateID": { - "type": "string", - "enum": ["sessionID"] - }, - "data": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "callID": { - "type": "string" - }, - "output": { - "type": "string" - } - }, - "required": ["timestamp", "sessionID", "callID", "output"], - "additionalProperties": false - } - }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"], - "additionalProperties": false - }, - "SyncEventSessionNextStepStarted": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["sync"] - }, - "name": { - "type": "string", - "enum": ["session.next.step.started.1"] - }, - "id": { - "type": "string" - }, - "seq": { - "type": "number" - }, - "aggregateID": { - "type": "string", - "enum": ["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"], - "additionalProperties": false - }, - "snapshot": { - "type": "string" - } - }, - "required": ["timestamp", "sessionID", "agent", "model"], - "additionalProperties": false - } - }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"], - "additionalProperties": false - }, - "SyncEventSessionNextStepEnded": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["sync"] - }, - "name": { - "type": "string", - "enum": ["session.next.step.ended.1"] - }, - "id": { - "type": "string" - }, - "seq": { - "type": "number" - }, - "aggregateID": { - "type": "string", - "enum": ["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": "number" - }, - "output": { - "type": "number" - }, - "reasoning": { - "type": "number" - }, - "cache": { - "type": "object", - "properties": { - "read": { - "type": "number" - }, - "write": { - "type": "number" - } - }, - "required": ["read", "write"], - "additionalProperties": false - } - }, - "required": ["input", "output", "reasoning", "cache"], - "additionalProperties": false - }, - "snapshot": { - "type": "string" - } - }, - "required": ["timestamp", "sessionID", "finish", "cost", "tokens"], - "additionalProperties": false - } - }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"], - "additionalProperties": false - }, - "SyncEventSessionNextStepFailed": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["sync"] - }, - "name": { - "type": "string", - "enum": ["session.next.step.failed.1"] - }, - "id": { - "type": "string" - }, - "seq": { - "type": "number" - }, - "aggregateID": { - "type": "string", - "enum": ["sessionID"] - }, - "data": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "error": { - "$ref": "#/components/schemas/SessionErrorUnknown" - } - }, - "required": ["timestamp", "sessionID", "error"], - "additionalProperties": false - } - }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"], - "additionalProperties": false - }, - "SyncEventSessionNextTextStarted": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["sync"] - }, - "name": { - "type": "string", - "enum": ["session.next.text.started.1"] - }, - "id": { - "type": "string" - }, - "seq": { - "type": "number" - }, - "aggregateID": { - "type": "string", - "enum": ["sessionID"] - }, - "data": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses" - } - }, - "required": ["timestamp", "sessionID"], - "additionalProperties": false - } - }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"], - "additionalProperties": false - }, - "SyncEventSessionNextTextDelta": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["sync"] - }, - "name": { - "type": "string", - "enum": ["session.next.text.delta.1"] - }, - "id": { - "type": "string" - }, - "seq": { - "type": "number" - }, - "aggregateID": { - "type": "string", - "enum": ["sessionID"] - }, - "data": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "delta": { - "type": "string" - } - }, - "required": ["timestamp", "sessionID", "delta"], - "additionalProperties": false - } - }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"], - "additionalProperties": false - }, - "SyncEventSessionNextTextEnded": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["sync"] - }, - "name": { - "type": "string", - "enum": ["session.next.text.ended.1"] - }, - "id": { - "type": "string" - }, - "seq": { - "type": "number" - }, - "aggregateID": { - "type": "string", - "enum": ["sessionID"] - }, - "data": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "text": { - "type": "string" - } - }, - "required": ["timestamp", "sessionID", "text"], - "additionalProperties": false - } - }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"], - "additionalProperties": false - }, - "SyncEventSessionNextReasoningStarted": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["sync"] - }, - "name": { - "type": "string", - "enum": ["session.next.reasoning.started.1"] - }, - "id": { - "type": "string" - }, - "seq": { - "type": "number" - }, - "aggregateID": { - "type": "string", - "enum": ["sessionID"] - }, - "data": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "reasoningID": { - "type": "string" - } - }, - "required": ["timestamp", "sessionID", "reasoningID"], - "additionalProperties": false - } - }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"], - "additionalProperties": false - }, - "SyncEventSessionNextReasoningDelta": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["sync"] - }, - "name": { - "type": "string", - "enum": ["session.next.reasoning.delta.1"] - }, - "id": { - "type": "string" - }, - "seq": { - "type": "number" - }, - "aggregateID": { - "type": "string", - "enum": ["sessionID"] - }, - "data": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "reasoningID": { - "type": "string" - }, - "delta": { - "type": "string" - } - }, - "required": ["timestamp", "sessionID", "reasoningID", "delta"], - "additionalProperties": false - } - }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"], - "additionalProperties": false - }, - "SyncEventSessionNextReasoningEnded": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["sync"] - }, - "name": { - "type": "string", - "enum": ["session.next.reasoning.ended.1"] - }, - "id": { - "type": "string" - }, - "seq": { - "type": "number" - }, - "aggregateID": { - "type": "string", - "enum": ["sessionID"] - }, - "data": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "reasoningID": { - "type": "string" - }, - "text": { - "type": "string" - } - }, - "required": ["timestamp", "sessionID", "reasoningID", "text"], - "additionalProperties": false - } - }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"], - "additionalProperties": false - }, - "SyncEventSessionNextToolInputStarted": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["sync"] - }, - "name": { - "type": "string", - "enum": ["session.next.tool.input.started.1"] - }, - "id": { - "type": "string" - }, - "seq": { - "type": "number" - }, - "aggregateID": { - "type": "string", - "enum": ["sessionID"] - }, - "data": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "callID": { - "type": "string" - }, - "name": { - "type": "string" - } - }, - "required": ["timestamp", "sessionID", "callID", "name"], - "additionalProperties": false - } - }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"], - "additionalProperties": false - }, - "SyncEventSessionNextToolInputDelta": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["sync"] - }, - "name": { - "type": "string", - "enum": ["session.next.tool.input.delta.1"] - }, - "id": { - "type": "string" - }, - "seq": { - "type": "number" - }, - "aggregateID": { - "type": "string", - "enum": ["sessionID"] - }, - "data": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "callID": { - "type": "string" - }, - "delta": { - "type": "string" - } - }, - "required": ["timestamp", "sessionID", "callID", "delta"], - "additionalProperties": false - } - }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"], - "additionalProperties": false - }, - "SyncEventSessionNextToolInputEnded": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["sync"] - }, - "name": { - "type": "string", - "enum": ["session.next.tool.input.ended.1"] - }, - "id": { - "type": "string" - }, - "seq": { - "type": "number" - }, - "aggregateID": { - "type": "string", - "enum": ["sessionID"] - }, - "data": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "callID": { - "type": "string" - }, - "text": { - "type": "string" - } - }, - "required": ["timestamp", "sessionID", "callID", "text"], - "additionalProperties": false - } - }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"], - "additionalProperties": false - }, - "SyncEventSessionNextToolCalled": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["sync"] - }, - "name": { - "type": "string", - "enum": ["session.next.tool.called.1"] - }, - "id": { - "type": "string" - }, - "seq": { - "type": "number" - }, - "aggregateID": { - "type": "string", - "enum": ["sessionID"] - }, - "data": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "callID": { - "type": "string" - }, - "tool": { - "type": "string" - }, - "input": { - "type": "object" - }, - "provider": { - "type": "object", - "properties": { - "executed": { - "type": "boolean" - }, - "metadata": { - "type": "object" - } - }, - "required": ["executed"], - "additionalProperties": false - } - }, - "required": ["timestamp", "sessionID", "callID", "tool", "input", "provider"], - "additionalProperties": false - } - }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"], - "additionalProperties": false - }, - "SyncEventSessionNextToolProgress": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["sync"] - }, - "name": { - "type": "string", - "enum": ["session.next.tool.progress.1"] - }, - "id": { - "type": "string" - }, - "seq": { - "type": "number" - }, - "aggregateID": { - "type": "string", - "enum": ["sessionID"] - }, - "data": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "callID": { - "type": "string" - }, - "structured": { - "type": "object" - }, - "content": { - "type": "array", - "items": { - "anyOf": [ - { - "$ref": "#/components/schemas/ToolTextContent" - }, - { - "$ref": "#/components/schemas/ToolFileContent" - } - ] - } - } - }, - "required": ["timestamp", "sessionID", "callID", "structured", "content"], - "additionalProperties": false - } - }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"], - "additionalProperties": false - }, - "SyncEventSessionNextToolSuccess": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["sync"] - }, - "name": { - "type": "string", - "enum": ["session.next.tool.success.1"] - }, - "id": { - "type": "string" - }, - "seq": { - "type": "number" - }, - "aggregateID": { - "type": "string", - "enum": ["sessionID"] - }, - "data": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "callID": { - "type": "string" - }, - "structured": { - "type": "object" - }, - "content": { - "type": "array", - "items": { - "anyOf": [ - { - "$ref": "#/components/schemas/ToolTextContent" - }, - { - "$ref": "#/components/schemas/ToolFileContent" - } - ] - } - }, - "provider": { - "type": "object", - "properties": { - "executed": { - "type": "boolean" - }, - "metadata": { - "type": "object" - } - }, - "required": ["executed"], - "additionalProperties": false - } - }, - "required": ["timestamp", "sessionID", "callID", "structured", "content", "provider"], - "additionalProperties": false - } - }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"], - "additionalProperties": false - }, - "SyncEventSessionNextToolFailed": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["sync"] - }, - "name": { - "type": "string", - "enum": ["session.next.tool.failed.1"] - }, - "id": { - "type": "string" - }, - "seq": { - "type": "number" - }, - "aggregateID": { - "type": "string", - "enum": ["sessionID"] - }, - "data": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "callID": { - "type": "string" - }, - "error": { - "$ref": "#/components/schemas/SessionErrorUnknown" - }, - "provider": { - "type": "object", - "properties": { - "executed": { - "type": "boolean" - }, - "metadata": { - "type": "object" - } - }, - "required": ["executed"], - "additionalProperties": false - } - }, - "required": ["timestamp", "sessionID", "callID", "error", "provider"], - "additionalProperties": false - } - }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"], - "additionalProperties": false - }, - "SyncEventSessionNextRetried": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["sync"] - }, - "name": { - "type": "string", - "enum": ["session.next.retried.1"] - }, - "id": { - "type": "string" - }, - "seq": { - "type": "number" - }, - "aggregateID": { - "type": "string", - "enum": ["sessionID"] - }, - "data": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "attempt": { - "type": "number" - }, - "error": { - "$ref": "#/components/schemas/SessionNextRetry_error" - } - }, - "required": ["timestamp", "sessionID", "attempt", "error"], - "additionalProperties": false - } - }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"], - "additionalProperties": false - }, - "SyncEventSessionNextCompactionStarted": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["sync"] - }, - "name": { - "type": "string", - "enum": ["session.next.compaction.started.1"] - }, - "id": { - "type": "string" - }, - "seq": { - "type": "number" - }, - "aggregateID": { - "type": "string", - "enum": ["sessionID"] - }, - "data": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "reason": { - "type": "string", - "enum": ["auto", "manual"] - } - }, - "required": ["timestamp", "sessionID", "reason"], - "additionalProperties": false - } - }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"], - "additionalProperties": false - }, - "SyncEventSessionNextCompactionDelta": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["sync"] - }, - "name": { - "type": "string", - "enum": ["session.next.compaction.delta.1"] - }, - "id": { - "type": "string" - }, - "seq": { - "type": "number" - }, - "aggregateID": { - "type": "string", - "enum": ["sessionID"] - }, - "data": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "text": { - "type": "string" - } - }, - "required": ["timestamp", "sessionID", "text"], - "additionalProperties": false - } - }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"], - "additionalProperties": false - }, - "SyncEventSessionNextCompactionEnded": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["sync"] - }, - "name": { - "type": "string", - "enum": ["session.next.compaction.ended.1"] - }, - "id": { - "type": "string" - }, - "seq": { - "type": "number" - }, - "aggregateID": { - "type": "string", - "enum": ["sessionID"] - }, - "data": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "text": { - "type": "string" - }, - "include": { - "type": "string" - } - }, - "required": ["timestamp", "sessionID", "text"], - "additionalProperties": false - } - }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"], - "additionalProperties": false - }, - "EventServerConnected": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["server.connected"] - }, - "properties": { - "type": "object", - "properties": {} - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "EventGlobalDisposed": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["global.disposed"] - }, - "properties": { - "type": "object", - "properties": {} - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "EventServerInstanceDisposed": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["server.instance.disposed"] - }, - "properties": { - "type": "object", - "properties": { - "directory": { - "type": "string" - } - }, - "required": ["directory"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "EventFileEdited": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["file.edited"] - }, - "properties": { - "type": "object", - "properties": { - "file": { - "type": "string" - } - }, - "required": ["file"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "EventFileWatcherUpdated": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["file.watcher.updated"] - }, - "properties": { - "type": "object", - "properties": { - "file": { - "type": "string" - }, - "event": { - "type": "string", - "enum": ["add", "change", "unlink"] - } - }, - "required": ["file", "event"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "EventLspClientDiagnostics": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["lsp.client.diagnostics"] - }, - "properties": { - "type": "object", - "properties": { - "serverID": { - "type": "string" - }, - "path": { - "type": "string" - } - }, - "required": ["serverID", "path"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "EventLspUpdated": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["lsp.updated"] - }, - "properties": { - "type": "object", - "properties": {} - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "EventMessagePartDelta": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["message.part.delta"] - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "messageID": { - "type": "string", - "pattern": "^msg" - }, - "partID": { - "type": "string", - "pattern": "^prt" - }, - "field": { - "type": "string" - }, - "delta": { - "type": "string" - } - }, - "required": ["sessionID", "messageID", "partID", "field", "delta"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "EventPermissionAsked": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["permission.asked"] - }, - "properties": { - "$ref": "#/components/schemas/PermissionRequest" - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "EventPermissionReplied": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["permission.replied"] - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "requestID": { - "type": "string", - "pattern": "^per" - }, - "reply": { - "type": "string", - "enum": ["once", "always", "reject"] - } - }, - "required": ["sessionID", "requestID", "reply"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "EventSessionDiff": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["session.diff"] - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "diff": { - "type": "array", - "items": { - "$ref": "#/components/schemas/SnapshotFileDiff" - } - } - }, - "required": ["sessionID", "diff"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "EventSessionError": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["session.error"] - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "error": { "anyOf": [ { - "$ref": "#/components/schemas/ProviderAuthError" + "type": "string", + "enum": [ + "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" + ] }, { - "$ref": "#/components/schemas/UnknownError" - }, - { - "$ref": "#/components/schemas/MessageOutputLengthError" - }, - { - "$ref": "#/components/schemas/MessageAbortedError" - }, - { - "$ref": "#/components/schemas/StructuredOutputError" - }, - { - "$ref": "#/components/schemas/ContextOverflowError" - }, - { - "$ref": "#/components/schemas/APIError" + "type": "string" } ] } }, + "required": ["command"], "additionalProperties": false } }, "required": ["id", "type", "properties"], "additionalProperties": false }, - "EventQuestionAsked": { + "Event.tui.toast.show": { "type": "object", "properties": { "id": { @@ -18515,81 +19639,34 @@ }, "type": { "type": "string", - "enum": ["question.asked"] - }, - "properties": { - "$ref": "#/components/schemas/QuestionRequest" - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "EventQuestionReplied": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["question.replied"] - }, - "properties": { - "$ref": "#/components/schemas/QuestionReplied" - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "EventQuestionRejected": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["question.rejected"] - }, - "properties": { - "$ref": "#/components/schemas/QuestionRejected" - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "EventTodoUpdated": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["todo.updated"] + "enum": ["tui.toast.show"] }, "properties": { "type": "object", "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses" + "title": { + "type": "string" }, - "todos": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Todo" - } + "message": { + "type": "string" + }, + "variant": { + "type": "string", + "enum": ["info", "success", "warning", "error"] + }, + "duration": { + "type": "integer", + "exclusiveMinimum": 0 } }, - "required": ["sessionID", "todos"], + "required": ["message", "variant"], "additionalProperties": false } }, "required": ["id", "type", "properties"], "additionalProperties": false }, - "EventSessionStatus": { + "Event.tui.session.select": { "type": "object", "properties": { "id": { @@ -18597,42 +19674,15 @@ }, "type": { "type": "string", - "enum": ["session.status"] + "enum": ["tui.session.select"] }, "properties": { "type": "object", "properties": { "sessionID": { "type": "string", - "pattern": "^ses" - }, - "status": { - "$ref": "#/components/schemas/SessionStatus" - } - }, - "required": ["sessionID", "status"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "EventSessionIdle": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["session.idle"] - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses" + "pattern": "^ses", + "description": "Session ID to navigate to" } }, "required": ["sessionID"], @@ -18642,1808 +19692,6 @@ "required": ["id", "type", "properties"], "additionalProperties": false }, - "EventMcpToolsChanged": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["mcp.tools.changed"] - }, - "properties": { - "type": "object", - "properties": { - "server": { - "type": "string" - } - }, - "required": ["server"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "EventMcpBrowserOpenFailed": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["mcp.browser.open.failed"] - }, - "properties": { - "type": "object", - "properties": { - "mcpName": { - "type": "string" - }, - "url": { - "type": "string" - } - }, - "required": ["mcpName", "url"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "EventCommandExecuted": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["command.executed"] - }, - "properties": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "arguments": { - "type": "string" - }, - "messageID": { - "type": "string", - "pattern": "^msg" - } - }, - "required": ["name", "sessionID", "arguments", "messageID"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "EventProjectUpdated": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["project.updated"] - }, - "properties": { - "$ref": "#/components/schemas/Project" - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "EventSessionCompacted": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["session.compacted"] - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses" - } - }, - "required": ["sessionID"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "EventVcsBranchUpdated": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["vcs.branch.updated"] - }, - "properties": { - "type": "object", - "properties": { - "branch": { - "type": "string" - } - }, - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "EventWorkspaceReady": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["workspace.ready"] - }, - "properties": { - "type": "object", - "properties": { - "name": { - "type": "string" - } - }, - "required": ["name"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "EventWorkspaceFailed": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["workspace.failed"] - }, - "properties": { - "type": "object", - "properties": { - "message": { - "type": "string" - } - }, - "required": ["message"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "EventWorkspaceStatus": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["workspace.status"] - }, - "properties": { - "type": "object", - "properties": { - "workspaceID": { - "type": "string", - "pattern": "^wrk" - }, - "status": { - "type": "string", - "enum": ["connected", "connecting", "disconnected", "error"] - } - }, - "required": ["workspaceID", "status"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "EventWorktreeReady": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["worktree.ready"] - }, - "properties": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "branch": { - "type": "string" - } - }, - "required": ["name"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "EventWorktreeFailed": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["worktree.failed"] - }, - "properties": { - "type": "object", - "properties": { - "message": { - "type": "string" - } - }, - "required": ["message"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "EventPtyCreated": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["pty.created"] - }, - "properties": { - "type": "object", - "properties": { - "info": { - "$ref": "#/components/schemas/Pty" - } - }, - "required": ["info"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "EventPtyUpdated": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["pty.updated"] - }, - "properties": { - "type": "object", - "properties": { - "info": { - "$ref": "#/components/schemas/Pty" - } - }, - "required": ["info"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "EventPtyExited": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["pty.exited"] - }, - "properties": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^pty" - }, - "exitCode": { - "type": "integer", - "minimum": 0 - } - }, - "required": ["id", "exitCode"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "EventPtyDeleted": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["pty.deleted"] - }, - "properties": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^pty" - } - }, - "required": ["id"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "EventInstallationUpdated": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["installation.updated"] - }, - "properties": { - "type": "object", - "properties": { - "version": { - "type": "string" - } - }, - "required": ["version"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "EventInstallationUpdate-available": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["installation.update-available"] - }, - "properties": { - "type": "object", - "properties": { - "version": { - "type": "string" - } - }, - "required": ["version"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "EventMessageUpdated": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["message.updated"] - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "info": { - "$ref": "#/components/schemas/Message" - } - }, - "required": ["sessionID", "info"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "EventMessageRemoved": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["message.removed"] - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "messageID": { - "type": "string", - "pattern": "^msg" - } - }, - "required": ["sessionID", "messageID"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "EventMessagePartUpdated": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["message.part.updated"] - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "part": { - "$ref": "#/components/schemas/Part" - }, - "time": { - "type": "integer", - "minimum": 0 - } - }, - "required": ["sessionID", "part", "time"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "EventMessagePartRemoved": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["message.part.removed"] - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "messageID": { - "type": "string", - "pattern": "^msg" - }, - "partID": { - "type": "string", - "pattern": "^prt" - } - }, - "required": ["sessionID", "messageID", "partID"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "EventSessionCreated": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["session.created"] - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "info": { - "$ref": "#/components/schemas/Session" - } - }, - "required": ["sessionID", "info"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "EventSessionUpdated": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["session.updated"] - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "info": { - "$ref": "#/components/schemas/Session" - } - }, - "required": ["sessionID", "info"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "EventSessionDeleted": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["session.deleted"] - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "info": { - "$ref": "#/components/schemas/Session" - } - }, - "required": ["sessionID", "info"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "EventSessionNextAgentSwitched": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["session.next.agent.switched"] - }, - "properties": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "agent": { - "type": "string" - } - }, - "required": ["timestamp", "sessionID", "agent"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "EventSessionNextModelSwitched": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["session.next.model.switched"] - }, - "properties": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "model": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "providerID": { - "type": "string" - }, - "variant": { - "type": "string" - } - }, - "required": ["id", "providerID"], - "additionalProperties": false - } - }, - "required": ["timestamp", "sessionID", "model"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "PromptSource": { - "type": "object", - "properties": { - "start": { - "type": "number" - }, - "end": { - "type": "number" - }, - "text": { - "type": "string" - } - }, - "required": ["start", "end", "text"], - "additionalProperties": false - }, - "PromptFileAttachment": { - "type": "object", - "properties": { - "uri": { - "type": "string" - }, - "mime": { - "type": "string" - }, - "name": { - "type": "string" - }, - "description": { - "type": "string" - }, - "source": { - "$ref": "#/components/schemas/PromptSource" - } - }, - "required": ["uri", "mime"], - "additionalProperties": false - }, - "PromptAgentAttachment": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "source": { - "$ref": "#/components/schemas/PromptSource" - } - }, - "required": ["name"], - "additionalProperties": false - }, - "PromptReferenceAttachment": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "kind": { - "type": "string", - "enum": ["local", "git", "invalid"] - }, - "uri": { - "type": "string" - }, - "repository": { - "type": "string" - }, - "branch": { - "type": "string" - }, - "target": { - "type": "string" - }, - "targetUri": { - "type": "string" - }, - "problem": { - "type": "string" - }, - "source": { - "$ref": "#/components/schemas/PromptSource" - } - }, - "required": ["name", "kind"], - "additionalProperties": false - }, - "EventSessionNextPrompted": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["session.next.prompted"] - }, - "properties": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "prompt": { - "$ref": "#/components/schemas/Prompt" - } - }, - "required": ["timestamp", "sessionID", "prompt"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "EventSessionNextSynthetic": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["session.next.synthetic"] - }, - "properties": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "text": { - "type": "string" - } - }, - "required": ["timestamp", "sessionID", "text"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "EventSessionNextShellStarted": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["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"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "EventSessionNextShellEnded": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["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"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "EventSessionNextStepStarted": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["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"], - "additionalProperties": false - }, - "snapshot": { - "type": "string" - } - }, - "required": ["timestamp", "sessionID", "agent", "model"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "EventSessionNextStepEnded": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["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": "number" - }, - "output": { - "type": "number" - }, - "reasoning": { - "type": "number" - }, - "cache": { - "type": "object", - "properties": { - "read": { - "type": "number" - }, - "write": { - "type": "number" - } - }, - "required": ["read", "write"], - "additionalProperties": false - } - }, - "required": ["input", "output", "reasoning", "cache"], - "additionalProperties": false - }, - "snapshot": { - "type": "string" - } - }, - "required": ["timestamp", "sessionID", "finish", "cost", "tokens"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "SessionErrorUnknown": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["unknown"] - }, - "message": { - "type": "string" - } - }, - "required": ["type", "message"], - "additionalProperties": false - }, - "EventSessionNextStepFailed": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["session.next.step.failed"] - }, - "properties": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "error": { - "$ref": "#/components/schemas/SessionErrorUnknown" - } - }, - "required": ["timestamp", "sessionID", "error"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "EventSessionNextTextStarted": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["session.next.text.started"] - }, - "properties": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses" - } - }, - "required": ["timestamp", "sessionID"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "EventSessionNextTextDelta": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["session.next.text.delta"] - }, - "properties": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "delta": { - "type": "string" - } - }, - "required": ["timestamp", "sessionID", "delta"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "EventSessionNextTextEnded": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["session.next.text.ended"] - }, - "properties": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "text": { - "type": "string" - } - }, - "required": ["timestamp", "sessionID", "text"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "EventSessionNextReasoningStarted": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["session.next.reasoning.started"] - }, - "properties": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "reasoningID": { - "type": "string" - } - }, - "required": ["timestamp", "sessionID", "reasoningID"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "EventSessionNextReasoningDelta": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["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"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "EventSessionNextReasoningEnded": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["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"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "EventSessionNextToolInputStarted": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["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"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "EventSessionNextToolInputDelta": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["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"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "EventSessionNextToolInputEnded": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["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"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "EventSessionNextToolCalled": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["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" - }, - "provider": { - "type": "object", - "properties": { - "executed": { - "type": "boolean" - }, - "metadata": { - "type": "object" - } - }, - "required": ["executed"], - "additionalProperties": false - } - }, - "required": ["timestamp", "sessionID", "callID", "tool", "input", "provider"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "ToolTextContent": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["text"] - }, - "text": { - "type": "string" - } - }, - "required": ["type", "text"], - "additionalProperties": false - }, - "ToolFileContent": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["file"] - }, - "uri": { - "type": "string" - }, - "mime": { - "type": "string" - }, - "name": { - "type": "string" - } - }, - "required": ["type", "uri", "mime"], - "additionalProperties": false - }, - "EventSessionNextToolProgress": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["session.next.tool.progress"] - }, - "properties": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "callID": { - "type": "string" - }, - "structured": { - "type": "object" - }, - "content": { - "type": "array", - "items": { - "anyOf": [ - { - "$ref": "#/components/schemas/ToolTextContent" - }, - { - "$ref": "#/components/schemas/ToolFileContent" - } - ] - } - } - }, - "required": ["timestamp", "sessionID", "callID", "structured", "content"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "EventSessionNextToolSuccess": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["session.next.tool.success"] - }, - "properties": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "callID": { - "type": "string" - }, - "structured": { - "type": "object" - }, - "content": { - "type": "array", - "items": { - "anyOf": [ - { - "$ref": "#/components/schemas/ToolTextContent" - }, - { - "$ref": "#/components/schemas/ToolFileContent" - } - ] - } - }, - "provider": { - "type": "object", - "properties": { - "executed": { - "type": "boolean" - }, - "metadata": { - "type": "object" - } - }, - "required": ["executed"], - "additionalProperties": false - } - }, - "required": ["timestamp", "sessionID", "callID", "structured", "content", "provider"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "EventSessionNextToolFailed": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["session.next.tool.failed"] - }, - "properties": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "callID": { - "type": "string" - }, - "error": { - "$ref": "#/components/schemas/SessionErrorUnknown" - }, - "provider": { - "type": "object", - "properties": { - "executed": { - "type": "boolean" - }, - "metadata": { - "type": "object" - } - }, - "required": ["executed"], - "additionalProperties": false - } - }, - "required": ["timestamp", "sessionID", "callID", "error", "provider"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "SessionNextRetry_error": { - "type": "object", - "properties": { - "message": { - "type": "string" - }, - "statusCode": { - "type": "number" - }, - "isRetryable": { - "type": "boolean" - }, - "responseHeaders": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, - "responseBody": { - "type": "string" - }, - "metadata": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - }, - "required": ["message", "isRetryable"], - "additionalProperties": false - }, - "EventSessionNextRetried": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["session.next.retried"] - }, - "properties": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "attempt": { - "type": "number" - }, - "error": { - "$ref": "#/components/schemas/SessionNextRetry_error" - } - }, - "required": ["timestamp", "sessionID", "attempt", "error"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "EventSessionNextCompactionStarted": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["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"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "EventSessionNextCompactionDelta": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["session.next.compaction.delta"] - }, - "properties": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "text": { - "type": "string" - } - }, - "required": ["timestamp", "sessionID", "text"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "EventSessionNextCompactionEnded": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["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"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "EventPluginAdded": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["plugin.added"] - }, - "properties": { - "type": "object", - "properties": { - "id": { - "type": "string" - } - }, - "required": ["id"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, "ModelV2Info": { "type": "object", "properties": { @@ -20768,77 +20016,159 @@ ], "additionalProperties": false }, - "EventCatalogModelUpdated": { + "PromptSource": { "type": "object", "properties": { - "id": { + "start": { + "type": "number" + }, + "end": { + "type": "number" + }, + "text": { + "type": "string" + } + }, + "required": ["start", "end", "text"], + "additionalProperties": false + }, + "PromptFileAttachment": { + "type": "object", + "properties": { + "uri": { "type": "string" }, + "mime": { + "type": "string" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "source": { + "$ref": "#/components/schemas/PromptSource" + } + }, + "required": ["uri", "mime"], + "additionalProperties": false + }, + "PromptAgentAttachment": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "source": { + "$ref": "#/components/schemas/PromptSource" + } + }, + "required": ["name"], + "additionalProperties": false + }, + "PromptReferenceAttachment": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "kind": { + "type": "string", + "enum": ["local", "git", "invalid"] + }, + "uri": { + "type": "string" + }, + "repository": { + "type": "string" + }, + "branch": { + "type": "string" + }, + "target": { + "type": "string" + }, + "targetUri": { + "type": "string" + }, + "problem": { + "type": "string" + }, + "source": { + "$ref": "#/components/schemas/PromptSource" + } + }, + "required": ["name", "kind"], + "additionalProperties": false + }, + "SessionErrorUnknown": { + "type": "object", + "properties": { "type": { "type": "string", - "enum": ["catalog.model.updated"] + "enum": ["unknown"] }, - "properties": { + "message": { + "type": "string" + } + }, + "required": ["type", "message"], + "additionalProperties": false + }, + "ToolTextContent": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["text"] + }, + "text": { + "type": "string" + } + }, + "required": ["type", "text"], + "additionalProperties": false + }, + "ToolFileContent": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["file"] + }, + "uri": { + "type": "string" + }, + "mime": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": ["type", "uri", "mime"], + "additionalProperties": false + }, + "SessionNextRetry_error": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "statusCode": { + "type": "number" + }, + "isRetryable": { + "type": "boolean" + }, + "responseHeaders": { "type": "object", - "properties": { - "model": { - "$ref": "#/components/schemas/ModelV2Info" - } - }, - "required": ["model"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "EventModels-devRefreshed": { - "type": "object", - "properties": { - "id": { - "type": "string" + "additionalProperties": { + "type": "string" + } }, - "type": { - "type": "string", - "enum": ["models-dev.refreshed"] - }, - "properties": { - "type": "object", - "properties": {} - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "AccountV2OAuthCredential": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["oauth"] - }, - "refresh": { - "type": "string" - }, - "access": { - "type": "string" - }, - "expires": { - "type": "integer", - "minimum": 0 - } - }, - "required": ["type", "refresh", "access", "expires"], - "additionalProperties": false - }, - "AccountV2ApiKeyCredential": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["api"] - }, - "key": { + "responseBody": { "type": "string" }, "metadata": { @@ -20848,115 +20178,34 @@ } } }, - "required": ["type", "key"], + "required": ["message", "isRetryable"], "additionalProperties": false }, - "AccountV2Credential": { - "anyOf": [ - { - "$ref": "#/components/schemas/AccountV2OAuthCredential" - }, - { - "$ref": "#/components/schemas/AccountV2ApiKeyCredential" - } - ] + "PermissionV2Action": { + "type": "string", + "enum": ["allow", "deny", "ask"] }, - "AccountV2Info": { + "PermissionV2Rule": { "type": "object", "properties": { - "id": { + "permission": { "type": "string" }, - "serviceID": { + "pattern": { "type": "string" }, - "description": { - "type": "string" - }, - "credential": { - "$ref": "#/components/schemas/AccountV2Credential" + "action": { + "$ref": "#/components/schemas/PermissionV2Action" } }, - "required": ["id", "serviceID", "description", "credential"], + "required": ["permission", "pattern", "action"], "additionalProperties": false }, - "EventAccountAdded": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["account.added"] - }, - "properties": { - "type": "object", - "properties": { - "account": { - "$ref": "#/components/schemas/AccountV2Info" - } - }, - "required": ["account"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "EventAccountRemoved": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["account.removed"] - }, - "properties": { - "type": "object", - "properties": { - "account": { - "$ref": "#/components/schemas/AccountV2Info" - } - }, - "required": ["account"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "EventAccountSwitched": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["account.switched"] - }, - "properties": { - "type": "object", - "properties": { - "serviceID": { - "type": "string" - }, - "from": { - "type": "string" - }, - "to": { - "type": "string" - } - }, - "required": ["serviceID"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false + "PermissionV2Ruleset": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PermissionV2Rule" + } }, "PolicyEffect": { "type": "string", @@ -21859,7 +21108,7 @@ "required": ["id", "name", "enabled", "env", "endpoint", "options"], "additionalProperties": false }, - "EventTuiToastShow1": { + "EventModels-devRefreshed": { "type": "object", "properties": { "id": { @@ -21867,27 +21116,34 @@ }, "type": { "type": "string", - "enum": ["tui.toast.show"] + "enum": ["models-dev.refreshed"] + }, + "properties": { + "type": "object", + "properties": {} + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventPluginAdded": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["plugin.added"] }, "properties": { "type": "object", "properties": { - "title": { + "id": { "type": "string" - }, - "message": { - "type": "string" - }, - "variant": { - "type": "string", - "enum": ["info", "success", "warning", "error"] - }, - "duration": { - "type": "integer", - "exclusiveMinimum": 0 } }, - "required": ["message", "variant"], + "required": ["id"], "additionalProperties": false } }, @@ -22214,6 +21470,2363 @@ ], "additionalProperties": false }, + "EventCatalogModelUpdated": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["catalog.model.updated"] + }, + "properties": { + "type": "object", + "properties": { + "model": { + "$ref": "#/components/schemas/ModelV2Info1" + } + }, + "required": ["model"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventFileEdited": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["file.edited"] + }, + "properties": { + "type": "object", + "properties": { + "file": { + "type": "string" + } + }, + "required": ["file"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextAgentSwitched": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.agent.switched"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "agent": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "agent"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextModelSwitched": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.model.switched"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "model": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "providerID": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "required": ["id", "providerID"], + "additionalProperties": false + } + }, + "required": ["timestamp", "sessionID", "model"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextPrompted": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.prompted"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "prompt": { + "$ref": "#/components/schemas/Prompt" + } + }, + "required": ["timestamp", "sessionID", "prompt"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextSynthetic": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.synthetic"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "text": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "text"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextShellStarted": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["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"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextShellEnded": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["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"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextStepStarted": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["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"], + "additionalProperties": false + }, + "snapshot": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "agent", "model"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextStepEnded": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["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": "number" + }, + "output": { + "type": "number" + }, + "reasoning": { + "type": "number" + }, + "cache": { + "type": "object", + "properties": { + "read": { + "type": "number" + }, + "write": { + "type": "number" + } + }, + "required": ["read", "write"], + "additionalProperties": false + } + }, + "required": ["input", "output", "reasoning", "cache"], + "additionalProperties": false + }, + "snapshot": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "finish", "cost", "tokens"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextStepFailed": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.step.failed"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "error": { + "$ref": "#/components/schemas/SessionErrorUnknown" + } + }, + "required": ["timestamp", "sessionID", "error"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextTextStarted": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.text.started"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + } + }, + "required": ["timestamp", "sessionID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextTextDelta": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.text.delta"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "delta": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "delta"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextTextEnded": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.text.ended"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "text": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "text"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextReasoningStarted": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.reasoning.started"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "reasoningID": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "reasoningID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextReasoningDelta": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["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"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextReasoningEnded": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["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"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextToolInputStarted": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["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"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextToolInputDelta": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["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"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextToolInputEnded": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["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"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextToolCalled": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["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" + }, + "provider": { + "type": "object", + "properties": { + "executed": { + "type": "boolean" + }, + "metadata": { + "type": "object" + } + }, + "required": ["executed"], + "additionalProperties": false + } + }, + "required": ["timestamp", "sessionID", "callID", "tool", "input", "provider"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextToolProgress": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.tool.progress"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "callID": { + "type": "string" + }, + "structured": { + "type": "object" + }, + "content": { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/components/schemas/ToolTextContent" + }, + { + "$ref": "#/components/schemas/ToolFileContent" + } + ] + } + } + }, + "required": ["timestamp", "sessionID", "callID", "structured", "content"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextToolSuccess": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.tool.success"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "callID": { + "type": "string" + }, + "structured": { + "type": "object" + }, + "content": { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/components/schemas/ToolTextContent" + }, + { + "$ref": "#/components/schemas/ToolFileContent" + } + ] + } + }, + "provider": { + "type": "object", + "properties": { + "executed": { + "type": "boolean" + }, + "metadata": { + "type": "object" + } + }, + "required": ["executed"], + "additionalProperties": false + } + }, + "required": ["timestamp", "sessionID", "callID", "structured", "content", "provider"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextToolFailed": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.tool.failed"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "callID": { + "type": "string" + }, + "error": { + "$ref": "#/components/schemas/SessionErrorUnknown" + }, + "provider": { + "type": "object", + "properties": { + "executed": { + "type": "boolean" + }, + "metadata": { + "type": "object" + } + }, + "required": ["executed"], + "additionalProperties": false + } + }, + "required": ["timestamp", "sessionID", "callID", "error", "provider"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextRetried": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.retried"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "attempt": { + "type": "number" + }, + "error": { + "$ref": "#/components/schemas/SessionNextRetry_error" + } + }, + "required": ["timestamp", "sessionID", "attempt", "error"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextCompactionStarted": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["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"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextCompactionDelta": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.compaction.delta"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "text": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "text"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextCompactionEnded": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["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"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventFileWatcherUpdated": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["file.watcher.updated"] + }, + "properties": { + "type": "object", + "properties": { + "file": { + "type": "string" + }, + "event": { + "type": "string", + "enum": ["add", "change", "unlink"] + } + }, + "required": ["file", "event"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionCreated": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.created"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "info": { + "$ref": "#/components/schemas/Session" + } + }, + "required": ["sessionID", "info"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionUpdated": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.updated"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "info": { + "$ref": "#/components/schemas/Session" + } + }, + "required": ["sessionID", "info"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionDeleted": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.deleted"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "info": { + "$ref": "#/components/schemas/Session" + } + }, + "required": ["sessionID", "info"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventMessageUpdated": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["message.updated"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "info": { + "$ref": "#/components/schemas/Message" + } + }, + "required": ["sessionID", "info"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventMessageRemoved": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["message.removed"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "messageID": { + "type": "string", + "pattern": "^msg" + } + }, + "required": ["sessionID", "messageID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventMessagePartUpdated": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["message.part.updated"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "part": { + "$ref": "#/components/schemas/Part" + }, + "time": { + "type": "number" + } + }, + "required": ["sessionID", "part", "time"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventMessagePartRemoved": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["message.part.removed"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "messageID": { + "type": "string", + "pattern": "^msg" + }, + "partID": { + "type": "string", + "pattern": "^prt" + } + }, + "required": ["sessionID", "messageID", "partID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventMessagePartDelta": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["message.part.delta"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "messageID": { + "type": "string", + "pattern": "^msg" + }, + "partID": { + "type": "string", + "pattern": "^prt" + }, + "field": { + "type": "string" + }, + "delta": { + "type": "string" + } + }, + "required": ["sessionID", "messageID", "partID", "field", "delta"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventPermissionAsked": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["permission.asked"] + }, + "properties": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^per" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "permission": { + "type": "string" + }, + "patterns": { + "type": "array", + "items": { + "type": "string" + } + }, + "metadata": { + "type": "object" + }, + "always": { + "type": "array", + "items": { + "type": "string" + } + }, + "tool": { + "type": "object", + "properties": { + "messageID": { + "type": "string", + "pattern": "^msg" + }, + "callID": { + "type": "string" + } + }, + "required": ["messageID", "callID"], + "additionalProperties": false + } + }, + "required": ["id", "sessionID", "permission", "patterns", "metadata", "always"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventPermissionReplied": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["permission.replied"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "requestID": { + "type": "string", + "pattern": "^per" + }, + "reply": { + "type": "string", + "enum": ["once", "always", "reject"] + } + }, + "required": ["sessionID", "requestID", "reply"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionDiff": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.diff"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "diff": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SnapshotFileDiff" + } + } + }, + "required": ["sessionID", "diff"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionError": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.error"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "error": { + "anyOf": [ + { + "$ref": "#/components/schemas/ProviderAuthError" + }, + { + "$ref": "#/components/schemas/UnknownError" + }, + { + "$ref": "#/components/schemas/MessageOutputLengthError" + }, + { + "$ref": "#/components/schemas/MessageAbortedError" + }, + { + "$ref": "#/components/schemas/StructuredOutputError" + }, + { + "$ref": "#/components/schemas/ContextOverflowError" + }, + { + "$ref": "#/components/schemas/APIError" + } + ] + } + }, + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventQuestionAsked": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["question.asked"] + }, + "properties": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^que" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "questions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/QuestionInfo" + }, + "description": "Questions to ask" + }, + "tool": { + "$ref": "#/components/schemas/QuestionTool" + } + }, + "required": ["id", "sessionID", "questions"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventQuestionReplied": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["question.replied"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "requestID": { + "type": "string", + "pattern": "^que" + }, + "answers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/QuestionAnswer" + } + } + }, + "required": ["sessionID", "requestID", "answers"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventQuestionRejected": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["question.rejected"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "requestID": { + "type": "string", + "pattern": "^que" + } + }, + "required": ["sessionID", "requestID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventTodoUpdated": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["todo.updated"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "todos": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Todo" + } + } + }, + "required": ["sessionID", "todos"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionStatus": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.status"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "status": { + "$ref": "#/components/schemas/SessionStatus" + } + }, + "required": ["sessionID", "status"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionIdle": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.idle"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + } + }, + "required": ["sessionID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionCompacted": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.compacted"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + } + }, + "required": ["sessionID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventLspUpdated": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["lsp.updated"] + }, + "properties": { + "type": "object", + "properties": {} + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventMcpToolsChanged": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["mcp.tools.changed"] + }, + "properties": { + "type": "object", + "properties": { + "server": { + "type": "string" + } + }, + "required": ["server"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventMcpBrowserOpenFailed": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["mcp.browser.open.failed"] + }, + "properties": { + "type": "object", + "properties": { + "mcpName": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": ["mcpName", "url"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventCommandExecuted": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["command.executed"] + }, + "properties": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "arguments": { + "type": "string" + }, + "messageID": { + "type": "string", + "pattern": "^msg" + } + }, + "required": ["name", "sessionID", "arguments", "messageID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventProjectUpdated": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["project.updated"] + }, + "properties": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "worktree": { + "type": "string" + }, + "vcs": { + "type": "string", + "enum": ["git"] + }, + "name": { + "type": "string" + }, + "icon": { + "type": "object", + "properties": { + "url": { + "type": "string" + }, + "override": { + "type": "string" + }, + "color": { + "type": "string" + } + }, + "additionalProperties": false + }, + "commands": { + "type": "object", + "properties": { + "start": { + "type": "string", + "description": "Startup script to run when creating a new workspace (worktree)" + } + }, + "additionalProperties": false + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "integer", + "minimum": 0 + }, + "updated": { + "type": "integer", + "minimum": 0 + }, + "initialized": { + "type": "integer", + "minimum": 0 + } + }, + "required": ["created", "updated"], + "additionalProperties": false + }, + "sandboxes": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["id", "worktree", "time", "sandboxes"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventVcsBranchUpdated": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["vcs.branch.updated"] + }, + "properties": { + "type": "object", + "properties": { + "branch": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventWorkspaceReady": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["workspace.ready"] + }, + "properties": { + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "required": ["name"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventWorkspaceFailed": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["workspace.failed"] + }, + "properties": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + }, + "required": ["message"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventWorkspaceStatus": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["workspace.status"] + }, + "properties": { + "type": "object", + "properties": { + "workspaceID": { + "type": "string", + "pattern": "^wrk" + }, + "status": { + "type": "string", + "enum": ["connected", "connecting", "disconnected", "error"] + } + }, + "required": ["workspaceID", "status"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventWorktreeReady": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["worktree.ready"] + }, + "properties": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "branch": { + "type": "string" + } + }, + "required": ["name"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventWorktreeFailed": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["worktree.failed"] + }, + "properties": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + }, + "required": ["message"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventPtyCreated": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["pty.created"] + }, + "properties": { + "type": "object", + "properties": { + "info": { + "$ref": "#/components/schemas/Pty" + } + }, + "required": ["info"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventPtyUpdated": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["pty.updated"] + }, + "properties": { + "type": "object", + "properties": { + "info": { + "$ref": "#/components/schemas/Pty" + } + }, + "required": ["info"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventPtyExited": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["pty.exited"] + }, + "properties": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^pty" + }, + "exitCode": { + "type": "integer", + "minimum": 0 + } + }, + "required": ["id", "exitCode"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventPtyDeleted": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["pty.deleted"] + }, + "properties": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^pty" + } + }, + "required": ["id"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventInstallationUpdated": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["installation.updated"] + }, + "properties": { + "type": "object", + "properties": { + "version": { + "type": "string" + } + }, + "required": ["version"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventInstallationUpdate-available": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["installation.update-available"] + }, + "properties": { + "type": "object", + "properties": { + "version": { + "type": "string" + } + }, + "required": ["version"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventServerConnected": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["server.connected"] + }, + "properties": { + "type": "object", + "properties": {} + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventGlobalDisposed": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["global.disposed"] + }, + "properties": { + "type": "object", + "properties": {} + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "AuthOAuthCredential": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["oauth"] + }, + "refresh": { + "type": "string" + }, + "access": { + "type": "string" + }, + "expires": { + "type": "integer", + "minimum": 0 + } + }, + "required": ["type", "refresh", "access", "expires"], + "additionalProperties": false + }, + "AuthApiKeyCredential": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["api"] + }, + "key": { + "type": "string" + }, + "metadata": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "required": ["type", "key"], + "additionalProperties": false + }, + "AuthCredential": { + "anyOf": [ + { + "$ref": "#/components/schemas/AuthOAuthCredential" + }, + { + "$ref": "#/components/schemas/AuthApiKeyCredential" + } + ] + }, + "AuthInfo": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "serviceID": { + "type": "string" + }, + "description": { + "type": "string" + }, + "credential": { + "$ref": "#/components/schemas/AuthCredential" + } + }, + "required": ["id", "serviceID", "description", "credential"], + "additionalProperties": false + }, + "EventAccountAdded": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["account.added"] + }, + "properties": { + "type": "object", + "properties": { + "account": { + "$ref": "#/components/schemas/AuthInfo" + } + }, + "required": ["account"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventAccountRemoved": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["account.removed"] + }, + "properties": { + "type": "object", + "properties": { + "account": { + "$ref": "#/components/schemas/AuthInfo" + } + }, + "required": ["account"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventAccountSwitched": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["account.switched"] + }, + "properties": { + "type": "object", + "properties": { + "serviceID": { + "type": "string" + }, + "from": { + "type": "string" + }, + "to": { + "type": "string" + } + }, + "required": ["serviceID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, "BadRequestError": { "type": "object", "required": ["name", "data"], From 1afa9e32c9ebde43fc94782c883b422a3628daff Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sun, 31 May 2026 01:21:53 +0000 Subject: [PATCH 027/840] chore: update nix node_modules hashes --- nix/hashes.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/nix/hashes.json b/nix/hashes.json index 8e95f0b91a..4dcdf1018f 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-aTweGlyK8w+A9X7FpZnbVh+czXAVlttnn7Efhzm+8kY=", - "aarch64-linux": "sha256-Gl+fwiGv/BC9u8xV4h0NIYbEac+LJSODLSTipWwdWII=", - "aarch64-darwin": "sha256-Vt6eVf9gb+A5nULpMgtrg2YNIQy3L//wNVThVkVbgyc=", - "x86_64-darwin": "sha256-0uHk7YnGTeIOLxajdj+CcAokfAIdDLFeS0VwN0mXRrc=" + "x86_64-linux": "sha256-QKwLTvlWz6HF/TF+ztsIXp0GqT9+K8fq0ppXSk6wiW0=", + "aarch64-linux": "sha256-dokuA/VIwbsDFaWrKJRHhUEEl3184wW8Z6Mf21fOdXQ=", + "aarch64-darwin": "sha256-j7Bhh0qUoAYEL4cuHehPEg8ikgvYLpf+KzNSMdqXuVM=", + "x86_64-darwin": "sha256-oXuUHLVitqw8gjesrHz/G9sEgtRLwKxEALGSca28l+8=" } } From 25edeaf4730b5053fa600f7fda3a98889dff0885 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sun, 31 May 2026 03:18:43 -0400 Subject: [PATCH 028/840] fix(sdk): preserve generated event contracts --- packages/core/src/permission.ts | 6 +- packages/opencode/src/question/index.ts | 4 +- packages/opencode/src/server/event.ts | 7 + .../src/server/routes/instance/httpapi/api.ts | 14 +- .../routes/instance/httpapi/groups/global.ts | 27 +- packages/sdk/js/src/v2/gen/types.gen.ts | 1112 +++--- packages/sdk/openapi.json | 3476 +++++++++-------- 7 files changed, 2496 insertions(+), 2150 deletions(-) diff --git a/packages/core/src/permission.ts b/packages/core/src/permission.ts index 07c7d8e7be..95c2745b9d 100644 --- a/packages/core/src/permission.ts +++ b/packages/core/src/permission.ts @@ -14,17 +14,17 @@ export class PermissionID extends Newtype()( } } -export const Action = Schema.Literals(["allow", "deny", "ask"]).annotate({ identifier: "PermissionV2.Action" }) +export const Action = Schema.Literals(["allow", "deny", "ask"]).annotate({ identifier: "Permission.Action" }) export type Action = typeof Action.Type export const Rule = Schema.Struct({ permission: Schema.String, pattern: Schema.String, action: Action, -}).annotate({ identifier: "PermissionV2.Rule" }) +}).annotate({ identifier: "Permission.Rule" }) export type Rule = typeof Rule.Type -export const Ruleset = Schema.Array(Rule).annotate({ identifier: "PermissionV2.Ruleset" }) +export const Ruleset = Schema.Array(Rule).annotate({ identifier: "Permission.Ruleset" }) export type Ruleset = typeof Ruleset.Type const EDIT_TOOLS = ["edit", "write", "apply_patch"] diff --git a/packages/opencode/src/question/index.ts b/packages/opencode/src/question/index.ts index 051ae7afd6..f93fc8eb20 100644 --- a/packages/opencode/src/question/index.ts +++ b/packages/opencode/src/question/index.ts @@ -75,13 +75,13 @@ export const Reply = Schema.Struct({ }).annotate({ identifier: "QuestionReply" }) export type Reply = Schema.Schema.Type -const Replied = Schema.Struct({ +export const Replied = Schema.Struct({ sessionID: SessionID, requestID: QuestionID, answers: Schema.Array(Answer), }).annotate({ identifier: "QuestionReplied" }) -const Rejected = Schema.Struct({ +export const Rejected = Schema.Struct({ sessionID: SessionID, requestID: QuestionID, }).annotate({ identifier: "QuestionRejected" }) diff --git a/packages/opencode/src/server/event.ts b/packages/opencode/src/server/event.ts index f7b657da33..a581312555 100644 --- a/packages/opencode/src/server/event.ts +++ b/packages/opencode/src/server/event.ts @@ -1,6 +1,13 @@ import { EventV2 } from "@opencode-ai/core/event" +import { Schema } from "effect" export const Event = { Connected: EventV2.define({ type: "server.connected", schema: {} }), Disposed: EventV2.define({ type: "global.disposed", schema: {} }), } + +export const InstanceDisposed = Schema.Struct({ + id: Schema.String, + type: Schema.Literal("server.instance.disposed"), + properties: Schema.Struct({ directory: Schema.String }), +}).annotate({ identifier: "Event.server.instance.disposed" }) diff --git a/packages/opencode/src/server/routes/instance/httpapi/api.ts b/packages/opencode/src/server/routes/instance/httpapi/api.ts index 0f0b695ea8..11649d11f8 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/api.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/api.ts @@ -1,12 +1,13 @@ import { Schema } from "effect" import { HttpApi } from "effect/unstable/httpapi" import { EventV2 } from "@opencode-ai/core/event" +import { InstanceDisposed } from "@/server/event" +import { Question } from "@/question" import { ConfigApi } from "./groups/config" import { ControlApi } from "./groups/control" import { EventApi } from "./groups/event" import { ExperimentalApi } from "./groups/experimental" import { FileApi } from "./groups/file" -import { GlobalApi } from "./groups/global" import { InstanceApi } from "./groups/instance" import { McpApi } from "./groups/mcp" import { PermissionApi } from "./groups/permission" @@ -19,11 +20,13 @@ import { SyncApi } from "./groups/sync" import { TuiApi } from "./groups/tui" import { WorkspaceApi } from "./groups/workspace" import { V2Api } from "./groups/v2" +// GlobalEventSchema snapshots the registry after event-producing groups register their variants. +import { GlobalApi } from "./groups/global" import { Authorization } from "./middleware/authorization" import { SchemaErrorMiddleware } from "./middleware/schema-error" -const EventSchema = Schema.Union( - EventV2.registry +const EventSchema = Schema.Union([ + ...EventV2.registry .values() .map((definition) => Schema.Struct({ @@ -33,7 +36,8 @@ const EventSchema = Schema.Union( }).annotate({ identifier: `Event.${definition.type}` }), ) .toArray(), -).annotate({ identifier: "Event" }) + InstanceDisposed, +]).annotate({ identifier: "Event" }) export const RootHttpApi = HttpApi.make("opencode-root") .addHttpApi(ControlApi) @@ -64,7 +68,7 @@ export const OpenCodeHttpApi = HttpApi.make("opencode") .addHttpApi(EventApi) .addHttpApi(InstanceHttpApi) .addHttpApi(PtyConnectApi) - .annotate(HttpApi.AdditionalSchemas, [EventSchema]) + .annotate(HttpApi.AdditionalSchemas, [EventSchema, Question.Replied, Question.Rejected]) export type RootHttpApiType = typeof RootHttpApi export type InstanceHttpApiType = typeof InstanceHttpApi diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/global.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/global.ts index b7a50962dd..56c741df19 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/global.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/global.ts @@ -1,6 +1,6 @@ import { Config } from "@/config/config" import { EventV2 } from "@opencode-ai/core/event" -import "@/server/event" +import { InstanceDisposed } from "@/server/event" import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" import { described } from "./metadata" @@ -10,18 +10,37 @@ const GlobalHealth = Schema.Struct({ version: Schema.String, }) +const SyncEventSchemas = EventV2.registry + .values() + .flatMap((definition) => { + if (!definition.sync) return [] + return [ + Schema.Struct({ + type: Schema.Literal("sync"), + name: Schema.Literal(EventV2.versionedType(definition.type, definition.sync.version)), + id: Schema.String, + seq: Schema.Finite, + aggregateID: Schema.Literal(definition.sync.aggregate), + data: definition.data, + }).annotate({ identifier: `SyncEvent.${definition.type}` }), + ] + }) + .toArray() + const GlobalEventSchema = Schema.Struct({ directory: Schema.String, project: Schema.optional(Schema.String), workspace: Schema.optional(Schema.String), - payload: Schema.Union( - EventV2.registry + payload: Schema.Union([ + ...EventV2.registry .values() .map((definition) => Schema.Struct({ id: Schema.String, type: Schema.Literal(definition.type), properties: definition.data }), ) .toArray(), - ), + InstanceDisposed, + ...SyncEventSchemas, + ]), }).annotate({ identifier: "GlobalEvent" }) export const GlobalUpgradeInput = Schema.Struct({ diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index b19a5f2440..3be97a5cf9 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -81,6 +81,18 @@ export type Event = | EventAccountAdded | EventAccountRemoved | EventAccountSwitched + | EventServerInstanceDisposed + +export type QuestionReplied = { + sessionID: string + requestID: string + answers: Array +} + +export type QuestionRejected = { + sessionID: string + requestID: string +} export type OAuth = { type: "oauth" @@ -177,7 +189,7 @@ export type Session = { compacting?: number archived?: number } - permission?: PermissionV2Ruleset + permission?: PermissionRuleset revert?: { messageID: string partID?: string @@ -1403,6 +1415,63 @@ export type GlobalEvent = { [key: string]: unknown } } + | { + id: string + type: "account.added" + properties: { + account: AuthInfo + } + } + | { + id: string + type: "account.removed" + properties: { + account: AuthInfo + } + } + | { + id: string + type: "account.switched" + properties: { + serviceID: string + from?: string + to?: string + } + } + | EventServerInstanceDisposed + | SyncEventSessionNextAgentSwitched + | SyncEventSessionNextModelSwitched + | SyncEventSessionNextPrompted + | SyncEventSessionNextSynthetic + | SyncEventSessionNextShellStarted + | SyncEventSessionNextShellEnded + | SyncEventSessionNextStepStarted + | SyncEventSessionNextStepEnded + | SyncEventSessionNextStepFailed + | SyncEventSessionNextTextStarted + | SyncEventSessionNextTextDelta + | SyncEventSessionNextTextEnded + | SyncEventSessionNextReasoningStarted + | SyncEventSessionNextReasoningDelta + | SyncEventSessionNextReasoningEnded + | SyncEventSessionNextToolInputStarted + | SyncEventSessionNextToolInputDelta + | SyncEventSessionNextToolInputEnded + | SyncEventSessionNextToolCalled + | SyncEventSessionNextToolProgress + | SyncEventSessionNextToolSuccess + | SyncEventSessionNextToolFailed + | SyncEventSessionNextRetried + | SyncEventSessionNextCompactionStarted + | SyncEventSessionNextCompactionDelta + | SyncEventSessionNextCompactionEnded + | SyncEventSessionCreated + | SyncEventSessionUpdated + | SyncEventSessionDeleted + | SyncEventMessageUpdated + | SyncEventMessageRemoved + | SyncEventMessagePartUpdated + | SyncEventMessagePartRemoved } /** @@ -2337,112 +2406,6 @@ export type ProviderAuthError1 = { } } -export type Session1 = { - id: string - slug: string - projectID: string - workspaceID?: string - directory: string - path?: string - parentID?: string - summary?: { - additions: number - deletions: number - files: number - diffs?: Array - } - cost?: number - tokens?: { - input: number - output: number - reasoning: number - cache: { - read: number - write: number - } - } - share?: { - url: string - } - title: string - agent?: string - model?: { - id: string - providerID: string - variant?: string - } - version: string - metadata?: { - [key: string]: unknown - } - time: { - created: number - updated: number - compacting?: number - archived?: number - } - permission?: PermissionRuleset - revert?: { - messageID: string - partID?: string - snapshot?: string - diff?: string - } -} - -export type Session2 = { - id: string - slug: string - projectID: string - workspaceID?: string - directory: string - path?: string - parentID?: string - summary?: { - additions: number - deletions: number - files: number - diffs?: Array - } - cost?: number - tokens?: { - input: number - output: number - reasoning: number - cache: { - read: number - write: number - } - } - share?: { - url: string - } - title: string - agent?: string - model?: { - id: string - providerID: string - variant?: string - } - version: string - metadata?: { - [key: string]: unknown - } - time: { - created: number - updated: number - compacting?: number - archived?: number - } - permission?: PermissionRuleset - revert?: { - messageID: string - partID?: string - snapshot?: string - diff?: string - } -} - export type NotFoundError = { name: "NotFoundError" data: { @@ -2450,271 +2413,6 @@ export type NotFoundError = { } } -export type Session3 = { - id: string - slug: string - projectID: string - workspaceID?: string - directory: string - path?: string - parentID?: string - summary?: { - additions: number - deletions: number - files: number - diffs?: Array - } - cost?: number - tokens?: { - input: number - output: number - reasoning: number - cache: { - read: number - write: number - } - } - share?: { - url: string - } - title: string - agent?: string - model?: { - id: string - providerID: string - variant?: string - } - version: string - metadata?: { - [key: string]: unknown - } - time: { - created: number - updated: number - compacting?: number - archived?: number - } - permission?: PermissionRuleset - revert?: { - messageID: string - partID?: string - snapshot?: string - diff?: string - } -} - -export type Session4 = { - id: string - slug: string - projectID: string - workspaceID?: string - directory: string - path?: string - parentID?: string - summary?: { - additions: number - deletions: number - files: number - diffs?: Array - } - cost?: number - tokens?: { - input: number - output: number - reasoning: number - cache: { - read: number - write: number - } - } - share?: { - url: string - } - title: string - agent?: string - model?: { - id: string - providerID: string - variant?: string - } - version: string - metadata?: { - [key: string]: unknown - } - time: { - created: number - updated: number - compacting?: number - archived?: number - } - permission?: PermissionRuleset - revert?: { - messageID: string - partID?: string - snapshot?: string - diff?: string - } -} - -export type Session5 = { - id: string - slug: string - projectID: string - workspaceID?: string - directory: string - path?: string - parentID?: string - summary?: { - additions: number - deletions: number - files: number - diffs?: Array - } - cost?: number - tokens?: { - input: number - output: number - reasoning: number - cache: { - read: number - write: number - } - } - share?: { - url: string - } - title: string - agent?: string - model?: { - id: string - providerID: string - variant?: string - } - version: string - metadata?: { - [key: string]: unknown - } - time: { - created: number - updated: number - compacting?: number - archived?: number - } - permission?: PermissionRuleset - revert?: { - messageID: string - partID?: string - snapshot?: string - diff?: string - } -} - -export type Session6 = { - id: string - slug: string - projectID: string - workspaceID?: string - directory: string - path?: string - parentID?: string - summary?: { - additions: number - deletions: number - files: number - diffs?: Array - } - cost?: number - tokens?: { - input: number - output: number - reasoning: number - cache: { - read: number - write: number - } - } - share?: { - url: string - } - title: string - agent?: string - model?: { - id: string - providerID: string - variant?: string - } - version: string - metadata?: { - [key: string]: unknown - } - time: { - created: number - updated: number - compacting?: number - archived?: number - } - permission?: PermissionRuleset - revert?: { - messageID: string - partID?: string - snapshot?: string - diff?: string - } -} - -export type Session7 = { - id: string - slug: string - projectID: string - workspaceID?: string - directory: string - path?: string - parentID?: string - summary?: { - additions: number - deletions: number - files: number - diffs?: Array - } - cost?: number - tokens?: { - input: number - output: number - reasoning: number - cache: { - read: number - write: number - } - } - share?: { - url: string - } - title: string - agent?: string - model?: { - id: string - providerID: string - variant?: string - } - version: string - metadata?: { - [key: string]: unknown - } - time: { - created: number - updated: number - compacting?: number - archived?: number - } - permission?: PermissionRuleset - revert?: { - messageID: string - partID?: string - snapshot?: string - diff?: string - } -} - export type TextPartInput = { id?: string type: "text" @@ -2769,112 +2467,6 @@ export type SessionBusyError = { message: string } -export type Session8 = { - id: string - slug: string - projectID: string - workspaceID?: string - directory: string - path?: string - parentID?: string - summary?: { - additions: number - deletions: number - files: number - diffs?: Array - } - cost?: number - tokens?: { - input: number - output: number - reasoning: number - cache: { - read: number - write: number - } - } - share?: { - url: string - } - title: string - agent?: string - model?: { - id: string - providerID: string - variant?: string - } - version: string - metadata?: { - [key: string]: unknown - } - time: { - created: number - updated: number - compacting?: number - archived?: number - } - permission?: PermissionRuleset - revert?: { - messageID: string - partID?: string - snapshot?: string - diff?: string - } -} - -export type Session9 = { - id: string - slug: string - projectID: string - workspaceID?: string - directory: string - path?: string - parentID?: string - summary?: { - additions: number - deletions: number - files: number - diffs?: Array - } - cost?: number - tokens?: { - input: number - output: number - reasoning: number - cache: { - read: number - write: number - } - } - share?: { - url: string - } - title: string - agent?: string - model?: { - id: string - providerID: string - variant?: string - } - version: string - metadata?: { - [key: string]: unknown - } - time: { - created: number - updated: number - compacting?: number - archived?: number - } - permission?: PermissionRuleset - revert?: { - messageID: string - partID?: string - snapshot?: string - diff?: string - } -} - export type V2SessionsResponse = { items: Array cursor: { @@ -3219,15 +2811,521 @@ export type SessionNextRetryError = { } } -export type PermissionV2Action = "allow" | "deny" | "ask" - -export type PermissionV2Rule = { - permission: string - pattern: string - action: PermissionV2Action +export type AuthOAuthCredential = { + type: "oauth" + refresh: string + access: string + expires: number } -export type PermissionV2Ruleset = Array +export type AuthApiKeyCredential = { + type: "api" + key: string + metadata?: { + [key: string]: string + } +} + +export type AuthCredential = AuthOAuthCredential | AuthApiKeyCredential + +export type AuthInfo = { + id: string + serviceID: string + description: string + credential: AuthCredential +} + +export type EventServerInstanceDisposed = { + id: string + type: "server.instance.disposed" + properties: { + directory: string + } +} + +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 + model: { + 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 SyncEventSessionNextStepFailed = { + type: "sync" + name: "session.next.step.failed.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + error: SessionErrorUnknown + } +} + +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 SyncEventSessionNextToolFailed = { + type: "sync" + name: "session.next.tool.failed.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + callID: string + error: SessionErrorUnknown + 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 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: Session + } +} + +export type SyncEventSessionDeleted = { + type: "sync" + name: "session.deleted.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + sessionID: string + info: Session + } +} + +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 PolicyEffect = "allow" | "deny" @@ -4375,30 +4473,6 @@ export type EventGlobalDisposed = { } } -export type AuthOAuthCredential = { - type: "oauth" - refresh: string - access: string - expires: number -} - -export type AuthApiKeyCredential = { - type: "api" - key: string - metadata?: { - [key: string]: string - } -} - -export type AuthCredential = AuthOAuthCredential | AuthApiKeyCredential - -export type AuthInfo = { - id: string - serviceID: string - description: string - credential: AuthCredential -} - export type EventAccountAdded = { id: string type: "account.added" @@ -6657,7 +6731,7 @@ export type SessionListResponses = { /** * List of sessions */ - 200: Array + 200: Array } export type SessionListResponse = SessionListResponses[keyof SessionListResponses] @@ -6699,7 +6773,7 @@ export type SessionCreateResponses = { /** * Successfully created session */ - 200: Session3 + 200: Session } export type SessionCreateResponse = SessionCreateResponses[keyof SessionCreateResponses] @@ -6797,7 +6871,7 @@ export type SessionGetResponses = { /** * Get session */ - 200: Session2 + 200: Session } export type SessionGetResponse = SessionGetResponses[keyof SessionGetResponses] @@ -6840,7 +6914,7 @@ export type SessionUpdateResponses = { /** * Successfully updated session */ - 200: Session4 + 200: Session } export type SessionUpdateResponse = SessionUpdateResponses[keyof SessionUpdateResponses] @@ -6874,7 +6948,7 @@ export type SessionChildrenResponses = { /** * List of children */ - 200: Array + 200: Array } export type SessionChildrenResponse = SessionChildrenResponses[keyof SessionChildrenResponses] @@ -7143,7 +7217,7 @@ export type SessionForkResponses = { /** * 200 */ - 200: Session5 + 200: Session } export type SessionForkResponse = SessionForkResponses[keyof SessionForkResponses] @@ -7249,7 +7323,7 @@ export type SessionUnshareResponses = { /** * Successfully unshared session */ - 200: Session7 + 200: Session } export type SessionUnshareResponse = SessionUnshareResponses[keyof SessionUnshareResponses] @@ -7287,7 +7361,7 @@ export type SessionShareResponses = { /** * Successfully shared session */ - 200: Session6 + 200: Session } export type SessionShareResponse = SessionShareResponses[keyof SessionShareResponses] @@ -7516,7 +7590,7 @@ export type SessionRevertResponses = { /** * Updated session */ - 200: Session8 + 200: Session } export type SessionRevertResponse = SessionRevertResponses[keyof SessionRevertResponses] @@ -7554,7 +7628,7 @@ export type SessionUnrevertResponses = { /** * Updated session */ - 200: Session9 + 200: Session } export type SessionUnrevertResponse = SessionUnrevertResponses[keyof SessionUnrevertResponses] diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 06b923bd7d..c5990ae473 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -5074,7 +5074,7 @@ "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/Session1" + "$ref": "#/components/schemas/Session" }, "description": "List of sessions" } @@ -5128,7 +5128,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Session3" + "$ref": "#/components/schemas/Session" } } } @@ -5311,7 +5311,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Session2" + "$ref": "#/components/schemas/Session" } } } @@ -5468,7 +5468,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Session4" + "$ref": "#/components/schemas/Session" } } } @@ -5580,7 +5580,7 @@ "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/Session1" + "$ref": "#/components/schemas/Session" }, "description": "List of children" } @@ -6298,7 +6298,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Session5" + "$ref": "#/components/schemas/Session" } } } @@ -6569,7 +6569,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Session6" + "$ref": "#/components/schemas/Session" } } } @@ -6650,7 +6650,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Session7" + "$ref": "#/components/schemas/Session" } } } @@ -7273,7 +7273,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Session8" + "$ref": "#/components/schemas/Session" } } } @@ -7384,7 +7384,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Session9" + "$ref": "#/components/schemas/Session" } } } @@ -10715,9 +10715,48 @@ }, { "$ref": "#/components/schemas/EventAccountSwitched" + }, + { + "$ref": "#/components/schemas/EventServerInstanceDisposed" } ] }, + "QuestionReplied": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "requestID": { + "type": "string", + "pattern": "^que" + }, + "answers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/QuestionAnswer" + } + } + }, + "required": ["sessionID", "requestID", "answers"], + "additionalProperties": false + }, + "QuestionRejected": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "requestID": { + "type": "string", + "pattern": "^que" + } + }, + "required": ["sessionID", "requestID"], + "additionalProperties": false + }, "OAuth": { "type": "object", "properties": { @@ -11019,7 +11058,7 @@ "additionalProperties": false }, "permission": { - "$ref": "#/components/schemas/PermissionV2Ruleset" + "$ref": "#/components/schemas/PermissionRuleset" }, "revert": { "type": "object", @@ -14882,6 +14921,186 @@ }, "required": ["id", "type", "properties"], "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["account.added"] + }, + "properties": { + "type": "object", + "properties": { + "account": { + "$ref": "#/components/schemas/AuthInfo" + } + }, + "required": ["account"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["account.removed"] + }, + "properties": { + "type": "object", + "properties": { + "account": { + "$ref": "#/components/schemas/AuthInfo" + } + }, + "required": ["account"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["account.switched"] + }, + "properties": { + "type": "object", + "properties": { + "serviceID": { + "type": "string" + }, + "from": { + "type": "string" + }, + "to": { + "type": "string" + } + }, + "required": ["serviceID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "$ref": "#/components/schemas/EventServerInstanceDisposed" + }, + { + "$ref": "#/components/schemas/SyncEventSessionNextAgentSwitched" + }, + { + "$ref": "#/components/schemas/SyncEventSessionNextModelSwitched" + }, + { + "$ref": "#/components/schemas/SyncEventSessionNextPrompted" + }, + { + "$ref": "#/components/schemas/SyncEventSessionNextSynthetic" + }, + { + "$ref": "#/components/schemas/SyncEventSessionNextShellStarted" + }, + { + "$ref": "#/components/schemas/SyncEventSessionNextShellEnded" + }, + { + "$ref": "#/components/schemas/SyncEventSessionNextStepStarted" + }, + { + "$ref": "#/components/schemas/SyncEventSessionNextStepEnded" + }, + { + "$ref": "#/components/schemas/SyncEventSessionNextStepFailed" + }, + { + "$ref": "#/components/schemas/SyncEventSessionNextTextStarted" + }, + { + "$ref": "#/components/schemas/SyncEventSessionNextTextDelta" + }, + { + "$ref": "#/components/schemas/SyncEventSessionNextTextEnded" + }, + { + "$ref": "#/components/schemas/SyncEventSessionNextReasoningStarted" + }, + { + "$ref": "#/components/schemas/SyncEventSessionNextReasoningDelta" + }, + { + "$ref": "#/components/schemas/SyncEventSessionNextReasoningEnded" + }, + { + "$ref": "#/components/schemas/SyncEventSessionNextToolInputStarted" + }, + { + "$ref": "#/components/schemas/SyncEventSessionNextToolInputDelta" + }, + { + "$ref": "#/components/schemas/SyncEventSessionNextToolInputEnded" + }, + { + "$ref": "#/components/schemas/SyncEventSessionNextToolCalled" + }, + { + "$ref": "#/components/schemas/SyncEventSessionNextToolProgress" + }, + { + "$ref": "#/components/schemas/SyncEventSessionNextToolSuccess" + }, + { + "$ref": "#/components/schemas/SyncEventSessionNextToolFailed" + }, + { + "$ref": "#/components/schemas/SyncEventSessionNextRetried" + }, + { + "$ref": "#/components/schemas/SyncEventSessionNextCompactionStarted" + }, + { + "$ref": "#/components/schemas/SyncEventSessionNextCompactionDelta" + }, + { + "$ref": "#/components/schemas/SyncEventSessionNextCompactionEnded" + }, + { + "$ref": "#/components/schemas/SyncEventSessionCreated" + }, + { + "$ref": "#/components/schemas/SyncEventSessionUpdated" + }, + { + "$ref": "#/components/schemas/SyncEventSessionDeleted" + }, + { + "$ref": "#/components/schemas/SyncEventMessageUpdated" + }, + { + "$ref": "#/components/schemas/SyncEventMessageRemoved" + }, + { + "$ref": "#/components/schemas/SyncEventMessagePartUpdated" + }, + { + "$ref": "#/components/schemas/SyncEventMessagePartRemoved" } ] } @@ -17464,344 +17683,6 @@ "required": ["name", "data"], "additionalProperties": false }, - "Session1": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^ses" - }, - "slug": { - "type": "string" - }, - "projectID": { - "type": "string" - }, - "workspaceID": { - "type": "string", - "pattern": "^wrk" - }, - "directory": { - "type": "string" - }, - "path": { - "type": "string" - }, - "parentID": { - "type": "string", - "pattern": "^ses" - }, - "summary": { - "type": "object", - "properties": { - "additions": { - "type": "number" - }, - "deletions": { - "type": "number" - }, - "files": { - "type": "number" - }, - "diffs": { - "type": "array", - "items": { - "$ref": "#/components/schemas/SnapshotFileDiff" - } - } - }, - "required": ["additions", "deletions", "files"], - "additionalProperties": false - }, - "cost": { - "type": "number" - }, - "tokens": { - "type": "object", - "properties": { - "input": { - "type": "number" - }, - "output": { - "type": "number" - }, - "reasoning": { - "type": "number" - }, - "cache": { - "type": "object", - "properties": { - "read": { - "type": "number" - }, - "write": { - "type": "number" - } - }, - "required": ["read", "write"], - "additionalProperties": false - } - }, - "required": ["input", "output", "reasoning", "cache"], - "additionalProperties": false - }, - "share": { - "type": "object", - "properties": { - "url": { - "type": "string" - } - }, - "required": ["url"], - "additionalProperties": false - }, - "title": { - "type": "string" - }, - "agent": { - "type": "string" - }, - "model": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "providerID": { - "type": "string" - }, - "variant": { - "type": "string" - } - }, - "required": ["id", "providerID"], - "additionalProperties": false - }, - "version": { - "type": "string" - }, - "metadata": { - "type": "object" - }, - "time": { - "type": "object", - "properties": { - "created": { - "type": "integer", - "minimum": 0 - }, - "updated": { - "type": "integer", - "minimum": 0 - }, - "compacting": { - "type": "integer", - "minimum": 0 - }, - "archived": { - "type": "number" - } - }, - "required": ["created", "updated"], - "additionalProperties": false - }, - "permission": { - "$ref": "#/components/schemas/PermissionRuleset" - }, - "revert": { - "type": "object", - "properties": { - "messageID": { - "type": "string", - "pattern": "^msg" - }, - "partID": { - "type": "string", - "pattern": "^prt" - }, - "snapshot": { - "type": "string" - }, - "diff": { - "type": "string" - } - }, - "required": ["messageID"], - "additionalProperties": false - } - }, - "required": ["id", "slug", "projectID", "directory", "title", "version", "time"], - "additionalProperties": false - }, - "Session2": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^ses" - }, - "slug": { - "type": "string" - }, - "projectID": { - "type": "string" - }, - "workspaceID": { - "type": "string", - "pattern": "^wrk" - }, - "directory": { - "type": "string" - }, - "path": { - "type": "string" - }, - "parentID": { - "type": "string", - "pattern": "^ses" - }, - "summary": { - "type": "object", - "properties": { - "additions": { - "type": "number" - }, - "deletions": { - "type": "number" - }, - "files": { - "type": "number" - }, - "diffs": { - "type": "array", - "items": { - "$ref": "#/components/schemas/SnapshotFileDiff" - } - } - }, - "required": ["additions", "deletions", "files"], - "additionalProperties": false - }, - "cost": { - "type": "number" - }, - "tokens": { - "type": "object", - "properties": { - "input": { - "type": "number" - }, - "output": { - "type": "number" - }, - "reasoning": { - "type": "number" - }, - "cache": { - "type": "object", - "properties": { - "read": { - "type": "number" - }, - "write": { - "type": "number" - } - }, - "required": ["read", "write"], - "additionalProperties": false - } - }, - "required": ["input", "output", "reasoning", "cache"], - "additionalProperties": false - }, - "share": { - "type": "object", - "properties": { - "url": { - "type": "string" - } - }, - "required": ["url"], - "additionalProperties": false - }, - "title": { - "type": "string" - }, - "agent": { - "type": "string" - }, - "model": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "providerID": { - "type": "string" - }, - "variant": { - "type": "string" - } - }, - "required": ["id", "providerID"], - "additionalProperties": false - }, - "version": { - "type": "string" - }, - "metadata": { - "type": "object" - }, - "time": { - "type": "object", - "properties": { - "created": { - "type": "integer", - "minimum": 0 - }, - "updated": { - "type": "integer", - "minimum": 0 - }, - "compacting": { - "type": "integer", - "minimum": 0 - }, - "archived": { - "type": "number" - } - }, - "required": ["created", "updated"], - "additionalProperties": false - }, - "permission": { - "$ref": "#/components/schemas/PermissionRuleset" - }, - "revert": { - "type": "object", - "properties": { - "messageID": { - "type": "string", - "pattern": "^msg" - }, - "partID": { - "type": "string", - "pattern": "^prt" - }, - "snapshot": { - "type": "string" - }, - "diff": { - "type": "string" - } - }, - "required": ["messageID"], - "additionalProperties": false - } - }, - "required": ["id", "slug", "projectID", "directory", "title", "version", "time"], - "additionalProperties": false - }, "NotFoundError": { "type": "object", "required": ["name", "data"], @@ -17821,851 +17702,6 @@ } } }, - "Session3": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^ses" - }, - "slug": { - "type": "string" - }, - "projectID": { - "type": "string" - }, - "workspaceID": { - "type": "string", - "pattern": "^wrk" - }, - "directory": { - "type": "string" - }, - "path": { - "type": "string" - }, - "parentID": { - "type": "string", - "pattern": "^ses" - }, - "summary": { - "type": "object", - "properties": { - "additions": { - "type": "number" - }, - "deletions": { - "type": "number" - }, - "files": { - "type": "number" - }, - "diffs": { - "type": "array", - "items": { - "$ref": "#/components/schemas/SnapshotFileDiff" - } - } - }, - "required": ["additions", "deletions", "files"], - "additionalProperties": false - }, - "cost": { - "type": "number" - }, - "tokens": { - "type": "object", - "properties": { - "input": { - "type": "number" - }, - "output": { - "type": "number" - }, - "reasoning": { - "type": "number" - }, - "cache": { - "type": "object", - "properties": { - "read": { - "type": "number" - }, - "write": { - "type": "number" - } - }, - "required": ["read", "write"], - "additionalProperties": false - } - }, - "required": ["input", "output", "reasoning", "cache"], - "additionalProperties": false - }, - "share": { - "type": "object", - "properties": { - "url": { - "type": "string" - } - }, - "required": ["url"], - "additionalProperties": false - }, - "title": { - "type": "string" - }, - "agent": { - "type": "string" - }, - "model": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "providerID": { - "type": "string" - }, - "variant": { - "type": "string" - } - }, - "required": ["id", "providerID"], - "additionalProperties": false - }, - "version": { - "type": "string" - }, - "metadata": { - "type": "object" - }, - "time": { - "type": "object", - "properties": { - "created": { - "type": "integer", - "minimum": 0 - }, - "updated": { - "type": "integer", - "minimum": 0 - }, - "compacting": { - "type": "integer", - "minimum": 0 - }, - "archived": { - "type": "number" - } - }, - "required": ["created", "updated"], - "additionalProperties": false - }, - "permission": { - "$ref": "#/components/schemas/PermissionRuleset" - }, - "revert": { - "type": "object", - "properties": { - "messageID": { - "type": "string", - "pattern": "^msg" - }, - "partID": { - "type": "string", - "pattern": "^prt" - }, - "snapshot": { - "type": "string" - }, - "diff": { - "type": "string" - } - }, - "required": ["messageID"], - "additionalProperties": false - } - }, - "required": ["id", "slug", "projectID", "directory", "title", "version", "time"], - "additionalProperties": false - }, - "Session4": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^ses" - }, - "slug": { - "type": "string" - }, - "projectID": { - "type": "string" - }, - "workspaceID": { - "type": "string", - "pattern": "^wrk" - }, - "directory": { - "type": "string" - }, - "path": { - "type": "string" - }, - "parentID": { - "type": "string", - "pattern": "^ses" - }, - "summary": { - "type": "object", - "properties": { - "additions": { - "type": "number" - }, - "deletions": { - "type": "number" - }, - "files": { - "type": "number" - }, - "diffs": { - "type": "array", - "items": { - "$ref": "#/components/schemas/SnapshotFileDiff" - } - } - }, - "required": ["additions", "deletions", "files"], - "additionalProperties": false - }, - "cost": { - "type": "number" - }, - "tokens": { - "type": "object", - "properties": { - "input": { - "type": "number" - }, - "output": { - "type": "number" - }, - "reasoning": { - "type": "number" - }, - "cache": { - "type": "object", - "properties": { - "read": { - "type": "number" - }, - "write": { - "type": "number" - } - }, - "required": ["read", "write"], - "additionalProperties": false - } - }, - "required": ["input", "output", "reasoning", "cache"], - "additionalProperties": false - }, - "share": { - "type": "object", - "properties": { - "url": { - "type": "string" - } - }, - "required": ["url"], - "additionalProperties": false - }, - "title": { - "type": "string" - }, - "agent": { - "type": "string" - }, - "model": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "providerID": { - "type": "string" - }, - "variant": { - "type": "string" - } - }, - "required": ["id", "providerID"], - "additionalProperties": false - }, - "version": { - "type": "string" - }, - "metadata": { - "type": "object" - }, - "time": { - "type": "object", - "properties": { - "created": { - "type": "integer", - "minimum": 0 - }, - "updated": { - "type": "integer", - "minimum": 0 - }, - "compacting": { - "type": "integer", - "minimum": 0 - }, - "archived": { - "type": "number" - } - }, - "required": ["created", "updated"], - "additionalProperties": false - }, - "permission": { - "$ref": "#/components/schemas/PermissionRuleset" - }, - "revert": { - "type": "object", - "properties": { - "messageID": { - "type": "string", - "pattern": "^msg" - }, - "partID": { - "type": "string", - "pattern": "^prt" - }, - "snapshot": { - "type": "string" - }, - "diff": { - "type": "string" - } - }, - "required": ["messageID"], - "additionalProperties": false - } - }, - "required": ["id", "slug", "projectID", "directory", "title", "version", "time"], - "additionalProperties": false - }, - "Session5": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^ses" - }, - "slug": { - "type": "string" - }, - "projectID": { - "type": "string" - }, - "workspaceID": { - "type": "string", - "pattern": "^wrk" - }, - "directory": { - "type": "string" - }, - "path": { - "type": "string" - }, - "parentID": { - "type": "string", - "pattern": "^ses" - }, - "summary": { - "type": "object", - "properties": { - "additions": { - "type": "number" - }, - "deletions": { - "type": "number" - }, - "files": { - "type": "number" - }, - "diffs": { - "type": "array", - "items": { - "$ref": "#/components/schemas/SnapshotFileDiff" - } - } - }, - "required": ["additions", "deletions", "files"], - "additionalProperties": false - }, - "cost": { - "type": "number" - }, - "tokens": { - "type": "object", - "properties": { - "input": { - "type": "number" - }, - "output": { - "type": "number" - }, - "reasoning": { - "type": "number" - }, - "cache": { - "type": "object", - "properties": { - "read": { - "type": "number" - }, - "write": { - "type": "number" - } - }, - "required": ["read", "write"], - "additionalProperties": false - } - }, - "required": ["input", "output", "reasoning", "cache"], - "additionalProperties": false - }, - "share": { - "type": "object", - "properties": { - "url": { - "type": "string" - } - }, - "required": ["url"], - "additionalProperties": false - }, - "title": { - "type": "string" - }, - "agent": { - "type": "string" - }, - "model": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "providerID": { - "type": "string" - }, - "variant": { - "type": "string" - } - }, - "required": ["id", "providerID"], - "additionalProperties": false - }, - "version": { - "type": "string" - }, - "metadata": { - "type": "object" - }, - "time": { - "type": "object", - "properties": { - "created": { - "type": "integer", - "minimum": 0 - }, - "updated": { - "type": "integer", - "minimum": 0 - }, - "compacting": { - "type": "integer", - "minimum": 0 - }, - "archived": { - "type": "number" - } - }, - "required": ["created", "updated"], - "additionalProperties": false - }, - "permission": { - "$ref": "#/components/schemas/PermissionRuleset" - }, - "revert": { - "type": "object", - "properties": { - "messageID": { - "type": "string", - "pattern": "^msg" - }, - "partID": { - "type": "string", - "pattern": "^prt" - }, - "snapshot": { - "type": "string" - }, - "diff": { - "type": "string" - } - }, - "required": ["messageID"], - "additionalProperties": false - } - }, - "required": ["id", "slug", "projectID", "directory", "title", "version", "time"], - "additionalProperties": false - }, - "Session6": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^ses" - }, - "slug": { - "type": "string" - }, - "projectID": { - "type": "string" - }, - "workspaceID": { - "type": "string", - "pattern": "^wrk" - }, - "directory": { - "type": "string" - }, - "path": { - "type": "string" - }, - "parentID": { - "type": "string", - "pattern": "^ses" - }, - "summary": { - "type": "object", - "properties": { - "additions": { - "type": "number" - }, - "deletions": { - "type": "number" - }, - "files": { - "type": "number" - }, - "diffs": { - "type": "array", - "items": { - "$ref": "#/components/schemas/SnapshotFileDiff" - } - } - }, - "required": ["additions", "deletions", "files"], - "additionalProperties": false - }, - "cost": { - "type": "number" - }, - "tokens": { - "type": "object", - "properties": { - "input": { - "type": "number" - }, - "output": { - "type": "number" - }, - "reasoning": { - "type": "number" - }, - "cache": { - "type": "object", - "properties": { - "read": { - "type": "number" - }, - "write": { - "type": "number" - } - }, - "required": ["read", "write"], - "additionalProperties": false - } - }, - "required": ["input", "output", "reasoning", "cache"], - "additionalProperties": false - }, - "share": { - "type": "object", - "properties": { - "url": { - "type": "string" - } - }, - "required": ["url"], - "additionalProperties": false - }, - "title": { - "type": "string" - }, - "agent": { - "type": "string" - }, - "model": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "providerID": { - "type": "string" - }, - "variant": { - "type": "string" - } - }, - "required": ["id", "providerID"], - "additionalProperties": false - }, - "version": { - "type": "string" - }, - "metadata": { - "type": "object" - }, - "time": { - "type": "object", - "properties": { - "created": { - "type": "integer", - "minimum": 0 - }, - "updated": { - "type": "integer", - "minimum": 0 - }, - "compacting": { - "type": "integer", - "minimum": 0 - }, - "archived": { - "type": "number" - } - }, - "required": ["created", "updated"], - "additionalProperties": false - }, - "permission": { - "$ref": "#/components/schemas/PermissionRuleset" - }, - "revert": { - "type": "object", - "properties": { - "messageID": { - "type": "string", - "pattern": "^msg" - }, - "partID": { - "type": "string", - "pattern": "^prt" - }, - "snapshot": { - "type": "string" - }, - "diff": { - "type": "string" - } - }, - "required": ["messageID"], - "additionalProperties": false - } - }, - "required": ["id", "slug", "projectID", "directory", "title", "version", "time"], - "additionalProperties": false - }, - "Session7": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^ses" - }, - "slug": { - "type": "string" - }, - "projectID": { - "type": "string" - }, - "workspaceID": { - "type": "string", - "pattern": "^wrk" - }, - "directory": { - "type": "string" - }, - "path": { - "type": "string" - }, - "parentID": { - "type": "string", - "pattern": "^ses" - }, - "summary": { - "type": "object", - "properties": { - "additions": { - "type": "number" - }, - "deletions": { - "type": "number" - }, - "files": { - "type": "number" - }, - "diffs": { - "type": "array", - "items": { - "$ref": "#/components/schemas/SnapshotFileDiff" - } - } - }, - "required": ["additions", "deletions", "files"], - "additionalProperties": false - }, - "cost": { - "type": "number" - }, - "tokens": { - "type": "object", - "properties": { - "input": { - "type": "number" - }, - "output": { - "type": "number" - }, - "reasoning": { - "type": "number" - }, - "cache": { - "type": "object", - "properties": { - "read": { - "type": "number" - }, - "write": { - "type": "number" - } - }, - "required": ["read", "write"], - "additionalProperties": false - } - }, - "required": ["input", "output", "reasoning", "cache"], - "additionalProperties": false - }, - "share": { - "type": "object", - "properties": { - "url": { - "type": "string" - } - }, - "required": ["url"], - "additionalProperties": false - }, - "title": { - "type": "string" - }, - "agent": { - "type": "string" - }, - "model": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "providerID": { - "type": "string" - }, - "variant": { - "type": "string" - } - }, - "required": ["id", "providerID"], - "additionalProperties": false - }, - "version": { - "type": "string" - }, - "metadata": { - "type": "object" - }, - "time": { - "type": "object", - "properties": { - "created": { - "type": "integer", - "minimum": 0 - }, - "updated": { - "type": "integer", - "minimum": 0 - }, - "compacting": { - "type": "integer", - "minimum": 0 - }, - "archived": { - "type": "number" - } - }, - "required": ["created", "updated"], - "additionalProperties": false - }, - "permission": { - "$ref": "#/components/schemas/PermissionRuleset" - }, - "revert": { - "type": "object", - "properties": { - "messageID": { - "type": "string", - "pattern": "^msg" - }, - "partID": { - "type": "string", - "pattern": "^prt" - }, - "snapshot": { - "type": "string" - }, - "diff": { - "type": "string" - } - }, - "required": ["messageID"], - "additionalProperties": false - } - }, - "required": ["id", "slug", "projectID", "directory", "title", "version", "time"], - "additionalProperties": false - }, "TextPartInput": { "type": "object", "properties": { @@ -18828,344 +17864,6 @@ "required": ["_tag", "sessionID", "message"], "additionalProperties": false }, - "Session8": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^ses" - }, - "slug": { - "type": "string" - }, - "projectID": { - "type": "string" - }, - "workspaceID": { - "type": "string", - "pattern": "^wrk" - }, - "directory": { - "type": "string" - }, - "path": { - "type": "string" - }, - "parentID": { - "type": "string", - "pattern": "^ses" - }, - "summary": { - "type": "object", - "properties": { - "additions": { - "type": "number" - }, - "deletions": { - "type": "number" - }, - "files": { - "type": "number" - }, - "diffs": { - "type": "array", - "items": { - "$ref": "#/components/schemas/SnapshotFileDiff" - } - } - }, - "required": ["additions", "deletions", "files"], - "additionalProperties": false - }, - "cost": { - "type": "number" - }, - "tokens": { - "type": "object", - "properties": { - "input": { - "type": "number" - }, - "output": { - "type": "number" - }, - "reasoning": { - "type": "number" - }, - "cache": { - "type": "object", - "properties": { - "read": { - "type": "number" - }, - "write": { - "type": "number" - } - }, - "required": ["read", "write"], - "additionalProperties": false - } - }, - "required": ["input", "output", "reasoning", "cache"], - "additionalProperties": false - }, - "share": { - "type": "object", - "properties": { - "url": { - "type": "string" - } - }, - "required": ["url"], - "additionalProperties": false - }, - "title": { - "type": "string" - }, - "agent": { - "type": "string" - }, - "model": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "providerID": { - "type": "string" - }, - "variant": { - "type": "string" - } - }, - "required": ["id", "providerID"], - "additionalProperties": false - }, - "version": { - "type": "string" - }, - "metadata": { - "type": "object" - }, - "time": { - "type": "object", - "properties": { - "created": { - "type": "integer", - "minimum": 0 - }, - "updated": { - "type": "integer", - "minimum": 0 - }, - "compacting": { - "type": "integer", - "minimum": 0 - }, - "archived": { - "type": "number" - } - }, - "required": ["created", "updated"], - "additionalProperties": false - }, - "permission": { - "$ref": "#/components/schemas/PermissionRuleset" - }, - "revert": { - "type": "object", - "properties": { - "messageID": { - "type": "string", - "pattern": "^msg" - }, - "partID": { - "type": "string", - "pattern": "^prt" - }, - "snapshot": { - "type": "string" - }, - "diff": { - "type": "string" - } - }, - "required": ["messageID"], - "additionalProperties": false - } - }, - "required": ["id", "slug", "projectID", "directory", "title", "version", "time"], - "additionalProperties": false - }, - "Session9": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^ses" - }, - "slug": { - "type": "string" - }, - "projectID": { - "type": "string" - }, - "workspaceID": { - "type": "string", - "pattern": "^wrk" - }, - "directory": { - "type": "string" - }, - "path": { - "type": "string" - }, - "parentID": { - "type": "string", - "pattern": "^ses" - }, - "summary": { - "type": "object", - "properties": { - "additions": { - "type": "number" - }, - "deletions": { - "type": "number" - }, - "files": { - "type": "number" - }, - "diffs": { - "type": "array", - "items": { - "$ref": "#/components/schemas/SnapshotFileDiff" - } - } - }, - "required": ["additions", "deletions", "files"], - "additionalProperties": false - }, - "cost": { - "type": "number" - }, - "tokens": { - "type": "object", - "properties": { - "input": { - "type": "number" - }, - "output": { - "type": "number" - }, - "reasoning": { - "type": "number" - }, - "cache": { - "type": "object", - "properties": { - "read": { - "type": "number" - }, - "write": { - "type": "number" - } - }, - "required": ["read", "write"], - "additionalProperties": false - } - }, - "required": ["input", "output", "reasoning", "cache"], - "additionalProperties": false - }, - "share": { - "type": "object", - "properties": { - "url": { - "type": "string" - } - }, - "required": ["url"], - "additionalProperties": false - }, - "title": { - "type": "string" - }, - "agent": { - "type": "string" - }, - "model": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "providerID": { - "type": "string" - }, - "variant": { - "type": "string" - } - }, - "required": ["id", "providerID"], - "additionalProperties": false - }, - "version": { - "type": "string" - }, - "metadata": { - "type": "object" - }, - "time": { - "type": "object", - "properties": { - "created": { - "type": "integer", - "minimum": 0 - }, - "updated": { - "type": "integer", - "minimum": 0 - }, - "compacting": { - "type": "integer", - "minimum": 0 - }, - "archived": { - "type": "number" - } - }, - "required": ["created", "updated"], - "additionalProperties": false - }, - "permission": { - "$ref": "#/components/schemas/PermissionRuleset" - }, - "revert": { - "type": "object", - "properties": { - "messageID": { - "type": "string", - "pattern": "^msg" - }, - "partID": { - "type": "string", - "pattern": "^prt" - }, - "snapshot": { - "type": "string" - }, - "diff": { - "type": "string" - } - }, - "required": ["messageID"], - "additionalProperties": false - } - }, - "required": ["id", "slug", "projectID", "directory", "title", "version", "time"], - "additionalProperties": false - }, "V2SessionsResponse": { "type": "object", "properties": { @@ -20181,31 +18879,1645 @@ "required": ["message", "isRetryable"], "additionalProperties": false }, - "PermissionV2Action": { - "type": "string", - "enum": ["allow", "deny", "ask"] - }, - "PermissionV2Rule": { + "AuthOAuthCredential": { "type": "object", "properties": { - "permission": { + "type": { + "type": "string", + "enum": ["oauth"] + }, + "refresh": { "type": "string" }, - "pattern": { + "access": { "type": "string" }, - "action": { - "$ref": "#/components/schemas/PermissionV2Action" + "expires": { + "type": "integer", + "minimum": 0 } }, - "required": ["permission", "pattern", "action"], + "required": ["type", "refresh", "access", "expires"], "additionalProperties": false }, - "PermissionV2Ruleset": { - "type": "array", - "items": { - "$ref": "#/components/schemas/PermissionV2Rule" - } + "AuthApiKeyCredential": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["api"] + }, + "key": { + "type": "string" + }, + "metadata": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "required": ["type", "key"], + "additionalProperties": false + }, + "AuthCredential": { + "anyOf": [ + { + "$ref": "#/components/schemas/AuthOAuthCredential" + }, + { + "$ref": "#/components/schemas/AuthApiKeyCredential" + } + ] + }, + "AuthInfo": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "serviceID": { + "type": "string" + }, + "description": { + "type": "string" + }, + "credential": { + "$ref": "#/components/schemas/AuthCredential" + } + }, + "required": ["id", "serviceID", "description", "credential"], + "additionalProperties": false + }, + "EventServerInstanceDisposed": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["server.instance.disposed"] + }, + "properties": { + "type": "object", + "properties": { + "directory": { + "type": "string" + } + }, + "required": ["directory"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "SyncEventSessionNextAgentSwitched": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.agent.switched.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "agent": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "agent"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionNextModelSwitched": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.model.switched.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "model": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "providerID": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "required": ["id", "providerID"], + "additionalProperties": false + } + }, + "required": ["timestamp", "sessionID", "model"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionNextPrompted": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.prompted.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "prompt": { + "$ref": "#/components/schemas/Prompt" + } + }, + "required": ["timestamp", "sessionID", "prompt"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionNextSynthetic": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.synthetic.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "text": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "text"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionNextShellStarted": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.shell.started.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "callID": { + "type": "string" + }, + "command": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "callID", "command"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionNextShellEnded": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.shell.ended.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "callID": { + "type": "string" + }, + "output": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "callID", "output"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionNextStepStarted": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.step.started.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["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"], + "additionalProperties": false + }, + "snapshot": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "agent", "model"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionNextStepEnded": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.step.ended.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["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": "number" + }, + "output": { + "type": "number" + }, + "reasoning": { + "type": "number" + }, + "cache": { + "type": "object", + "properties": { + "read": { + "type": "number" + }, + "write": { + "type": "number" + } + }, + "required": ["read", "write"], + "additionalProperties": false + } + }, + "required": ["input", "output", "reasoning", "cache"], + "additionalProperties": false + }, + "snapshot": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "finish", "cost", "tokens"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionNextStepFailed": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.step.failed.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "error": { + "$ref": "#/components/schemas/SessionErrorUnknown" + } + }, + "required": ["timestamp", "sessionID", "error"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionNextTextStarted": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.text.started.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + } + }, + "required": ["timestamp", "sessionID"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionNextTextDelta": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.text.delta.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "delta": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "delta"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionNextTextEnded": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.text.ended.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "text": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "text"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionNextReasoningStarted": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.reasoning.started.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "reasoningID": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "reasoningID"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionNextReasoningDelta": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.reasoning.delta.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "reasoningID": { + "type": "string" + }, + "delta": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "reasoningID", "delta"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionNextReasoningEnded": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.reasoning.ended.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "reasoningID": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "reasoningID", "text"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionNextToolInputStarted": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.tool.input.started.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "callID": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "callID", "name"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionNextToolInputDelta": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.tool.input.delta.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "callID": { + "type": "string" + }, + "delta": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "callID", "delta"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionNextToolInputEnded": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.tool.input.ended.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "callID": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "callID", "text"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionNextToolCalled": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.tool.called.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "callID": { + "type": "string" + }, + "tool": { + "type": "string" + }, + "input": { + "type": "object" + }, + "provider": { + "type": "object", + "properties": { + "executed": { + "type": "boolean" + }, + "metadata": { + "type": "object" + } + }, + "required": ["executed"], + "additionalProperties": false + } + }, + "required": ["timestamp", "sessionID", "callID", "tool", "input", "provider"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionNextToolProgress": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.tool.progress.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "callID": { + "type": "string" + }, + "structured": { + "type": "object" + }, + "content": { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/components/schemas/ToolTextContent" + }, + { + "$ref": "#/components/schemas/ToolFileContent" + } + ] + } + } + }, + "required": ["timestamp", "sessionID", "callID", "structured", "content"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionNextToolSuccess": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.tool.success.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "callID": { + "type": "string" + }, + "structured": { + "type": "object" + }, + "content": { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/components/schemas/ToolTextContent" + }, + { + "$ref": "#/components/schemas/ToolFileContent" + } + ] + } + }, + "provider": { + "type": "object", + "properties": { + "executed": { + "type": "boolean" + }, + "metadata": { + "type": "object" + } + }, + "required": ["executed"], + "additionalProperties": false + } + }, + "required": ["timestamp", "sessionID", "callID", "structured", "content", "provider"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionNextToolFailed": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.tool.failed.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "callID": { + "type": "string" + }, + "error": { + "$ref": "#/components/schemas/SessionErrorUnknown" + }, + "provider": { + "type": "object", + "properties": { + "executed": { + "type": "boolean" + }, + "metadata": { + "type": "object" + } + }, + "required": ["executed"], + "additionalProperties": false + } + }, + "required": ["timestamp", "sessionID", "callID", "error", "provider"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionNextRetried": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.retried.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "attempt": { + "type": "number" + }, + "error": { + "$ref": "#/components/schemas/SessionNextRetry_error" + } + }, + "required": ["timestamp", "sessionID", "attempt", "error"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionNextCompactionStarted": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.compaction.started.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "reason": { + "type": "string", + "enum": ["auto", "manual"] + } + }, + "required": ["timestamp", "sessionID", "reason"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionNextCompactionDelta": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.compaction.delta.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "text": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "text"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionNextCompactionEnded": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.compaction.ended.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "text": { + "type": "string" + }, + "include": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "text"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionCreated": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.created.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "info": { + "$ref": "#/components/schemas/Session" + } + }, + "required": ["sessionID", "info"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionUpdated": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.updated.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "info": { + "$ref": "#/components/schemas/Session" + } + }, + "required": ["sessionID", "info"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionDeleted": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.deleted.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "info": { + "$ref": "#/components/schemas/Session" + } + }, + "required": ["sessionID", "info"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventMessageUpdated": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["message.updated.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "info": { + "$ref": "#/components/schemas/Message" + } + }, + "required": ["sessionID", "info"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventMessageRemoved": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["message.removed.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "messageID": { + "type": "string", + "pattern": "^msg" + } + }, + "required": ["sessionID", "messageID"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventMessagePartUpdated": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["message.part.updated.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "part": { + "$ref": "#/components/schemas/Part" + }, + "time": { + "type": "number" + } + }, + "required": ["sessionID", "part", "time"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventMessagePartRemoved": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["message.part.removed.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "messageID": { + "type": "string", + "pattern": "^msg" + }, + "partID": { + "type": "string", + "pattern": "^prt" + } + }, + "required": ["sessionID", "messageID", "partID"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false }, "PolicyEffect": { "type": "string", @@ -23679,76 +23991,6 @@ "required": ["id", "type", "properties"], "additionalProperties": false }, - "AuthOAuthCredential": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["oauth"] - }, - "refresh": { - "type": "string" - }, - "access": { - "type": "string" - }, - "expires": { - "type": "integer", - "minimum": 0 - } - }, - "required": ["type", "refresh", "access", "expires"], - "additionalProperties": false - }, - "AuthApiKeyCredential": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["api"] - }, - "key": { - "type": "string" - }, - "metadata": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - }, - "required": ["type", "key"], - "additionalProperties": false - }, - "AuthCredential": { - "anyOf": [ - { - "$ref": "#/components/schemas/AuthOAuthCredential" - }, - { - "$ref": "#/components/schemas/AuthApiKeyCredential" - } - ] - }, - "AuthInfo": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "serviceID": { - "type": "string" - }, - "description": { - "type": "string" - }, - "credential": { - "$ref": "#/components/schemas/AuthCredential" - } - }, - "required": ["id", "serviceID", "description", "credential"], - "additionalProperties": false - }, "EventAccountAdded": { "type": "object", "properties": { From a893ca857a30f152ba61d379dad1dd6b2c9927dc Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sun, 31 May 2026 03:25:47 -0400 Subject: [PATCH 029/840] sync --- packages/core/src/database/migration.gen.ts | 48 ++++++++++----------- 1 file changed, 23 insertions(+), 25 deletions(-) diff --git a/packages/core/src/database/migration.gen.ts b/packages/core/src/database/migration.gen.ts index 1a6918b33b..4447c60088 100644 --- a/packages/core/src/database/migration.gen.ts +++ b/packages/core/src/database/migration.gen.ts @@ -1,27 +1,25 @@ import type { DatabaseMigration } from "./migration" -export const migrations = ( - await Promise.all([ - import("./migration/20260127222353_familiar_lady_ursula"), - import("./migration/20260211171708_add_project_commands"), - import("./migration/20260213144116_wakeful_the_professor"), - import("./migration/20260225215848_workspace"), - import("./migration/20260227213759_add_session_workspace_id"), - import("./migration/20260228203230_blue_harpoon"), - import("./migration/20260303231226_add_workspace_fields"), - import("./migration/20260309230000_move_org_to_state"), - import("./migration/20260312043431_session_message_cursor"), - import("./migration/20260323234822_events"), - import("./migration/20260410174513_workspace-name"), - import("./migration/20260413175956_chief_energizer"), - import("./migration/20260423070820_add_icon_url_override"), - import("./migration/20260427172553_slow_nightmare"), - import("./migration/20260428004200_add_session_path"), - import("./migration/20260501142318_next_venus"), - import("./migration/20260504145000_add_sync_owner"), - import("./migration/20260507164347_add_workspace_time"), - import("./migration/20260510033149_session_usage"), - import("./migration/20260511000411_data_migration_state"), - import("./migration/20260530232709_lovely_romulus"), - ]) -).map((module) => module.default) satisfies DatabaseMigration.Migration[] +export const migrations = (await Promise.all([ + import("./migration/20260127222353_familiar_lady_ursula"), + import("./migration/20260211171708_add_project_commands"), + import("./migration/20260213144116_wakeful_the_professor"), + import("./migration/20260225215848_workspace"), + import("./migration/20260227213759_add_session_workspace_id"), + import("./migration/20260228203230_blue_harpoon"), + import("./migration/20260303231226_add_workspace_fields"), + import("./migration/20260309230000_move_org_to_state"), + import("./migration/20260312043431_session_message_cursor"), + import("./migration/20260323234822_events"), + import("./migration/20260410174513_workspace-name"), + import("./migration/20260413175956_chief_energizer"), + import("./migration/20260423070820_add_icon_url_override"), + import("./migration/20260427172553_slow_nightmare"), + import("./migration/20260428004200_add_session_path"), + import("./migration/20260501142318_next_venus"), + import("./migration/20260504145000_add_sync_owner"), + import("./migration/20260507164347_add_workspace_time"), + import("./migration/20260510033149_session_usage"), + import("./migration/20260511000411_data_migration_state"), + import("./migration/20260530232709_lovely_romulus"), +])).map((module) => module.default) satisfies DatabaseMigration.Migration[] From a29196720607aa5d09cc88e18cb701c545b6abe6 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sun, 31 May 2026 07:27:16 +0000 Subject: [PATCH 030/840] chore: generate --- packages/core/src/database/migration.gen.ts | 48 +++++++++++---------- 1 file changed, 25 insertions(+), 23 deletions(-) diff --git a/packages/core/src/database/migration.gen.ts b/packages/core/src/database/migration.gen.ts index 4447c60088..1a6918b33b 100644 --- a/packages/core/src/database/migration.gen.ts +++ b/packages/core/src/database/migration.gen.ts @@ -1,25 +1,27 @@ import type { DatabaseMigration } from "./migration" -export const migrations = (await Promise.all([ - import("./migration/20260127222353_familiar_lady_ursula"), - import("./migration/20260211171708_add_project_commands"), - import("./migration/20260213144116_wakeful_the_professor"), - import("./migration/20260225215848_workspace"), - import("./migration/20260227213759_add_session_workspace_id"), - import("./migration/20260228203230_blue_harpoon"), - import("./migration/20260303231226_add_workspace_fields"), - import("./migration/20260309230000_move_org_to_state"), - import("./migration/20260312043431_session_message_cursor"), - import("./migration/20260323234822_events"), - import("./migration/20260410174513_workspace-name"), - import("./migration/20260413175956_chief_energizer"), - import("./migration/20260423070820_add_icon_url_override"), - import("./migration/20260427172553_slow_nightmare"), - import("./migration/20260428004200_add_session_path"), - import("./migration/20260501142318_next_venus"), - import("./migration/20260504145000_add_sync_owner"), - import("./migration/20260507164347_add_workspace_time"), - import("./migration/20260510033149_session_usage"), - import("./migration/20260511000411_data_migration_state"), - import("./migration/20260530232709_lovely_romulus"), -])).map((module) => module.default) satisfies DatabaseMigration.Migration[] +export const migrations = ( + await Promise.all([ + import("./migration/20260127222353_familiar_lady_ursula"), + import("./migration/20260211171708_add_project_commands"), + import("./migration/20260213144116_wakeful_the_professor"), + import("./migration/20260225215848_workspace"), + import("./migration/20260227213759_add_session_workspace_id"), + import("./migration/20260228203230_blue_harpoon"), + import("./migration/20260303231226_add_workspace_fields"), + import("./migration/20260309230000_move_org_to_state"), + import("./migration/20260312043431_session_message_cursor"), + import("./migration/20260323234822_events"), + import("./migration/20260410174513_workspace-name"), + import("./migration/20260413175956_chief_energizer"), + import("./migration/20260423070820_add_icon_url_override"), + import("./migration/20260427172553_slow_nightmare"), + import("./migration/20260428004200_add_session_path"), + import("./migration/20260501142318_next_venus"), + import("./migration/20260504145000_add_sync_owner"), + import("./migration/20260507164347_add_workspace_time"), + import("./migration/20260510033149_session_usage"), + import("./migration/20260511000411_data_migration_state"), + import("./migration/20260530232709_lovely_romulus"), + ]) +).map((module) => module.default) satisfies DatabaseMigration.Migration[] From e8dd8f7fe0af0b4fa0a14add29f9350d50b351c2 Mon Sep 17 00:00:00 2001 From: Simon Klee Date: Sun, 31 May 2026 10:58:12 +0200 Subject: [PATCH 031/840] tui(run): use keymap instead of raw key events (#30077) Co-authored-by: Sebastian Herrlinger --- bun.lock | 30 +- package.json | 6 +- .../src/cli/cmd/run/footer.command.tsx | 7 +- .../src/cli/cmd/run/footer.permission.tsx | 9 +- .../src/cli/cmd/run/footer.prompt.tsx | 428 +++++++++--------- .../src/cli/cmd/run/footer.question.tsx | 5 +- packages/opencode/src/cli/cmd/run/footer.ts | 95 ++-- .../opencode/src/cli/cmd/run/footer.view.tsx | 86 ++-- .../opencode/src/cli/cmd/run/keymap.shared.ts | 154 ------- .../opencode/src/cli/cmd/run/prompt.shared.ts | 187 +------- .../opencode/src/cli/cmd/run/runtime.boot.ts | 66 ++- .../src/cli/cmd/run/runtime.lifecycle.ts | 18 +- packages/opencode/src/cli/cmd/run/runtime.ts | 15 +- packages/opencode/src/cli/cmd/run/types.ts | 18 +- packages/opencode/src/cli/cmd/tui/keymap.tsx | 35 +- .../test/cli/run/footer.view.test.tsx | 373 ++++++++++++--- .../test/cli/run/prompt.shared.test.ts | 55 --- .../test/cli/run/runtime.boot.test.ts | 58 ++- packages/plugin/package.json | 6 +- 19 files changed, 760 insertions(+), 891 deletions(-) delete mode 100644 packages/opencode/src/cli/cmd/run/keymap.shared.ts diff --git a/bun.lock b/bun.lock index 9b33108f27..74b6df0fc8 100644 --- a/bun.lock +++ b/bun.lock @@ -611,9 +611,9 @@ "typescript": "catalog:", }, "peerDependencies": { - "@opentui/core": ">=0.2.16", - "@opentui/keymap": ">=0.2.16", - "@opentui/solid": ">=0.2.16", + "@opentui/core": ">=0.3.0", + "@opentui/keymap": ">=0.3.0", + "@opentui/solid": ">=0.3.0", }, "optionalPeers": [ "@opentui/core", @@ -862,9 +862,9 @@ "@npmcli/arborist": "9.4.0", "@octokit/rest": "22.0.0", "@openauthjs/openauth": "0.0.0-20250322224806", - "@opentui/core": "0.2.16", - "@opentui/keymap": "0.2.16", - "@opentui/solid": "0.2.16", + "@opentui/core": "0.3.0", + "@opentui/keymap": "0.3.0", + "@opentui/solid": "0.3.0", "@pierre/diffs": "1.1.0-beta.18", "@playwright/test": "1.59.1", "@sentry/solid": "10.36.0", @@ -1758,23 +1758,23 @@ "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="], - "@opentui/core": ["@opentui/core@0.2.16", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.2.16", "@opentui/core-darwin-x64": "0.2.16", "@opentui/core-linux-arm64": "0.2.16", "@opentui/core-linux-x64": "0.2.16", "@opentui/core-win32-arm64": "0.2.16", "@opentui/core-win32-x64": "0.2.16" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-4vWN15Zc3nsXJlOiHhhpqkBXD+wrNFKxCPtiTiillZYDRre+XsZogVTOOGUDwaBIC23OSxq7imezLmmtShVBEA=="], + "@opentui/core": ["@opentui/core@0.3.0", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.3.0", "@opentui/core-darwin-x64": "0.3.0", "@opentui/core-linux-arm64": "0.3.0", "@opentui/core-linux-x64": "0.3.0", "@opentui/core-win32-arm64": "0.3.0", "@opentui/core-win32-x64": "0.3.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-wvNESYGYGRLuvarZ3QY4CTB+BziZ/j6Snd9qRKD4fQ7SF6G4UpYElLTFrg7uzRo1v7WJTqbquymcTvWEHMnpYA=="], - "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.16", "", { "os": "darwin", "cpu": "arm64" }, "sha512-aFb2Yp+oqDu3h6VCWi7xpQ9yjpKSQcROzGGfHgqC6Nd3U+uiLfPJBkmiI87iK0opCggCFj5TkKI004050DmGjg=="], + "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.3.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-/eDfAcutAHJqR9spwHMLuo6LMqngymev/m+i6uqlk98gX1EJiJe2pJ16sKbp3RctgH/Gz/8TYOhVHpPGYJl7yQ=="], - "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.16", "", { "os": "darwin", "cpu": "x64" }, "sha512-KimiHE0j7EsTB5P8doW0lr1eH5iZKLPKWQO+tmy1VcdYr/TzqhdHSvGuJXrZvfTFi9/rV57Eq0d7964Ri9O0vQ=="], + "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.3.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-/j6EWAvdwhz1wU/mWfXepAf3+NuMYz2Ic5ozaid5LdwIpPomIkM9yCUDm76mQhRBbjsAl/7UeSeUA0qSCMSZBg=="], - "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.16", "", { "os": "linux", "cpu": "arm64" }, "sha512-4fCwRCfTtUgS/5QcSEkSuBjgQymSOUWXgrXG2ycrf3Swi0QhKDA/pVjwLrUJ6eF+/8mQyQSEV72T8MxMO3M2qg=="], + "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.3.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-uUFVT3V35KkM1m8gaLmRcTV9dsJzXnxwM+dv6+NjScx0W/Y0CJKbW9wDYwnLyPnBNgaFUi171zmJra5gTtFTsw=="], - "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.16", "", { "os": "linux", "cpu": "x64" }, "sha512-KgQBGjiucw4e7gM+R8qOzHWBFhjCY1IfCrGjW3Wzxv2hKUlL+mPhelaeJwnEqtNxMUdVTYjlwlu3IHxslXMJWQ=="], + "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.3.0", "", { "os": "linux", "cpu": "x64" }, "sha512-73bNNNU2OaqZQLIlvzDOdAzQmzBAqf+cSilmJ+Y9JnybrBn1d6VShC66+V4xxIgonq1swk7BD+SUHYbwwGilQA=="], - "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.16", "", { "os": "win32", "cpu": "arm64" }, "sha512-C6WqEI3VkXatXraMgSFXZjEXq0pzURGjRpFAJZYmuVDmpqE57o7E80Np2UkdZ6m5kpJDt4mRyu3krc/P825iNQ=="], + "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.3.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-jg5KrV/4mVQ0mdkcL9CtQVtBk0NAtQ+2rCKoZ/jNHB6GxGK0ot9vDV6P3X68hZVkvpb2pdXfg6GRsZJ+Np4hZA=="], - "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.16", "", { "os": "win32", "cpu": "x64" }, "sha512-kCX3CMTns6DMCFDNTDV4sjmBKyA/iEvzaVhl/jYi4JRIVT2zcy1lo+lhXT5mPgYHmJZu8Uye6j3Zi3c7Z2Me5A=="], + "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.3.0", "", { "os": "win32", "cpu": "x64" }, "sha512-kiM3C5bwQBTfrJKAOfb+L3U6MMkPSQlMhAERlLMjqSurc+llcyqygr/wbXSvfAqJtKlIpf3MKJRnVFTyfRIdng=="], - "@opentui/keymap": ["@opentui/keymap@0.2.16", "", { "dependencies": { "@opentui/core": "0.2.16" }, "peerDependencies": { "@opentui/react": "0.2.16", "@opentui/solid": "0.2.16", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-YBLQfNLbU2kx49bjEY9rrFoNlvIoi5qNJfRcOt6frvnR3C6MLl0/8hZY+vMQ2PEQWeEiNejFnl1lQw+z4Nk2FQ=="], + "@opentui/keymap": ["@opentui/keymap@0.3.0", "", { "dependencies": { "@opentui/core": "0.3.0" }, "peerDependencies": { "@opentui/react": "0.3.0", "@opentui/solid": "0.3.0", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-lJN57DanKujy3u0IhfSMCShvXIobRjhprdkrdM3brQoX6wxk7gTFE8fTCCz9z1nINkXNsKHQ6grZO1dsT/0mzA=="], - "@opentui/solid": ["@opentui/solid@0.2.16", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.16", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-2Q+v1PPpXXr+sALi9Aj6I5Jvo7xDfbmstYjRLL7lW3Hghh9i7ONQKpt/gyDDRbhSsYrhxKYTNenF9OxgoXkTHg=="], + "@opentui/solid": ["@opentui/solid@0.3.0", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.3.0", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-AUtNzvgkdW81Ftl0sahAy3tY1LIPSMzBw3APBC8jiDAzzPv4kYVdyWXryTxLbU2q+Pgtr57VwKwHgc5wsNrd2w=="], "@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="], diff --git a/package.json b/package.json index a403ff1e31..0b5119d94c 100644 --- a/package.json +++ b/package.json @@ -38,9 +38,9 @@ "@types/cross-spawn": "6.0.6", "@octokit/rest": "22.0.0", "@hono/zod-validator": "0.4.2", - "@opentui/core": "0.2.16", - "@opentui/keymap": "0.2.16", - "@opentui/solid": "0.2.16", + "@opentui/core": "0.3.0", + "@opentui/keymap": "0.3.0", + "@opentui/solid": "0.3.0", "ulid": "3.0.1", "@kobalte/core": "0.13.11", "@types/luxon": "3.7.1", diff --git a/packages/opencode/src/cli/cmd/run/footer.command.tsx b/packages/opencode/src/cli/cmd/run/footer.command.tsx index cf6822c066..bc2434f3e4 100644 --- a/packages/opencode/src/cli/cmd/run/footer.command.tsx +++ b/packages/opencode/src/cli/cmd/run/footer.command.tsx @@ -4,9 +4,8 @@ import { useKeyboard, type JSX } from "@opentui/solid" import fuzzysort from "fuzzysort" import { createEffect, createMemo, createSignal, type Accessor } from "solid-js" import { RunFooterMenu, createFooterMenuState, type RunFooterMenuItem } from "./footer.menu" -import { formatBindings } from "./keymap.shared" import type { RunFooterTheme } from "./theme" -import type { FooterKeybinds, FooterSubagentTab, RunCommand, RunInput, RunProvider } from "./types" +import type { FooterSubagentTab, RunCommand, RunInput, RunProvider } from "./types" type PanelEntry = RunFooterMenuItem & { category: string @@ -296,7 +295,7 @@ export function RunCommandMenuBody(props: { commands: Accessor subagents: Accessor variants: Accessor - keybinds: FooterKeybinds + variantCycle: string onClose: () => void onModel: () => void onSubagent: () => void @@ -334,7 +333,7 @@ export function RunCommandMenuBody(props: { action: "variant.cycle", category: "Suggested", display: "Variant cycle", - footer: formatBindings(props.keybinds.variantCycle, props.keybinds.leader), + footer: props.variantCycle, keywords: "variant cycle", }, ...(props.variants().length > 0 diff --git a/packages/opencode/src/cli/cmd/run/footer.permission.tsx b/packages/opencode/src/cli/cmd/run/footer.permission.tsx index b38c2da9d1..2790a9e0b6 100644 --- a/packages/opencode/src/cli/cmd/run/footer.permission.tsx +++ b/packages/opencode/src/cli/cmd/run/footer.permission.tsx @@ -64,7 +64,8 @@ function buttons( ) } -function RejectField(props: { +/** @internal Exported to test managed textarea submission without permission navigation. */ +export function RejectField(props: { theme: RunFooterTheme text: string disabled: boolean @@ -107,6 +108,7 @@ function RejectField(props: { focusedBackgroundColor={props.theme.surface} cursorColor={props.theme.text} focused={!props.disabled} + onSubmit={props.onConfirm} onContentChange={() => { if (!area || area.isDestroyed) { return @@ -119,11 +121,6 @@ function RejectField(props: { props.onCancel() return } - - if (event.name === "return" && !event.meta && !event.ctrl && !event.shift) { - event.preventDefault() - props.onConfirm() - } }} ref={(item) => { area = item diff --git a/packages/opencode/src/cli/cmd/run/footer.prompt.tsx b/packages/opencode/src/cli/cmd/run/footer.prompt.tsx index c3f9918acc..fd4e9072f0 100644 --- a/packages/opencode/src/cli/cmd/run/footer.prompt.tsx +++ b/packages/opencode/src/cli/cmd/run/footer.prompt.tsx @@ -1,13 +1,13 @@ // Prompt textarea component and its state machine for direct interactive mode. // -// createPromptState() wires keybinds, history navigation, leader-key sequences, -// and `@` autocomplete for files, subagents, and MCP resources. +// createPromptState() wires keymap command layers, history navigation, and +// `@` autocomplete for files, subagents, and MCP resources. // It produces a PromptState that RunPromptBody renders as an OpenTUI textarea, // while the footer view renders the current menu state below it. /** @jsxImportSource @opentui/solid */ import { pathToFileURL } from "bun" -import { StyledText, bg, fg, type KeyBinding, type KeyEvent, type TextareaRenderable } from "@opentui/core" -import { useKeyboard, useRenderer } from "@opentui/solid" +import { StyledText, bg, fg, type KeyEvent, type TextareaRenderable } from "@opentui/core" +import { useRenderer } from "@opentui/solid" import fuzzysort from "fuzzysort" import path from "path" import { createEffect, createMemo, createResource, createSignal, onCleanup, onMount, type Accessor } from "solid-js" @@ -20,15 +20,12 @@ import { mentionTriggerIndex, isNewCommand, movePromptHistory, - promptCycle, - promptHit, - promptInfo, - promptKeys, pushPromptHistory, } from "./prompt.shared" +import { OPENCODE_BASE_MODE, useBindings } from "@/cli/cmd/tui/keymap" import { FOOTER_MENU_ROWS, createFooterMenuState, type RunFooterMenuItem } from "./footer.menu" import type { RunFooterTheme } from "./theme" -import type { FooterKeybinds, FooterState, RunAgent, RunCommand, RunPrompt, RunPromptPart, RunResource } from "./types" +import type { FooterState, RunAgent, RunCommand, RunPrompt, RunPromptPart, RunResource, RunTuiConfig } from "./types" const AUTOCOMPLETE_ROWS = FOOTER_MENU_ROWS const AUTOCOMPLETE_BOTTOM_ROWS = 1 @@ -69,7 +66,7 @@ type PromptInput = { subagents: Accessor resources: Accessor commands: Accessor - keybinds: FooterKeybinds + tuiConfig: RunTuiConfig state: Accessor view: Accessor prompt: Accessor @@ -89,7 +86,6 @@ type PromptInput = { export type PromptState = { placeholder: Accessor - bindings: Accessor shell: Accessor visible: Accessor options: Accessor @@ -199,7 +195,6 @@ export function hintFlags(width: number) { export function RunPromptBody(props: { theme: () => RunFooterTheme placeholder: () => StyledText | string - bindings: () => KeyBinding[] onSubmit: () => void onKeyDown: (event: KeyEvent) => void onContentChange: () => void @@ -263,7 +258,6 @@ export function RunPromptBody(props: { backgroundColor={props.theme().surface} focusedBackgroundColor={props.theme().surface} cursorColor={props.theme().text} - keyBindings={props.bindings()} onSubmit={props.onSubmit} onKeyDown={props.onKeyDown} onPaste={() => { @@ -280,8 +274,6 @@ export function RunPromptBody(props: { } export function createPromptState(input: PromptInput): PromptState { - const keys = createMemo(() => promptKeys(input.keybinds)) - const bindings = createMemo(() => keys().bindings) const [shell, setShell] = createSignal(false) const placeholder = createMemo(() => { if (shell()) { @@ -301,8 +293,6 @@ export function createPromptState(input: PromptInput): PromptState { let draft: RunPrompt = { text: "", parts: [] } let stash: RunPrompt = { text: "", parts: [] } let area: TextareaRenderable | undefined - let leader = false - let timeout: NodeJS.Timeout | undefined let tick = false let prev = input.view() let type = 0 @@ -461,24 +451,6 @@ export function createPromptState(input: PromptInput): PromptState { return visible() ? menu.rows() - 1 + AUTOCOMPLETE_BOTTOM_ROWS : 0 }) - const clear = () => { - leader = false - if (!timeout) { - return - } - - clearTimeout(timeout) - timeout = undefined - } - - const arm = () => { - clear() - leader = true - timeout = setTimeout(() => { - clear() - }, input.keybinds.leaderTimeout) - } - const hide = () => { setMode(false) setQuery("") @@ -742,7 +714,7 @@ export function createPromptState(input: PromptInput): PromptState { const move = (dir: -1 | 1, event: KeyEvent) => { if (!area || area.isDestroyed) { - return + return false } if (history.index === null && dir === -1) { @@ -751,7 +723,7 @@ export function createPromptState(input: PromptInput): PromptState { const next = movePromptHistory(history, dir, area.plainText, area.cursorOffset) if (!next.apply || next.text === undefined || next.cursor === undefined) { - return + return false } history = next.state @@ -759,28 +731,27 @@ export function createPromptState(input: PromptInput): PromptState { next.state.index === null ? stash : (next.state.items[next.state.index] ?? { text: next.text, parts: [] }) restore(value, next.cursor) event.preventDefault() + return true } - const cycle = (event: KeyEvent): boolean => { - const next = promptCycle(leader, promptInfo(event), keys().leaders, keys().cycles) - if (!next.consume) { - return false + const historyCommand = (dir: -1 | 1, event: KeyEvent) => { + if (move(dir, event)) return + if (!area || area.isDestroyed) return false + + const endOffset = Bun.stringWidth(area.plainText) + if (dir === -1 && area.visualCursor.visualRow === 0) { + area.cursorOffset = 0 } - if (next.clear) { - clear() + const end = + typeof area.height === "number" && Number.isFinite(area.height) && area.height > 0 + ? area.height - 1 + : Math.max(0, (area.virtualLineCount ?? 1) - 1) + if (dir === 1 && area.visualCursor.visualRow === end) { + area.cursorOffset = endOffset } - if (next.arm) { - arm() - } - - if (next.cycle) { - input.onCycle() - } - - event.preventDefault() - return true + return false } const requestExit = () => { @@ -912,178 +883,190 @@ export function createPromptState(input: PromptInput): PromptState { refresh() } - const onKeyDown = (event: KeyEvent) => { - const key = promptInfo(event) - if (visible()) { - const name = event.name.toLowerCase() - const ctrl = event.ctrl && !event.meta && !event.shift - if (name === "up" || (ctrl && name === "p")) { - event.preventDefault() - if (options().length > 0) { - menu.move(-1) - } - return - } - - if (name === "down" || (ctrl && name === "n")) { - event.preventDefault() - if (options().length > 0) { - menu.move(1) - } - return - } - - if (name === "escape") { - event.preventDefault() - cancelAutocomplete() - return - } - - if (name === "return") { - if (mode() === "slash" && options().length === 0) { - hide() - return - } - - event.preventDefault() - select() - return - } - - if (name === "tab") { - if (mode() === "slash" && options().length === 0) { - hide() - return - } - - event.preventDefault() - const item = options()[menu.selected()] - if (item?.kind === "mention" && item.directory) { - expand() - return - } - - select() - return - } - } - - if ( - key.name === "!" && - !shell() && - !event.ctrl && - !event.meta && - !event.super && - area && - !area.isDestroyed && - area.cursorOffset === 0 - ) { - event.preventDefault() - setShellMode(true) - return - } - - if (shell() && !visible()) { - if (key.name === "escape") { - event.preventDefault() - setShellMode(false) - return - } - - if (key.name === "backspace" && area && !area.isDestroyed && area.cursorOffset === 0) { - event.preventDefault() - setShellMode(false) - return - } - } - - if ( - key.name === "down" && - !visible() && - !event.ctrl && - !event.meta && - !event.shift && - !event.super && - area && - !area.isDestroyed && - area.plainText.length === 0 && - input.subagents() > 0 - ) { - event.preventDefault() - input.onSubagentMenu?.() - return - } - - if (promptHit(keys().clear, key)) { - const handled = requestExit() - if (handled) { - event.preventDefault() - } - return - } - - if (promptHit(keys().interrupts, key)) { - if (input.onInterrupt()) { - event.preventDefault() - return - } - } - - if (cycle(event)) { - return - } - - const up = promptHit(keys().previous, key) - const down = promptHit(keys().next, key) - if (!up && !down) { - return - } - - if (!area || area.isDestroyed) { - return - } - - const dir = up ? -1 : 1 - const endOffset = Bun.stringWidth(area.plainText) - if ((dir === -1 && area.cursorOffset === 0) || (dir === 1 && area.cursorOffset === endOffset)) { - move(dir, event) - return - } - - if (dir === -1 && area.visualCursor.visualRow === 0) { - area.cursorOffset = 0 - } - - const end = - typeof area.height === "number" && Number.isFinite(area.height) && area.height > 0 - ? area.height - 1 - : Math.max(0, (area.virtualLineCount ?? 1) - 1) - if (dir === 1 && area.visualCursor.visualRow === end) { - area.cursorOffset = endOffset - } + const baseBindingsEnabled = () => { + const current = input.view() + if (current === "command") return false + if (current === "model") return false + if (current === "variant") return false + if (current === "subagent-menu") return false + return true } - useKeyboard((event) => { - if (input.prompt()) { - return - } + useBindings(() => ({ + mode: OPENCODE_BASE_MODE, + enabled: baseBindingsEnabled(), + commands: [ + { + name: "prompt.clear", + title: "Clear prompt or exit", + category: "Prompt", + run() { + if (requestExit()) return + return false + }, + }, + ], + bindings: input.tuiConfig.keybinds.get("prompt.clear"), + })) - if ( - input.view() === "command" || - input.view() === "model" || - input.view() === "variant" || - input.view() === "subagent-menu" - ) { - return - } + useBindings(() => ({ + mode: OPENCODE_BASE_MODE, + enabled: input.prompt(), + commands: [ + { + name: "session.interrupt", + title: "Interrupt session", + category: "Session", + run() { + if (input.onInterrupt()) return + return false + }, + }, + ], + bindings: input.tuiConfig.keybinds.get("session.interrupt"), + })) - if (promptHit(keys().clear, promptInfo(event))) { - const handled = requestExit() - if (handled) { - event.preventDefault() - } - } - }) + useBindings(() => ({ + mode: OPENCODE_BASE_MODE, + enabled: input.prompt() && !visible(), + commands: [ + { + name: "prompt.history.previous", + title: "Previous prompt history", + category: "Prompt", + run(ctx: { event: KeyEvent }) { + return historyCommand(-1, ctx.event) + }, + }, + { + name: "prompt.history.next", + title: "Next prompt history", + category: "Prompt", + run(ctx: { event: KeyEvent }) { + return historyCommand(1, ctx.event) + }, + }, + ], + bindings: [ + ...input.tuiConfig.keybinds.get("prompt.history.previous"), + ...input.tuiConfig.keybinds.get("prompt.history.next"), + ], + })) + + useBindings(() => ({ + mode: OPENCODE_BASE_MODE, + enabled: input.prompt() && !visible(), + bindings: [ + { + key: "down", + desc: "View subagents", + group: "Prompt", + cmd() { + if (!area || area.isDestroyed) return false + if (area.plainText.length !== 0) return false + if (input.subagents() === 0) return false + input.onSubagentMenu?.() + }, + }, + { + key: "!", + desc: "Shell mode", + group: "Prompt", + cmd() { + if (shell()) return false + if (!area || area.isDestroyed) return false + if (area.cursorOffset !== 0) return false + setShellMode(true) + }, + }, + ], + })) + + useBindings(() => ({ + mode: OPENCODE_BASE_MODE, + enabled: input.prompt() && shell() && !visible(), + bindings: [ + { + key: "escape", + desc: "Exit shell mode", + group: "Prompt", + cmd: () => setShellMode(false), + }, + { + key: "backspace", + desc: "Exit shell mode", + group: "Prompt", + cmd() { + if (!area || area.isDestroyed) return false + if (area.cursorOffset !== 0) return false + setShellMode(false) + }, + }, + ], + })) + + useBindings(() => ({ + mode: OPENCODE_BASE_MODE, + enabled: input.prompt() && visible(), + commands: [ + { + name: "prompt.autocomplete.prev", + title: "Previous autocomplete item", + category: "Autocomplete", + run: () => menu.move(-1), + }, + { + name: "prompt.autocomplete.next", + title: "Next autocomplete item", + category: "Autocomplete", + run: () => menu.move(1), + }, + { + name: "prompt.autocomplete.hide", + title: "Hide autocomplete", + category: "Autocomplete", + run: cancelAutocomplete, + }, + { + name: "prompt.autocomplete.select", + title: "Select autocomplete item", + category: "Autocomplete", + run() { + if (mode() === "slash" && options().length === 0) { + hide() + return + } + select() + }, + }, + { + name: "prompt.autocomplete.complete", + title: "Complete autocomplete item", + category: "Autocomplete", + run() { + if (mode() === "slash" && options().length === 0) { + hide() + return + } + const item = options()[menu.selected()] + if (item?.kind === "mention" && item.directory) { + expand() + return + } + select() + }, + }, + ], + bindings: input.tuiConfig.keybinds.gather("run.prompt.autocomplete", [ + "prompt.autocomplete.prev", + "prompt.autocomplete.next", + "prompt.autocomplete.hide", + "prompt.autocomplete.select", + "prompt.autocomplete.complete", + ]), + })) + + const onKeyDown = (_event: KeyEvent) => {} const submitPrompt = (next: RunPrompt) => { if (!area || area.isDestroyed) { @@ -1144,7 +1127,6 @@ export function createPromptState(input: PromptInput): PromptState { } onCleanup(() => { - clear() if (area && !area.isDestroyed) { area.off("line-info-change", scheduleRows) } @@ -1188,7 +1170,6 @@ export function createPromptState(input: PromptInput): PromptState { syncDraft() } - clear() hide() prev = kind if (kind !== "prompt") { @@ -1202,7 +1183,6 @@ export function createPromptState(input: PromptInput): PromptState { return { placeholder, - bindings, shell, visible, options, diff --git a/packages/opencode/src/cli/cmd/run/footer.question.tsx b/packages/opencode/src/cli/cmd/run/footer.question.tsx index 5bea73a919..d0f36246a5 100644 --- a/packages/opencode/src/cli/cmd/run/footer.question.tsx +++ b/packages/opencode/src/cli/cmd/run/footer.question.tsx @@ -177,10 +177,6 @@ export function RunQuestionBody(props: { return } - if (event.name === "return" && !event.shift && !event.ctrl && !event.meta) { - saveCustom() - event.preventDefault() - } return } @@ -496,6 +492,7 @@ export function RunQuestionBody(props: { focusedBackgroundColor={props.theme.surface} cursorColor={props.theme.text} focused={!disabled()} + onSubmit={saveCustom} onContentChange={() => { if (!area || area.isDestroyed || disabled()) { return diff --git a/packages/opencode/src/cli/cmd/run/footer.ts b/packages/opencode/src/cli/cmd/run/footer.ts index c94c664cc0..4ac8b40d88 100644 --- a/packages/opencode/src/cli/cmd/run/footer.ts +++ b/packages/opencode/src/cli/cmd/run/footer.ts @@ -24,22 +24,22 @@ // Ctrl-c clears a live prompt draft first; otherwise interrupt and exit use a // two-press pattern where the first press shows a hint and the second press // within 5 seconds actually fires the action. -import { CliRenderEvents, type CliRenderer, type TreeSitterClient } from "@opentui/core" +import { CliRenderEvents, type CliRenderer, type KeyEvent, type Renderable, type TreeSitterClient } from "@opentui/core" +import type { Keymap } from "@opentui/keymap" import { render } from "@opentui/solid" import { createComponent, createSignal, type Accessor, type Setter } from "solid-js" import { createStore, reconcile } from "solid-js/store" +import { OpencodeKeymapProvider, formatKeyBindings } from "@/cli/cmd/tui/keymap" import { withRunSpan } from "./otel" import { RUN_COMMAND_PANEL_ROWS, RUN_SUBAGENT_PANEL_ROWS } from "./footer.command" import { SUBAGENT_INSPECTOR_ROWS } from "./footer.subagent" import { PROMPT_MAX_ROWS, TEXTAREA_MIN_ROWS } from "./footer.prompt" -import { printableBinding } from "./prompt.shared" import { RunFooterView } from "./footer.view" import { RunScrollbackStream } from "./scrollback.surface" import type { RunTheme } from "./theme" import type { FooterApi, FooterEvent, - FooterKeybinds, FooterPatch, FooterPromptRoute, FooterState, @@ -55,6 +55,7 @@ import type { RunPrompt, RunProvider, RunResource, + RunTuiConfig, StreamCommit, } from "./types" @@ -80,7 +81,8 @@ type RunFooterOptions = { first: boolean history?: RunPrompt[] theme: RunTheme - keybinds: FooterKeybinds + keymap: Keymap + tuiConfig: RunTuiConfig diffStyle: RunDiffStyle onPermissionReply: (input: PermissionReply) => void | Promise onQuestionReply: (input: QuestionReply) => void | Promise @@ -195,7 +197,6 @@ export class RunFooter implements FooterApi { private autocomplete = false private interruptTimeout: NodeJS.Timeout | undefined private exitTimeout: NodeJS.Timeout | undefined - private interruptHint: string private requestExitHandler: (() => boolean) | undefined private scrollback: RunScrollbackStream @@ -249,7 +250,6 @@ export class RunFooter implements FooterApi { setSubagent("questions", reconcile(next.questions, { key: "id" })) } this.base = Math.max(1, renderer.footerHeight - TEXTAREA_MIN_ROWS) - this.interruptHint = printableBinding(options.keybinds.interrupt, options.keybinds.leader) || "esc" this.scrollback = new RunScrollbackStream(renderer, options.theme, { diffStyle: options.diffStyle, wrote: options.wrote, @@ -259,42 +259,48 @@ export class RunFooter implements FooterApi { this.renderer.on(CliRenderEvents.DESTROY, this.handleDestroy) + const footer = this void render( () => - createComponent(RunFooterView, { - directory: options.directory, - state: this.state, - view: this.view, - subagent: this.subagent, - findFiles: options.findFiles, - agents: this.agents, - resources: this.resources, - commands: this.commands, - providers: this.providers, - currentModel: this.currentModel, - variants: this.variants, - currentVariant: this.currentVariant, - theme: options.theme, - diffStyle: options.diffStyle, - keybinds: options.keybinds, - history: options.history, - agent: options.agentLabel, - onSubmit: this.handlePrompt, - onPermissionReply: this.handlePermissionReply, - onQuestionReply: this.handleQuestionReply, - onQuestionReject: this.handleQuestionReject, - onCycle: this.handleCycle, - onInterrupt: this.handleInterrupt, - onInputClear: this.handleInputClear, - onExitRequest: this.handleExit, - onRequestExit: this.setRequestExitHandler, - onExit: () => this.close(), - onModelSelect: this.handleModelSelect, - onVariantSelect: this.handleVariantSelect, - onRows: this.syncRows, - onLayout: this.syncLayout, - onStatus: this.setStatus, - onSubagentSelect: options.onSubagentSelect, + createComponent(OpencodeKeymapProvider, { + keymap: options.keymap, + get children() { + return createComponent(RunFooterView, { + directory: options.directory, + state: footer.state, + view: footer.view, + subagent: footer.subagent, + findFiles: options.findFiles, + agents: footer.agents, + resources: footer.resources, + commands: footer.commands, + providers: footer.providers, + currentModel: footer.currentModel, + variants: footer.variants, + currentVariant: footer.currentVariant, + theme: options.theme, + diffStyle: options.diffStyle, + tuiConfig: options.tuiConfig, + history: options.history, + agent: options.agentLabel, + onSubmit: footer.handlePrompt, + onPermissionReply: footer.handlePermissionReply, + onQuestionReply: footer.handleQuestionReply, + onQuestionReject: footer.handleQuestionReject, + onCycle: footer.handleCycle, + onInterrupt: footer.handleInterrupt, + onInputClear: footer.handleInputClear, + onExitRequest: footer.handleExit, + onRequestExit: footer.setRequestExitHandler, + onExit: () => footer.close(), + onModelSelect: footer.handleModelSelect, + onVariantSelect: footer.handleVariantSelect, + onRows: footer.syncRows, + onLayout: footer.syncLayout, + onStatus: footer.setStatus, + onSubagentSelect: options.onSubagentSelect, + }) + }, }), this.renderer, ).catch(() => { @@ -781,6 +787,13 @@ export class RunFooter implements FooterApi { }, 5000) } + private interruptHint(): string { + const bindings = this.options.keymap + .getCommandBindings({ visibility: "registered", commands: ["session.interrupt"] }) + .get("session.interrupt") + return formatKeyBindings(bindings, this.options.tuiConfig) || "esc" + } + private clearExitTimer(): void { if (!this.exitTimeout) { return @@ -815,7 +828,7 @@ export class RunFooter implements FooterApi { if (next < 2) { this.armInterruptTimer() - this.patch({ status: `${this.interruptHint} again to interrupt` }) + this.patch({ status: `${this.interruptHint()} again to interrupt` }) return true } diff --git a/packages/opencode/src/cli/cmd/run/footer.view.tsx b/packages/opencode/src/cli/cmd/run/footer.view.tsx index affc664b15..1989f5f70f 100644 --- a/packages/opencode/src/cli/cmd/run/footer.view.tsx +++ b/packages/opencode/src/cli/cmd/run/footer.view.tsx @@ -10,7 +10,7 @@ // All state comes from the parent RunFooter through SolidJS signals. // The view itself is stateless except for derived memos. /** @jsxImportSource @opentui/solid */ -import { useKeyboard, useTerminalDimensions } from "@opentui/solid" +import { useTerminalDimensions } from "@opentui/solid" import { Match, Show, Switch, createEffect, createMemo, createSignal, onCleanup } from "solid-js" import "opentui-spinner/solid" import { createColors, createFrames } from "../tui/ui/spinner" @@ -26,9 +26,14 @@ import { RunFooterSubagentBody } from "./footer.subagent" import { RunPromptBody, createPromptState, hintFlags } from "./footer.prompt" import { RunPermissionBody } from "./footer.permission" import { RunQuestionBody } from "./footer.question" -import { printableBinding, promptBindings, promptHit, promptInfo } from "./prompt.shared" +import { + OPENCODE_BASE_MODE, + formatKeyBindings, + useBindings, + useKeymapSelector, + type OpenTuiKeymap, +} from "@/cli/cmd/tui/keymap" import type { - FooterKeybinds, FooterPromptRoute, FooterState, FooterSubagentState, @@ -43,6 +48,7 @@ import type { RunPrompt, RunProvider, RunResource, + RunTuiConfig, } from "./types" import { RUN_THEME_FALLBACK, type RunTheme } from "./theme" @@ -75,7 +81,7 @@ type RunFooterViewProps = { subagent?: () => FooterSubagentState theme?: RunTheme diffStyle?: RunDiffStyle - keybinds: FooterKeybinds + tuiConfig: RunTuiConfig history?: RunPrompt[] agent: string onSubmit: (input: RunPrompt) => boolean @@ -149,9 +155,24 @@ export function RunFooterView(props: RunFooterViewProps) { const current = route() return current.type === "subagent" ? subagent().details[current.sessionID] : undefined }) - const command = createMemo(() => printableBinding(props.keybinds.commandList, props.keybinds.leader)) - const interrupt = createMemo(() => printableBinding(props.keybinds.interrupt, props.keybinds.leader)) - const commandKeys = createMemo(() => promptBindings(props.keybinds.commandList, props.keybinds.leader)) + const command = useKeymapSelector((keymap: OpenTuiKeymap) => + formatKeyBindings( + keymap.getCommandBindings({ visibility: "registered", commands: ["command.palette.show"] }).get("command.palette.show"), + props.tuiConfig, + ) ?? "", + ) + const interrupt = useKeymapSelector((keymap: OpenTuiKeymap) => + formatKeyBindings( + keymap.getCommandBindings({ visibility: "registered", commands: ["session.interrupt"] }).get("session.interrupt"), + props.tuiConfig, + ) ?? "", + ) + const variantCycle = useKeymapSelector((keymap: OpenTuiKeymap) => + formatKeyBindings( + keymap.getCommandBindings({ visibility: "registered", commands: ["variant.cycle"] }).get("variant.cycle"), + props.tuiConfig, + ) ?? "", + ) const hints = createMemo(() => hintFlags(term().width)) const busy = createMemo(() => props.state().phase === "running") const armed = createMemo(() => props.state().interrupt > 0) @@ -257,7 +278,7 @@ export function RunFooterView(props: RunFooterViewProps) { subagents: () => tabs().length, resources: props.resources, commands: props.commands, - keybinds: props.keybinds, + tuiConfig: props.tuiConfig, state: props.state, view: promptView, prompt, @@ -285,30 +306,28 @@ export function RunFooterView(props: RunFooterViewProps) { props.onRequestExit?.(undefined) }) - useKeyboard((event) => { - if (event.defaultPrevented) { - return - } - - if (active().type !== "prompt") { - return - } - - if (route().type !== "composer") { - return - } - - if (composer.visible()) { - return - } - - if (!promptHit(commandKeys(), promptInfo(event))) { - return - } - - event.preventDefault() - openCommand() - }) + useBindings(() => ({ + mode: OPENCODE_BASE_MODE, + enabled: active().type === "prompt" && route().type === "composer" && !composer.visible(), + commands: [ + { + name: "command.palette.show", + title: "Open command palette", + category: "Prompt", + run: openCommand, + }, + { + name: "variant.cycle", + title: "Cycle model variant", + category: "Model", + run: props.onCycle, + }, + ], + bindings: [ + ...props.tuiConfig.keybinds.get("command.palette.show"), + ...props.tuiConfig.keybinds.get("variant.cycle"), + ], + })) createEffect(() => { const current = route() @@ -407,7 +426,6 @@ export function RunFooterView(props: RunFooterViewProps) { - -export type ParsedBinding = { - sequence: KeySequencePart[] - event: "press" | "release" -} - -const keyNameAliases = { - delete: "del", - enter: "return", - escape: "esc", - pagedown: "pgdn", - pageup: "pgup", -} as const - -const modifierAliases = { - meta: "alt", -} as const - -function hostPlatform() { - if (process.platform === "darwin") { - return "macos" as const - } - - if (process.platform === "win32") { - return "windows" as const - } - - if (process.platform === "linux") { - return "linux" as const - } - - return "unknown" as const -} - -function createCommandEvent() { - return new KeyEvent({ - name: "command", - ctrl: false, - meta: false, - shift: false, - option: false, - sequence: "", - number: false, - raw: "", - eventType: "press", - source: "raw", - }) -} - -function createParser(leader: string) { - const platform = hostPlatform() - const keymap = new Keymap({ - metadata: { - platform, - primaryModifier: platform === "macos" ? "super" : platform === "unknown" ? "unknown" : "ctrl", - modifiers: { - ctrl: "supported", - shift: "supported", - meta: "supported", - super: "unknown", - hyper: "unknown", - }, - }, - rootTarget: {}, - isDestroyed: false, - getFocusedTarget() { - return null - }, - getParentTarget(_target) { - return null - }, - isTargetDestroyed(_target) { - return false - }, - onKeyPress(_listener) { - return () => {} - }, - onKeyRelease(_listener) { - return () => {} - }, - onFocusChange(_listener) { - return () => {} - }, - onTargetDestroy(_target, _listener) { - return () => {} - }, - createCommandEvent, - }) - - const offDefault = registerDefaultKeys(keymap) - const offLeader = registerLeader(keymap, { trigger: leader }) - - return { - keymap, - dispose() { - offLeader() - offDefault() - }, - } -} - -function formatOptions(leader: string) { - return { - tokenDisplay: { - leader, - }, - keyNameAliases, - modifierAliases, - } as const -} - -function splitBinding(binding: ParsedBindingInput) { - if (typeof binding.key !== "string" || !binding.key.includes(",")) { - return [binding] - } - - return binding.key - .split(",") - .map((key) => key.trim()) - .filter(Boolean) - .map((key) => ({ - ...binding, - key, - })) -} - -export function parseBindings(bindings: readonly ParsedBindingInput[], leader: string): ParsedBinding[] { - const parser = createParser(leader) - - try { - return bindings.flatMap((binding) => - splitBinding(binding).map((item) => ({ - sequence: Array.from(parser.keymap.parseKeySequence(item.key)), - event: item.event ?? "press", - })), - ) - } finally { - parser.dispose() - } -} - -export function formatBinding(bindings: readonly ParsedBindingInput[], leader: string) { - return formatKeySequence(parseBindings(bindings, leader)[0]?.sequence, formatOptions(leader)) -} - -export function formatBindings(bindings: readonly ParsedBindingInput[], leader: string) { - return formatCommandBindings(parseBindings(bindings, leader), formatOptions(leader)) -} diff --git a/packages/opencode/src/cli/cmd/run/prompt.shared.ts b/packages/opencode/src/cli/cmd/run/prompt.shared.ts index 2dda26bae1..5f9570fd98 100644 --- a/packages/opencode/src/cli/cmd/run/prompt.shared.ts +++ b/packages/opencode/src/cli/cmd/run/prompt.shared.ts @@ -1,20 +1,14 @@ // Pure state machine for the prompt input. // -// Handles keybind parsing, history ring navigation, and the leader-key -// sequence for variant cycling. All functions are pure -- they take state -// in and return new state out, with no side effects. +// Handles history ring navigation and prompt text helpers. All functions are +// pure -- they take state in and return new state out, with no side effects. // // The history ring (PromptHistoryState) stores past prompts and tracks // the current browse position. When the user arrows up at cursor offset 0, // the current draft is saved and history begins. Arrowing past the end // restores the draft. -// -// The leader-key cycle (promptCycle) uses a two-step pattern: first press -// arms the leader, second press within the timeout fires the action. -import type { KeyBinding } from "@opentui/core" export { displayCharAt, displaySlice, mentionTriggerIndex } from "../prompt-display" -import { formatBinding, parseBindings } from "./keymap.shared" -import type { FooterKeybinds, RunPrompt } from "./types" +import type { RunPrompt } from "./types" const HISTORY_LIMIT = 200 @@ -24,36 +18,6 @@ export type PromptHistoryState = { draft: string } -export function promptInfo(event: { name: string; ctrl?: boolean; meta?: boolean; shift?: boolean; super?: boolean }) { - return { - name: event.name === " " ? "space" : event.name, - ctrl: !!event.ctrl, - meta: !!event.meta, - shift: !!event.shift, - super: !!event.super, - leader: false, - } -} - -type PromptInfo = ReturnType - -export type PromptKeys = { - leaders: PromptInfo[] - cycles: PromptInfo[] - interrupts: PromptInfo[] - previous: PromptInfo[] - next: PromptInfo[] - clear: PromptInfo[] - bindings: KeyBinding[] -} - -export type PromptCycle = { - arm: boolean - clear: boolean - cycle: boolean - consume: boolean -} - export type PromptMove = { state: PromptHistoryState text?: string @@ -73,98 +37,6 @@ export function promptSame(a: RunPrompt, b: RunPrompt): boolean { return a.mode === b.mode && a.text === b.text && JSON.stringify(a.parts) === JSON.stringify(b.parts) } -function promptKey(binding: ReturnType[number]): PromptInfo | undefined { - if (binding.event !== "press") { - return undefined - } - - const first = binding.sequence[0] - const second = binding.sequence[1] - - if (!first) { - return undefined - } - - if (!second) { - return first.patternName || first.tokenName - ? undefined - : { - name: first.stroke.name, - ctrl: first.stroke.ctrl, - meta: first.stroke.meta, - shift: first.stroke.shift, - super: first.stroke.super, - leader: false, - } - } - - if (binding.sequence.length !== 2 || first.tokenName !== "leader" || second.patternName || second.tokenName) { - return undefined - } - - return { - name: second.stroke.name, - ctrl: second.stroke.ctrl, - meta: second.stroke.meta, - shift: second.stroke.shift, - super: second.stroke.super, - leader: true, - } -} - -export function promptBindings(bindings: FooterKeybinds["commandList"], leader: string): PromptInfo[] { - return parseBindings(bindings, leader).flatMap((binding) => { - const key = promptKey(binding) - return key ? [key] : [] - }) -} - -function mapInputBindings( - bindings: FooterKeybinds["inputSubmit"], - leader: string, - action: "submit" | "newline", -): KeyBinding[] { - return promptBindings(bindings, leader).flatMap((key) => { - if (key.leader) { - return [] - } - - return [ - { - name: key.name, - ctrl: key.ctrl || undefined, - meta: key.meta || undefined, - shift: key.shift || undefined, - super: key.super || undefined, - action, - }, - ] - }) -} - -function textareaBindings(keybinds: FooterKeybinds): KeyBinding[] { - return [ - ...mapInputBindings(keybinds.inputSubmit, keybinds.leader, "submit"), - ...mapInputBindings(keybinds.inputNewline, keybinds.leader, "newline"), - ] -} - -export function promptKeys(keybinds: FooterKeybinds): PromptKeys { - return { - leaders: promptBindings([{ key: keybinds.leader }], keybinds.leader), - cycles: promptBindings(keybinds.variantCycle, keybinds.leader), - interrupts: promptBindings(keybinds.interrupt, keybinds.leader), - previous: promptBindings(keybinds.historyPrevious, keybinds.leader), - next: promptBindings(keybinds.historyNext, keybinds.leader), - clear: promptBindings(keybinds.inputClear, keybinds.leader), - bindings: textareaBindings(keybinds), - } -} - -export function printableBinding(bindings: FooterKeybinds["commandList"], leader: string): string { - return formatBinding(bindings, leader) -} - export function isExitCommand(input: string): boolean { const text = input.trim().toLowerCase() return text === "/exit" || text === "/quit" || text === ":q" @@ -174,59 +46,6 @@ export function isNewCommand(input: string): boolean { return input.trim().toLowerCase() === "/new" } -export function promptHit(bindings: PromptInfo[], event: PromptInfo): boolean { - return bindings.some( - (item) => - item.name === event.name && - item.ctrl === event.ctrl && - item.meta === event.meta && - item.shift === event.shift && - item.super === event.super && - item.leader === event.leader, - ) -} - -export function promptCycle( - armed: boolean, - event: PromptInfo, - leaders: PromptInfo[], - cycles: PromptInfo[], -): PromptCycle { - if (!armed && promptHit(leaders, event)) { - return { - arm: true, - clear: false, - cycle: false, - consume: true, - } - } - - if (armed) { - return { - arm: false, - clear: true, - cycle: promptHit(cycles, { ...event, leader: true }), - consume: true, - } - } - - if (!promptHit(cycles, event)) { - return { - arm: false, - clear: false, - cycle: false, - consume: false, - } - } - - return { - arm: false, - clear: false, - cycle: true, - consume: true, - } -} - export function createPromptHistory(items?: RunPrompt[]): PromptHistoryState { const list = (items ?? []).filter((item) => item.text.trim().length > 0).map(promptCopy) const next: RunPrompt[] = [] diff --git a/packages/opencode/src/cli/cmd/run/runtime.boot.ts b/packages/opencode/src/cli/cmd/run/runtime.boot.ts index 3ff9801c6a..d0113466c4 100644 --- a/packages/opencode/src/cli/cmd/run/runtime.boot.ts +++ b/packages/opencode/src/cli/cmd/run/runtime.boot.ts @@ -1,32 +1,21 @@ // Boot-time resolution for direct interactive mode. // // These functions run concurrently at startup to gather everything the runtime -// needs before the first frame: keybinds from TUI config, diff display style, +// needs before the first frame: TUI keymap config, diff display style, // model variant list with context limits, and session history for the prompt // history ring. All are async because they read config or hit the SDK, but // none block each other. import { Context, Effect, Layer } from "effect" -import { stringifyKeyStroke } from "@opentui/keymap" +import { createBindingLookup } from "@opentui/keymap/extras" import { TuiConfig } from "@/cli/cmd/tui/config/tui" import { TuiKeybind } from "@/cli/cmd/tui/config/keybind" import { makeRuntime } from "@/effect/run-service" import { reusePendingTask } from "./runtime.shared" import { resolveSession, sessionHistory } from "./session.shared" -import type { FooterKeybinds, RunDiffStyle, RunInput, RunPrompt, RunProvider } from "./types" +import type { RunDiffStyle, RunInput, RunPrompt, RunProvider, RunTuiConfig } from "./types" import { pickVariant } from "./variant.shared" -const DEFAULT_KEYBINDS: FooterKeybinds = { - leader: TuiKeybind.LeaderDefault, - leaderTimeout: 2000, - commandList: [{ key: "ctrl+p" }], - variantCycle: [{ key: "ctrl+t" }], - interrupt: [{ key: "escape" }], - historyPrevious: [{ key: "up" }], - historyNext: [{ key: "down" }], - inputClear: [{ key: "ctrl+c" }], - inputSubmit: [{ key: "return" }], - inputNewline: [{ key: "shift+return,ctrl+return,alt+return,ctrl+j" }], -} +const DEFAULT_LEADER_TIMEOUT = 2000 export type ModelInfo = { providers: RunProvider[] @@ -52,7 +41,7 @@ type BootService = { sessionID: string, model: RunInput["model"], ) => Effect.Effect - readonly resolveFooterKeybinds: () => Effect.Effect + readonly resolveRunTuiConfig: () => Effect.Effect readonly resolveDiffStyle: () => Effect.Effect } @@ -80,28 +69,27 @@ function emptySessionInfo(): SessionInfo { } } -function leaderKey(config: Config) { - const key = config.keybinds.get("leader")?.[0]?.key - if (!key) return TuiKeybind.LeaderDefault - return typeof key === "string" ? key : stringifyKeyStroke(key) +function defaultRunTuiConfig(): RunTuiConfig { + const keybinds = TuiKeybind.parse({}) + return { + keybinds: createBindingLookup(TuiKeybind.toBindingConfig(keybinds), { + commandMap: TuiKeybind.CommandMap, + bindingDefaults: TuiKeybind.bindingDefaults(), + }), + leader_timeout: DEFAULT_LEADER_TIMEOUT, + diff_style: "auto", + } } -function footerKeybinds(config: Config | undefined): FooterKeybinds { +function runTuiConfig(config: Config | undefined): RunTuiConfig { if (!config) { - return DEFAULT_KEYBINDS + return defaultRunTuiConfig() } return { - leader: leaderKey(config), - leaderTimeout: config.leader_timeout, - commandList: config.keybinds.get("command.palette.show"), - variantCycle: config.keybinds.get("variant.cycle"), - interrupt: config.keybinds.get("session.interrupt"), - historyPrevious: config.keybinds.get("prompt.history.previous"), - historyNext: config.keybinds.get("prompt.history.next"), - inputClear: config.keybinds.get("prompt.clear"), - inputSubmit: config.keybinds.get("input.submit"), - inputNewline: config.keybinds.get("input.newline"), + keybinds: config.keybinds, + leader_timeout: config.leader_timeout, + diff_style: config.diff_style ?? "auto", } } @@ -175,18 +163,18 @@ const layer = Layer.effect( } }) - const resolveFooterKeybinds = Effect.fn("RunBoot.resolveFooterKeybinds")(function* () { - return footerKeybinds(yield* config()) + const resolveRunTuiConfig = Effect.fn("RunBoot.resolveRunTuiConfig")(function* () { + return runTuiConfig(yield* config()) }) const resolveDiffStyle = Effect.fn("RunBoot.resolveDiffStyle")(function* () { - return (yield* config())?.diff_style ?? "auto" + return runTuiConfig(yield* config()).diff_style ?? "auto" }) return Service.of({ resolveModelInfo, resolveSessionInfo, - resolveFooterKeybinds, + resolveRunTuiConfig, resolveDiffStyle, }) }), @@ -212,9 +200,9 @@ export async function resolveSessionInfo( return runtime.runPromise((svc) => svc.resolveSessionInfo(sdk, sessionID, model)).catch(() => emptySessionInfo()) } -// Reads keybind overrides from TUI config and merges them with defaults. -export async function resolveFooterKeybinds(): Promise { - return runtime.runPromise((svc) => svc.resolveFooterKeybinds()).catch(() => DEFAULT_KEYBINDS) +// Reads TUI config once for direct mode keymap setup and display preferences. +export async function resolveRunTuiConfig(): Promise { + return runtime.runPromise((svc) => svc.resolveRunTuiConfig()).catch(() => defaultRunTuiConfig()) } export async function resolveDiffStyle(): Promise { diff --git a/packages/opencode/src/cli/cmd/run/runtime.lifecycle.ts b/packages/opencode/src/cli/cmd/run/runtime.lifecycle.ts index eb342a7fda..bc44aafa96 100644 --- a/packages/opencode/src/cli/cmd/run/runtime.lifecycle.ts +++ b/packages/opencode/src/cli/cmd/run/runtime.lifecycle.ts @@ -9,7 +9,9 @@ // Also wires SIGINT so Ctrl-c clears a live prompt draft first, then falls // back to the usual two-press exit sequence through RunFooter.requestExit(). import { createCliRenderer, type CliRenderer, type ScrollbackWriter } from "@opentui/core" +import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui" import { Session as SessionApi } from "@/session/session" +import { registerOpencodeKeymap } from "@/cli/cmd/tui/keymap" import * as Locale from "@/util/locale" import { withRunSpan } from "./otel" import { resolveInteractiveStdin } from "./runtime.stdin" @@ -17,15 +19,14 @@ import { entrySplash, exitSplash, splashMeta } from "./splash" import { resolveRunTheme } from "./theme" import type { FooterApi, - FooterKeybinds, PermissionReply, QuestionReject, QuestionReply, RunAgent, - RunDiffStyle, RunInput, RunPrompt, RunResource, + RunTuiConfig, } from "./types" import { formatModelLabel } from "./variant.shared" @@ -61,8 +62,7 @@ export type LifecycleInput = { agent: string | undefined model: RunInput["model"] variant: string | undefined - keybinds: FooterKeybinds - diffStyle: RunDiffStyle + tuiConfig: RunTuiConfig onPermissionReply: (input: PermissionReply) => void | Promise onQuestionReply: (input: QuestionReply) => void | Promise onQuestionReject: (input: QuestionReject) => void | Promise @@ -169,6 +169,7 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise { const source = resolveInteractiveStdin() + let unregisterKeymap: (() => void) | undefined try { const renderer = await createCliRenderer({ @@ -188,6 +189,8 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise {}) footer.destroy() + unregisterKeymap?.() shutdown(renderer) source.cleanup?.() } @@ -305,6 +310,7 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise { async (span) => { const start = performance.now() const log = trace() - const keybindTask = resolveFooterKeybinds() - const diffTask = resolveDiffStyle() + const tuiConfigTask = resolveRunTuiConfig() const ctx = await input.boot() const modelTask = resolveModelInfo(ctx.sdk, ctx.directory, ctx.model) const sessionTask = @@ -186,9 +185,8 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise { variant: undefined, }) const savedTask = resolveSavedVariant(ctx.model) - const [keybinds, diffStyle, session, savedVariant] = await Promise.all([ - keybindTask, - diffTask, + const [tuiConfig, session, savedVariant] = await Promise.all([ + tuiConfigTask, sessionTask, savedTask, ]) @@ -252,8 +250,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise { agent: state.agent, model: state.model, variant: state.activeVariant, - keybinds, - diffStyle, + tuiConfig, onPermissionReply: async (next) => { if (state.demo?.permission(next)) { return diff --git a/packages/opencode/src/cli/cmd/run/types.ts b/packages/opencode/src/cli/cmd/run/types.ts index d2de3fca8a..556b1f8623 100644 --- a/packages/opencode/src/cli/cmd/run/types.ts +++ b/packages/opencode/src/cli/cmd/run/types.ts @@ -11,9 +11,8 @@ // → stream.ts bridges to footer API // → footer.ts queues commits and patches the footer view // → OpenTUI split-footer renderer writes to terminal -import type { KeyEvent, Renderable } from "@opentui/core" -import type { Binding } from "@opentui/keymap" import type { OpencodeClient, PermissionRequest, QuestionRequest, ToolPart } from "@opencode-ai/sdk/v2" +import type { TuiConfig } from "@/cli/cmd/tui/config/tui" export type RunFilePart = { type: "file" @@ -265,20 +264,7 @@ export type QuestionReply = Parameters[0] export type QuestionReject = Parameters[0] -type FooterBinding = Binding - -export type FooterKeybinds = { - leader: string - leaderTimeout: number - commandList: readonly FooterBinding[] - variantCycle: readonly FooterBinding[] - interrupt: readonly FooterBinding[] - historyPrevious: readonly FooterBinding[] - historyNext: readonly FooterBinding[] - inputClear: readonly FooterBinding[] - inputSubmit: readonly FooterBinding[] - inputNewline: readonly FooterBinding[] -} +export type RunTuiConfig = Pick // Lifecycle phase of a scrollback entry. "start" opens the entry, "progress" // appends content (coalesced in the footer queue), "final" closes it. diff --git a/packages/opencode/src/cli/cmd/tui/keymap.tsx b/packages/opencode/src/cli/cmd/tui/keymap.tsx index d8489fd2f7..cd46e8ccc7 100644 --- a/packages/opencode/src/cli/cmd/tui/keymap.tsx +++ b/packages/opencode/src/cli/cmd/tui/keymap.tsx @@ -1,4 +1,4 @@ -import { type CliRenderer } from "@opentui/core" +import { InputRenderable, TextareaRenderable, type CliRenderer } from "@opentui/core" import * as addons from "@opentui/keymap/addons/opentui" import { stringifyKeyStroke } from "@opentui/keymap" import { @@ -31,6 +31,7 @@ type CommandSlashEntry = { onSelect: () => void } type Command = ReturnType[number] +type FormatConfig = Pick const modeStacks = new WeakMap() @@ -160,13 +161,22 @@ const inputCommands = [ "input.submit", ] as const -function leaderDisplay(config: TuiConfig.Resolved) { +function hasManagedTextareaFocus(renderer: CliRenderer) { + const editor = renderer.currentFocusedEditor + return editor instanceof TextareaRenderable && !(editor instanceof InputRenderable) +} + +function leaderDisplay(config: FormatConfig) { const key = config.keybinds.get(LEADER_TOKEN)?.[0]?.key if (!key) return TuiKeybind.LeaderDefault return typeof key === "string" ? key : stringifyKeyStroke(key) } -function formatOptions(config: TuiConfig.Resolved) { +function leaderKey(config: FormatConfig) { + return config.keybinds.get(LEADER_TOKEN)?.[0]?.key +} + +function formatOptions(config: FormatConfig) { return { tokenDisplay: { [LEADER_TOKEN]: leaderDisplay(config), @@ -182,13 +192,13 @@ function formatOptions(config: TuiConfig.Resolved) { } as const } -export function formatKeySequence(parts: Parameters[0], config: TuiConfig.Resolved) { +export function formatKeySequence(parts: Parameters[0], config: FormatConfig) { return formatKeySequenceExtra(parts, formatOptions(config)) } export function formatKeyBindings( bindings: Parameters[0], - config: TuiConfig.Resolved, + config: FormatConfig, ) { return formatCommandBindingsExtra(bindings, formatOptions(config)) } @@ -202,15 +212,18 @@ export function registerOpencodeKeymap( const offCommaBindings = addons.registerCommaBindings(keymap) const offAliasExpander = registerKeyAliases(keymap) const offBaseLayout = addons.registerBaseLayoutFallback(keymap) - const offLeader = addons.registerTimedLeader(keymap, { - trigger: config.keybinds.get(LEADER_TOKEN), - name: LEADER_TOKEN, - timeoutMs: config.leader_timeout, - }) + const leader = leaderKey(config) + const offLeader = leader + ? addons.registerTimedLeader(keymap, { + trigger: leader, + name: LEADER_TOKEN, + timeoutMs: config.leader_timeout, + }) + : () => {} const offEscape = addons.registerEscapeClearsPendingSequence(keymap) const offBackspace = addons.registerBackspacePopsPendingSequence(keymap) const offInputBindings = addons.registerManagedTextareaLayer(keymap, renderer, { - enabled: () => renderer.currentFocusedEditor !== null, + enabled: () => hasManagedTextareaFocus(renderer), bindings: config.keybinds.gather("input", inputCommands), }) diff --git a/packages/opencode/test/cli/run/footer.view.test.tsx b/packages/opencode/test/cli/run/footer.view.test.tsx index 697e64efc8..d20707e5b5 100644 --- a/packages/opencode/test/cli/run/footer.view.test.tsx +++ b/packages/opencode/test/cli/run/footer.view.test.tsx @@ -1,8 +1,10 @@ /** @jsxImportSource @opentui/solid */ import { expect, test } from "bun:test" -import { testRender } from "@opentui/solid" +import { testRender, useRenderer } from "@opentui/solid" import { createSignal } from "solid-js" +import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui" import type { QuestionRequest } from "@opencode-ai/sdk/v2" +import { OpencodeKeymapProvider, registerOpencodeKeymap } from "@/cli/cmd/tui/keymap" import { RUN_COMMAND_PANEL_ROWS, RUN_SUBAGENT_PANEL_ROWS, @@ -15,7 +17,6 @@ import { RunFooterView } from "@/cli/cmd/run/footer.view" import { RunEntryContent } from "@/cli/cmd/run/scrollback.writer" import { RUN_THEME_FALLBACK } from "@/cli/cmd/run/theme" import type { - FooterKeybinds, FooterState, FooterSubagentState, FooterSubagentTab, @@ -23,26 +24,14 @@ import type { RunCommand, RunInput, RunProvider, + RunTuiConfig, StreamCommit, } from "@/cli/cmd/run/types" import { RunQuestionBody } from "@/cli/cmd/run/footer.question" +import { RejectField } from "@/cli/cmd/run/footer.permission" +import { createTuiResolvedConfig } from "../../fixture/tui-runtime" -function bindings(...keys: string[]) { - return keys.map((key) => ({ key })) -} - -const keybinds: FooterKeybinds = { - leader: "ctrl+x", - leaderTimeout: 2000, - commandList: bindings("ctrl+p"), - variantCycle: bindings("ctrl+t"), - interrupt: bindings("escape"), - historyPrevious: bindings("up"), - historyNext: bindings("down"), - inputClear: bindings("ctrl+c"), - inputSubmit: bindings("return"), - inputNewline: bindings("shift+return,ctrl+return,alt+return,ctrl+j"), -} +const tuiConfig = createTuiResolvedConfig() function command(input: { name: string; description: string; source?: "command" | "mcp" | "skill" }) { return { @@ -143,6 +132,90 @@ function subagent(input: { } satisfies FooterSubagentTab } +function footerState(input: Partial = {}) { + return createSignal({ + phase: "idle", + status: "", + queue: 0, + model: "gpt-5", + duration: "", + usage: "", + first: false, + interrupt: 0, + exit: 0, + ...input, + })[0] +} + +async function renderFooter(input: { tuiConfig?: RunTuiConfig; onCycle?: () => void } = {}) { + const [view] = createSignal({ type: "prompt" }) + const [subagents] = createSignal({ tabs: [], details: {}, permissions: [], questions: [] }) + const state = footerState() + const config = input.tuiConfig ?? tuiConfig + let offKeymap: (() => void) | undefined + + function Harness() { + const renderer = useRenderer() + const keymap = createDefaultOpenTuiKeymap(renderer) + offKeymap = registerOpencodeKeymap(keymap, renderer, config) + + return ( + + []} + agents={() => []} + resources={() => []} + commands={() => []} + providers={() => undefined} + currentModel={() => undefined} + variants={() => []} + currentVariant={() => undefined} + state={state} + view={view} + subagent={subagents} + theme={RUN_THEME_FALLBACK} + tuiConfig={config} + agent="opencode" + onSubmit={() => true} + onPermissionReply={() => { }} + onQuestionReply={() => { }} + onQuestionReject={() => { }} + onCycle={input.onCycle ?? (() => { })} + onInterrupt={() => false} + onInputClear={() => { }} + onExit={() => { }} + onModelSelect={() => { }} + onVariantSelect={() => { }} + onRows={() => { }} + onLayout={() => { }} + onStatus={() => { }} + /> + + ) + } + + const app = await testRender( + () => ( + + + + ), + { width: 100, height: 8, kittyKeyboard: true }, + ) + + return { + ...app, + cleanup() { + app.renderer.currentFocusedRenderable?.blur() + app.renderer.currentFocusedEditor?.blur() + offKeymap?.() + offKeymap = undefined + app.renderer.destroy() + }, + } +} + test("run entry content updates when live commit text changes", async () => { const [commit, setCommit] = createSignal({ kind: "tool", @@ -204,15 +277,15 @@ test("direct command panel renders grouped command palette", async () => { commands={commands} subagents={subagents} variants={variants} - keybinds={keybinds} - onClose={() => {}} - onModel={() => {}} - onSubagent={() => {}} - onVariant={() => {}} - onVariantCycle={() => {}} - onCommand={() => {}} - onNew={() => {}} - onExit={() => {}} + variantCycle="ctrl+t" + onClose={() => { }} + onModel={() => { }} + onSubagent={() => { }} + onVariant={() => { }} + onVariantCycle={() => { }} + onCommand={() => { }} + onNew={() => { }} + onExit={() => { }} /> ), @@ -262,15 +335,15 @@ test("direct command panel shows subagent entry when available", async () => { commands={commands} subagents={subagents} variants={variants} - keybinds={keybinds} - onClose={() => {}} - onModel={() => {}} - onSubagent={() => {}} - onVariant={() => {}} - onVariantCycle={() => {}} - onCommand={() => {}} - onNew={() => {}} - onExit={() => {}} + variantCycle="ctrl+t" + onClose={() => { }} + onModel={() => { }} + onSubagent={() => { }} + onVariant={() => { }} + onVariantCycle={() => { }} + onCommand={() => { }} + onNew={() => { }} + onExit={() => { }} /> ), @@ -306,8 +379,8 @@ test("direct subagent panel renders active subagents", async () => { theme={() => RUN_THEME_FALLBACK.footer} tabs={tabs} current={current} - onClose={() => {}} - onSelect={() => {}} + onClose={() => { }} + onSelect={() => { }} onRows={(value) => { rows = value }} @@ -334,6 +407,61 @@ test("direct subagent panel renders active subagents", async () => { } }) +// OpenTUI currently segfaults when the full footer view suite creates several +// keymap-backed test renderers in one process. Re-enable after the runtime fix. +test.skip("direct footer opens command panel through keymap binding", async () => { + const app = await renderFooter() + + try { + await app.renderOnce() + app.mockInput.pressKey("p", { ctrl: true }) + await app.renderOnce() + + expect(app.captureCharFrame()).toContain("Commands") + } finally { + app.cleanup() + } +}) + +test.skip("direct footer dispatches leader variant binding only when leader is registered", async () => { + const calls: string[] = [] + const app = await renderFooter({ + tuiConfig: createTuiResolvedConfig({ keybinds: { leader: "ctrl+x", variant_cycle: "t" } }), + onCycle: () => calls.push("cycle"), + }) + + try { + await app.renderOnce() + app.mockInput.pressKey("t") + expect(calls).toEqual([]) + + app.mockInput.pressKey("x", { ctrl: true }) + app.mockInput.pressKey("t") + expect(calls).toEqual(["cycle"]) + } finally { + app.cleanup() + } +}) + +test("direct footer keeps leader variant binding inactive when leader is disabled", async () => { + const calls: string[] = [] + const app = await renderFooter({ + tuiConfig: createTuiResolvedConfig({ keybinds: { leader: "none", variant_cycle: "t" } }), + onCycle: () => calls.push("cycle"), + }) + + try { + await app.renderOnce() + app.mockInput.pressKey("t") + app.mockInput.pressKey("x", { ctrl: true }) + app.mockInput.pressKey("t") + + expect(calls).toEqual([]) + } finally { + app.cleanup() + } +}) + test("direct footer shows subagent indicator while prompt is running", async () => { const [state] = createSignal({ phase: "running", @@ -353,10 +481,14 @@ test("direct footer shows subagent indicator while prompt is running", async () permissions: [], questions: [], }) + let offKeymap: (() => void) | undefined + function Harness() { + const renderer = useRenderer() + const keymap = createDefaultOpenTuiKeymap(renderer) + offKeymap = registerOpencodeKeymap(keymap, renderer, tuiConfig) - const app = await testRender( - () => ( - + return ( + []} @@ -371,22 +503,30 @@ test("direct footer shows subagent indicator while prompt is running", async () view={view} subagent={subagents} theme={RUN_THEME_FALLBACK} - keybinds={keybinds} + tuiConfig={tuiConfig} agent="opencode" onSubmit={() => true} - onPermissionReply={() => {}} - onQuestionReply={() => {}} - onQuestionReject={() => {}} - onCycle={() => {}} + onPermissionReply={() => { }} + onQuestionReply={() => { }} + onQuestionReject={() => { }} + onCycle={() => { }} onInterrupt={() => false} - onInputClear={() => {}} - onExit={() => {}} - onModelSelect={() => {}} - onVariantSelect={() => {}} - onRows={() => {}} - onLayout={() => {}} - onStatus={() => {}} + onInputClear={() => { }} + onExit={() => { }} + onModelSelect={() => { }} + onVariantSelect={() => { }} + onRows={() => { }} + onLayout={() => { }} + onStatus={() => { }} /> + + ) + } + + const app = await testRender( + () => ( + + ), { @@ -399,6 +539,9 @@ test("direct footer shows subagent indicator while prompt is running", async () await app.renderOnce() expect(app.captureCharFrame()).toContain("interrupt · 1 agent · ↓ to view") } finally { + app.renderer.currentFocusedRenderable?.blur() + app.renderer.currentFocusedEditor?.blur() + offKeymap?.() app.renderer.destroy() } }) @@ -429,7 +572,7 @@ test("direct question body separates single-select checkmark from label", async onReply={(input) => { replies.push(input) }} - onReject={() => {}} + onReject={() => { }} /> ), @@ -450,6 +593,120 @@ test("direct question body separates single-select checkmark from label", async } }) +test("direct custom answer submits through keymap return binding", async () => { + const question = { + id: "question-1", + sessionID: "session-1", + questions: [ + { + question: "Which answer should I use?", + header: "Answer", + options: [{ label: "Provided", description: "Use the listed answer." }], + custom: true, + }, + ], + } satisfies QuestionRequest + const questions: unknown[] = [] + let off: (() => void) | undefined + + function Harness() { + const renderer = useRenderer() + const keymap = createDefaultOpenTuiKeymap(renderer) + off = registerOpencodeKeymap(keymap, renderer, tuiConfig) + + return ( + + { + questions.push(input) + }} + onReject={() => { }} + /> + + ) + } + + const app = await testRender( + () => ( + + + + ), + { width: 100, height: 18, kittyKeyboard: true }, + ) + + try { + await app.renderOnce() + app.mockInput.pressKey("2") + await app.renderOnce() + "typed".split("").forEach((key) => app.mockInput.pressKey(key)) + await app.renderOnce() + app.mockInput.pressEnter() + await app.renderOnce() + expect(questions).toEqual([{ requestID: "question-1", answers: [["typed"]] }]) + } finally { + app.renderer.currentFocusedRenderable?.blur() + app.renderer.currentFocusedEditor?.blur() + off?.() + app.renderer.destroy() + } +}) + +test("direct permission rejection submits through keymap return binding", async () => { + let text = "" + const submits: string[] = [] + let off: (() => void) | undefined + + function Harness() { + const renderer = useRenderer() + const keymap = createDefaultOpenTuiKeymap(renderer) + off = registerOpencodeKeymap(keymap, renderer, tuiConfig) + + return ( + + { + text = input + }} + onConfirm={() => { + submits.push(text) + }} + onCancel={() => { }} + /> + + ) + } + + const app = await testRender( + () => ( + + + + ), + { width: 100, height: 18, kittyKeyboard: true }, + ) + + try { + await app.renderOnce() + "retry".split("").forEach((key) => app.mockInput.pressKey(key)) + await app.renderOnce() + expect(app.captureCharFrame()).toContain("retry") + app.mockInput.pressEnter() + await app.renderOnce() + expect(submits).toEqual(["retry"]) + } finally { + app.renderer.currentFocusedRenderable?.blur() + app.renderer.currentFocusedEditor?.blur() + off?.() + app.renderer.destroy() + } +}) + test("direct model panel renders current model selector", async () => { const [providers] = createSignal([provider()]) const [current] = createSignal({ providerID: "opencode", modelID: "gpt-5" }) @@ -461,8 +718,8 @@ test("direct model panel renders current model selector", async () => { theme={() => RUN_THEME_FALLBACK.footer} providers={providers} current={current} - onClose={() => {}} - onSelect={() => {}} + onClose={() => { }} + onSelect={() => { }} /> ), @@ -500,8 +757,8 @@ test("direct variant panel renders current variant selector", async () => { theme={() => RUN_THEME_FALLBACK.footer} variants={variants} current={current} - onClose={() => {}} - onSelect={() => {}} + onClose={() => { }} + onSelect={() => { }} /> ), diff --git a/packages/opencode/test/cli/run/prompt.shared.test.ts b/packages/opencode/test/cli/run/prompt.shared.test.ts index 35b35ec3e7..cbf2cd9c9e 100644 --- a/packages/opencode/test/cli/run/prompt.shared.test.ts +++ b/packages/opencode/test/cli/run/prompt.shared.test.ts @@ -7,32 +7,10 @@ import { isNewCommand, mentionTriggerIndex, movePromptHistory, - printableBinding, - promptCycle, - promptHit, - promptInfo, - promptKeys, pushPromptHistory, } from "@/cli/cmd/run/prompt.shared" import type { RunPrompt } from "@/cli/cmd/run/types" -function bindings(...keys: string[]) { - return keys.map((key) => ({ key })) -} - -const keybinds = { - leader: "ctrl+x", - leaderTimeout: 2000, - commandList: bindings("ctrl+p"), - variantCycle: bindings("ctrl+t", "t"), - interrupt: bindings("escape"), - historyPrevious: bindings("up"), - historyNext: bindings("down"), - inputClear: bindings("ctrl+c"), - inputSubmit: bindings("return"), - inputNewline: bindings("shift+return,ctrl+return,alt+return,ctrl+j"), -} - function prompt(text: string, parts: RunPrompt["parts"] = []): RunPrompt { return { text, parts } } @@ -141,39 +119,6 @@ describe("run prompt shared", () => { expect(mentionTriggerIndex("中文 @src file")).toBeUndefined() }) - test("handles direct and leader-based variant cycling", () => { - const keys = promptKeys(keybinds) - - expect(promptHit(keys.clear, promptInfo({ name: "c", ctrl: true }))).toBe(true) - - expect(promptCycle(false, promptInfo({ name: "x", ctrl: true }), keys.leaders, keys.cycles)).toEqual({ - arm: true, - clear: false, - cycle: false, - consume: true, - }) - - expect(promptCycle(true, promptInfo({ name: "t" }), keys.leaders, keys.cycles)).toEqual({ - arm: false, - clear: true, - cycle: true, - consume: true, - }) - - expect(promptCycle(false, promptInfo({ name: "t", ctrl: true }), keys.leaders, keys.cycles)).toEqual({ - arm: false, - clear: false, - cycle: true, - consume: true, - }) - }) - - test("prints bindings with leader substitution and esc normalization", () => { - expect(printableBinding(keybinds.variantCycle.slice(1), "ctrl+x")).toBe("ctrl+x t") - expect(printableBinding(keybinds.interrupt, "ctrl+x")).toBe("esc") - expect(printableBinding([], "ctrl+x")).toBe("") - }) - test("recognizes exit commands", () => { expect(isExitCommand("/exit")).toBe(true) expect(isExitCommand(" /Quit ")).toBe(true) diff --git a/packages/opencode/test/cli/run/runtime.boot.test.ts b/packages/opencode/test/cli/run/runtime.boot.test.ts index 8dd9785532..e610463c77 100644 --- a/packages/opencode/test/cli/run/runtime.boot.test.ts +++ b/packages/opencode/test/cli/run/runtime.boot.test.ts @@ -1,8 +1,7 @@ import { afterEach, describe, expect, mock, spyOn, test } from "bun:test" import { OpencodeClient, type Provider } from "@opencode-ai/sdk/v2" import { TuiConfig, type Resolved } from "@/cli/cmd/tui/config/tui" -import { formatBindings } from "@/cli/cmd/run/keymap.shared" -import { resolveDiffStyle, resolveFooterKeybinds, resolveModelInfo } from "@/cli/cmd/run/runtime.boot" +import { resolveDiffStyle, resolveModelInfo, resolveRunTuiConfig } from "@/cli/cmd/run/runtime.boot" import { createTuiResolvedConfig } from "../../fixture/tui-runtime" function model(id: string, providerID: string, context: number, variants?: Record>) { @@ -111,35 +110,44 @@ describe("run runtime boot", () => { }), ) - const result = await resolveFooterKeybinds() + const result = await resolveRunTuiConfig() - expect(result.leader).toBe("ctrl+g") - expect(result.leaderTimeout).toBe(2000) - expect(formatBindings(result.commandList, result.leader)).toBe("ctrl+p") - expect(formatBindings(result.variantCycle, result.leader)).toBe("ctrl+t, alt+t") - expect(formatBindings(result.interrupt, result.leader)).toBe("ctrl+c") - expect(formatBindings(result.historyPrevious, result.leader)).toBe("k") - expect(formatBindings(result.historyNext, result.leader)).toBe("j") - expect(formatBindings(result.inputClear, result.leader)).toBe("ctrl+l") - expect(formatBindings(result.inputSubmit, result.leader)).toBe("ctrl+s") - expect(formatBindings(result.inputNewline, result.leader)).toBe("alt+return") + expect(result.keybinds.get("leader")?.[0]?.key).toBe("ctrl+g") + expect(result.leader_timeout).toBe(2000) + expect(result.keybinds.get("command.palette.show")?.[0]?.key).toBe("ctrl+p") + expect(result.keybinds.get("variant.cycle").map((item) => item.key)).toEqual(["ctrl+t", "alt+t"]) + expect(result.keybinds.get("session.interrupt")?.[0]?.key).toBe("ctrl+c") + expect(result.keybinds.get("prompt.history.previous")?.[0]?.key).toBe("k") + expect(result.keybinds.get("prompt.history.next")?.[0]?.key).toBe("j") + expect(result.keybinds.get("prompt.clear")?.[0]?.key).toBe("ctrl+l") + expect(result.keybinds.get("input.submit")?.[0]?.key).toBe("ctrl+s") + expect(result.keybinds.get("input.newline")?.[0]?.key).toBe("alt+return") }) - test("falls back to default keybinds when config load fails", async () => { + test("falls back to default tui keymap config when config load fails", async () => { spyOn(TuiConfig, "get").mockRejectedValue(new Error("boom")) - const result = await resolveFooterKeybinds() + const result = await resolveRunTuiConfig() - expect(result.leader).toBe("ctrl+x") - expect(result.leaderTimeout).toBe(2000) - expect(formatBindings(result.commandList, result.leader)).toBe("ctrl+p") - expect(formatBindings(result.variantCycle, result.leader)).toBe("ctrl+t") - expect(formatBindings(result.interrupt, result.leader)).toBe("esc") - expect(formatBindings(result.historyPrevious, result.leader)).toBe("up") - expect(formatBindings(result.historyNext, result.leader)).toBe("down") - expect(formatBindings(result.inputClear, result.leader)).toBe("ctrl+c") - expect(formatBindings(result.inputSubmit, result.leader)).toBe("return") - expect(formatBindings(result.inputNewline, result.leader)).toBe("shift+return, ctrl+return, alt+return, ctrl+j") + expect(result.keybinds.get("leader")?.[0]?.key).toBe("ctrl+x") + expect(result.leader_timeout).toBe(2000) + expect(result.diff_style).toBe("auto") + expect(result.keybinds.get("command.palette.show")?.[0]?.key).toBe("ctrl+p") + expect(result.keybinds.get("variant.cycle")?.[0]?.key).toBe("ctrl+t") + expect(result.keybinds.get("session.interrupt")?.[0]?.key).toBe("escape") + expect(result.keybinds.get("prompt.history.previous")?.[0]?.key).toBe("up") + expect(result.keybinds.get("prompt.history.next")?.[0]?.key).toBe("down") + expect(result.keybinds.get("prompt.clear")?.[0]?.key).toBe("ctrl+c") + expect(result.keybinds.get("input.submit")?.[0]?.key).toBe("return") + expect(result.keybinds.get("input.newline")?.[0]?.key).toBe("shift+return,ctrl+return,alt+return,ctrl+j") + }) + + test("preserves disabled leader from resolved tui config", async () => { + spyOn(TuiConfig, "get").mockResolvedValue(config({ leader: "none" })) + + const result = await resolveRunTuiConfig() + + expect(result.keybinds.get("leader")).toEqual([]) }) test("reads diff style and falls back to auto", async () => { diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 68f5c56d04..bcbf22459e 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -22,9 +22,9 @@ "zod": "catalog:" }, "peerDependencies": { - "@opentui/core": ">=0.2.16", - "@opentui/keymap": ">=0.2.16", - "@opentui/solid": ">=0.2.16" + "@opentui/core": ">=0.3.0", + "@opentui/keymap": ">=0.3.0", + "@opentui/solid": ">=0.3.0" }, "peerDependenciesMeta": { "@opentui/core": { From 6f07f10dd27e358b3428279f8df3a0d61dfe8b93 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sun, 31 May 2026 08:59:27 +0000 Subject: [PATCH 032/840] chore: generate --- .../opencode/src/cli/cmd/run/footer.view.tsx | 37 +++++--- packages/opencode/src/cli/cmd/run/runtime.ts | 6 +- packages/opencode/src/cli/cmd/tui/keymap.tsx | 5 +- .../test/cli/run/footer.view.test.tsx | 94 +++++++++---------- 4 files changed, 71 insertions(+), 71 deletions(-) diff --git a/packages/opencode/src/cli/cmd/run/footer.view.tsx b/packages/opencode/src/cli/cmd/run/footer.view.tsx index 1989f5f70f..bd37c1b211 100644 --- a/packages/opencode/src/cli/cmd/run/footer.view.tsx +++ b/packages/opencode/src/cli/cmd/run/footer.view.tsx @@ -155,23 +155,30 @@ export function RunFooterView(props: RunFooterViewProps) { const current = route() return current.type === "subagent" ? subagent().details[current.sessionID] : undefined }) - const command = useKeymapSelector((keymap: OpenTuiKeymap) => - formatKeyBindings( - keymap.getCommandBindings({ visibility: "registered", commands: ["command.palette.show"] }).get("command.palette.show"), - props.tuiConfig, - ) ?? "", + const command = useKeymapSelector( + (keymap: OpenTuiKeymap) => + formatKeyBindings( + keymap + .getCommandBindings({ visibility: "registered", commands: ["command.palette.show"] }) + .get("command.palette.show"), + props.tuiConfig, + ) ?? "", ) - const interrupt = useKeymapSelector((keymap: OpenTuiKeymap) => - formatKeyBindings( - keymap.getCommandBindings({ visibility: "registered", commands: ["session.interrupt"] }).get("session.interrupt"), - props.tuiConfig, - ) ?? "", + const interrupt = useKeymapSelector( + (keymap: OpenTuiKeymap) => + formatKeyBindings( + keymap + .getCommandBindings({ visibility: "registered", commands: ["session.interrupt"] }) + .get("session.interrupt"), + props.tuiConfig, + ) ?? "", ) - const variantCycle = useKeymapSelector((keymap: OpenTuiKeymap) => - formatKeyBindings( - keymap.getCommandBindings({ visibility: "registered", commands: ["variant.cycle"] }).get("variant.cycle"), - props.tuiConfig, - ) ?? "", + const variantCycle = useKeymapSelector( + (keymap: OpenTuiKeymap) => + formatKeyBindings( + keymap.getCommandBindings({ visibility: "registered", commands: ["variant.cycle"] }).get("variant.cycle"), + props.tuiConfig, + ) ?? "", ) const hints = createMemo(() => hintFlags(term().width)) const busy = createMemo(() => props.state().phase === "running") diff --git a/packages/opencode/src/cli/cmd/run/runtime.ts b/packages/opencode/src/cli/cmd/run/runtime.ts index ae4d3ad267..2e2b07398e 100644 --- a/packages/opencode/src/cli/cmd/run/runtime.ts +++ b/packages/opencode/src/cli/cmd/run/runtime.ts @@ -185,11 +185,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise { variant: undefined, }) const savedTask = resolveSavedVariant(ctx.model) - const [tuiConfig, session, savedVariant] = await Promise.all([ - tuiConfigTask, - sessionTask, - savedTask, - ]) + const [tuiConfig, session, savedVariant] = await Promise.all([tuiConfigTask, sessionTask, savedTask]) const state: RuntimeState = { shown: !session.first, aborting: false, diff --git a/packages/opencode/src/cli/cmd/tui/keymap.tsx b/packages/opencode/src/cli/cmd/tui/keymap.tsx index cd46e8ccc7..461b204a20 100644 --- a/packages/opencode/src/cli/cmd/tui/keymap.tsx +++ b/packages/opencode/src/cli/cmd/tui/keymap.tsx @@ -196,10 +196,7 @@ export function formatKeySequence(parts: Parameters[0], - config: FormatConfig, -) { +export function formatKeyBindings(bindings: Parameters[0], config: FormatConfig) { return formatCommandBindingsExtra(bindings, formatOptions(config)) } diff --git a/packages/opencode/test/cli/run/footer.view.test.tsx b/packages/opencode/test/cli/run/footer.view.test.tsx index d20707e5b5..214f4637d3 100644 --- a/packages/opencode/test/cli/run/footer.view.test.tsx +++ b/packages/opencode/test/cli/run/footer.view.test.tsx @@ -178,18 +178,18 @@ async function renderFooter(input: { tuiConfig?: RunTuiConfig; onCycle?: () => v tuiConfig={config} agent="opencode" onSubmit={() => true} - onPermissionReply={() => { }} - onQuestionReply={() => { }} - onQuestionReject={() => { }} - onCycle={input.onCycle ?? (() => { })} + onPermissionReply={() => {}} + onQuestionReply={() => {}} + onQuestionReject={() => {}} + onCycle={input.onCycle ?? (() => {})} onInterrupt={() => false} - onInputClear={() => { }} - onExit={() => { }} - onModelSelect={() => { }} - onVariantSelect={() => { }} - onRows={() => { }} - onLayout={() => { }} - onStatus={() => { }} + onInputClear={() => {}} + onExit={() => {}} + onModelSelect={() => {}} + onVariantSelect={() => {}} + onRows={() => {}} + onLayout={() => {}} + onStatus={() => {}} /> ) @@ -278,14 +278,14 @@ test("direct command panel renders grouped command palette", async () => { subagents={subagents} variants={variants} variantCycle="ctrl+t" - onClose={() => { }} - onModel={() => { }} - onSubagent={() => { }} - onVariant={() => { }} - onVariantCycle={() => { }} - onCommand={() => { }} - onNew={() => { }} - onExit={() => { }} + onClose={() => {}} + onModel={() => {}} + onSubagent={() => {}} + onVariant={() => {}} + onVariantCycle={() => {}} + onCommand={() => {}} + onNew={() => {}} + onExit={() => {}} /> ), @@ -336,14 +336,14 @@ test("direct command panel shows subagent entry when available", async () => { subagents={subagents} variants={variants} variantCycle="ctrl+t" - onClose={() => { }} - onModel={() => { }} - onSubagent={() => { }} - onVariant={() => { }} - onVariantCycle={() => { }} - onCommand={() => { }} - onNew={() => { }} - onExit={() => { }} + onClose={() => {}} + onModel={() => {}} + onSubagent={() => {}} + onVariant={() => {}} + onVariantCycle={() => {}} + onCommand={() => {}} + onNew={() => {}} + onExit={() => {}} /> ), @@ -379,8 +379,8 @@ test("direct subagent panel renders active subagents", async () => { theme={() => RUN_THEME_FALLBACK.footer} tabs={tabs} current={current} - onClose={() => { }} - onSelect={() => { }} + onClose={() => {}} + onSelect={() => {}} onRows={(value) => { rows = value }} @@ -506,18 +506,18 @@ test("direct footer shows subagent indicator while prompt is running", async () tuiConfig={tuiConfig} agent="opencode" onSubmit={() => true} - onPermissionReply={() => { }} - onQuestionReply={() => { }} - onQuestionReject={() => { }} - onCycle={() => { }} + onPermissionReply={() => {}} + onQuestionReply={() => {}} + onQuestionReject={() => {}} + onCycle={() => {}} onInterrupt={() => false} - onInputClear={() => { }} - onExit={() => { }} - onModelSelect={() => { }} - onVariantSelect={() => { }} - onRows={() => { }} - onLayout={() => { }} - onStatus={() => { }} + onInputClear={() => {}} + onExit={() => {}} + onModelSelect={() => {}} + onVariantSelect={() => {}} + onRows={() => {}} + onLayout={() => {}} + onStatus={() => {}} /> ) @@ -572,7 +572,7 @@ test("direct question body separates single-select checkmark from label", async onReply={(input) => { replies.push(input) }} - onReject={() => { }} + onReject={() => {}} /> ), @@ -622,7 +622,7 @@ test("direct custom answer submits through keymap return binding", async () => { onReply={(input) => { questions.push(input) }} - onReject={() => { }} + onReject={() => {}} /> ) @@ -676,7 +676,7 @@ test("direct permission rejection submits through keymap return binding", async onConfirm={() => { submits.push(text) }} - onCancel={() => { }} + onCancel={() => {}} /> ) @@ -718,8 +718,8 @@ test("direct model panel renders current model selector", async () => { theme={() => RUN_THEME_FALLBACK.footer} providers={providers} current={current} - onClose={() => { }} - onSelect={() => { }} + onClose={() => {}} + onSelect={() => {}} /> ), @@ -757,8 +757,8 @@ test("direct variant panel renders current variant selector", async () => { theme={() => RUN_THEME_FALLBACK.footer} variants={variants} current={current} - onClose={() => { }} - onSelect={() => { }} + onClose={() => {}} + onSelect={() => {}} /> ), From f401f01c05bead2fd0687004c912743d271e2b7b Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sun, 31 May 2026 09:18:58 +0000 Subject: [PATCH 033/840] chore: update nix node_modules hashes --- nix/hashes.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/nix/hashes.json b/nix/hashes.json index 4dcdf1018f..21f9e60e22 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-QKwLTvlWz6HF/TF+ztsIXp0GqT9+K8fq0ppXSk6wiW0=", - "aarch64-linux": "sha256-dokuA/VIwbsDFaWrKJRHhUEEl3184wW8Z6Mf21fOdXQ=", - "aarch64-darwin": "sha256-j7Bhh0qUoAYEL4cuHehPEg8ikgvYLpf+KzNSMdqXuVM=", - "x86_64-darwin": "sha256-oXuUHLVitqw8gjesrHz/G9sEgtRLwKxEALGSca28l+8=" + "x86_64-linux": "sha256-3OI8L9IT1XFN44CO3YEgHy/zKqVNhzkJjW1P2+9X4lU=", + "aarch64-linux": "sha256-YZSeZIUBQCII3+dLwKhvKv4RKHwAkLSnvGXXEHbmjeQ=", + "aarch64-darwin": "sha256-8CsowndXBpFDTd2RNSMh7xaULwLoz3Lu6kfqkacOk2s=", + "x86_64-darwin": "sha256-pWyjVHZVBjcGvVU9AoSR4nhuMNKIHKJK5faihD8UzhQ=" } } From 331bed2469e85da9d6b27bda4f517d5ddeb8ead1 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Sun, 31 May 2026 19:28:09 +0530 Subject: [PATCH 034/840] fix(core): stabilize migration registry generation (#30105) --- packages/core/script/migration.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/core/script/migration.ts b/packages/core/script/migration.ts index a74d39f4ed..5a8fb6451f 100644 --- a/packages/core/script/migration.ts +++ b/packages/core/script/migration.ts @@ -113,8 +113,10 @@ function escapeTemplate(line: string) { function renderRegistry(names: string[]) { return `import type { DatabaseMigration } from "./migration" -export const migrations = (await Promise.all([ -${names.map((name) => ` import("./migration/${name}"),`).join("\n")} -])).map((module) => module.default) satisfies DatabaseMigration.Migration[] +export const migrations = ( + await Promise.all([ +${names.map((name) => ` import("./migration/${name}"),`).join("\n")} + ]) +).map((module) => module.default) satisfies DatabaseMigration.Migration[] ` } From 5661af203487b90cf9ee0844b198b03cce26c412 Mon Sep 17 00:00:00 2001 From: Dax Date: Sun, 31 May 2026 11:57:44 -0400 Subject: [PATCH 035/840] feat(worktree): add managed workspace cloning (#30117) --- packages/cli/package.json | 1 + packages/cli/src/debug/agents.ts | 8 +- packages/core/src/auth.ts | 14 + packages/core/src/catalog.ts | 11 +- packages/core/src/model.ts | 6 +- packages/core/src/plugin.ts | 9 +- packages/core/src/plugin/account.ts | 4 +- packages/core/src/policy.ts | 2 + packages/core/src/provider.ts | 6 +- packages/core/src/state.ts | 23 +- packages/core/test/location-layer.test.ts | 25 +- .../routes/instance/httpapi/groups/global.ts | 2 + .../test/server/httpapi-global.test.ts | 6 +- packages/worktree/.gitignore | 1 + packages/worktree/Cargo.lock | 992 ++++++++++++++++++ packages/worktree/Cargo.toml | 19 + packages/worktree/crates/cli/Cargo.toml | 13 + packages/worktree/crates/cli/src/main.rs | 79 ++ packages/worktree/crates/core/Cargo.toml | 17 + packages/worktree/crates/core/src/copy.rs | 195 ++++ packages/worktree/crates/core/src/git.rs | 75 ++ packages/worktree/crates/core/src/lib.rs | 712 +++++++++++++ packages/worktree/specs.md | 182 ++++ 23 files changed, 2374 insertions(+), 28 deletions(-) create mode 100644 packages/worktree/.gitignore create mode 100644 packages/worktree/Cargo.lock create mode 100644 packages/worktree/Cargo.toml create mode 100644 packages/worktree/crates/cli/Cargo.toml create mode 100644 packages/worktree/crates/cli/src/main.rs create mode 100644 packages/worktree/crates/core/Cargo.toml create mode 100644 packages/worktree/crates/core/src/copy.rs create mode 100644 packages/worktree/crates/core/src/git.rs create mode 100644 packages/worktree/crates/core/src/lib.rs create mode 100644 packages/worktree/specs.md diff --git a/packages/cli/package.json b/packages/cli/package.json index c85fe53d10..8221953562 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -9,6 +9,7 @@ "opencode": "./src/index.ts" }, "scripts": { + "build": "bun run script/build.ts", "dev": "bun run src/index.ts", "typecheck": "tsgo --noEmit" }, diff --git a/packages/cli/src/debug/agents.ts b/packages/cli/src/debug/agents.ts index 5bef47c3ce..106d8f5163 100644 --- a/packages/cli/src/debug/agents.ts +++ b/packages/cli/src/debug/agents.ts @@ -8,8 +8,12 @@ import { AbsolutePath } from "@opencode-ai/core/schema" export const AgentsCommand = Command.make("agents", {}, () => Effect.gen(function* () { - yield* PluginBoot.Service.use((service) => service.wait()) - const agents = yield* AgentV2.Service.use((service) => service.all()) + const svc = { + plugin: yield* PluginBoot.Service, + agent: yield* AgentV2.Service, + } + yield* svc.plugin.wait() + const agents = yield* svc.agent.all() process.stdout.write( JSON.stringify( agents.sort((a, b) => a.id.localeCompare(b.id)), diff --git a/packages/core/src/auth.ts b/packages/core/src/auth.ts index 916bef9d17..a3c97bc8e5 100644 --- a/packages/core/src/auth.ts +++ b/packages/core/src/auth.ts @@ -122,6 +122,7 @@ export interface Interface { readonly remove: (id: ID) => Effect.Effect readonly activate: (id: ID) => Effect.Effect readonly active: (serviceID: ServiceID) => Effect.Effect + readonly activeAll: () => Effect.Effect, Error> readonly forService: (serviceID: ServiceID) => Effect.Effect } @@ -216,6 +217,19 @@ export const layer = Layer.effect( ) }), + activeAll: Effect.fn("Auth.activeAll")(function* () { + const data = yield* SynchronizedRef.get(state) + const result = new Map() + for (const account of Object.values(data.accounts)) { + if (!result.has(account.serviceID)) result.set(account.serviceID, account) + } + for (const [serviceID, id] of Object.entries(data.active)) { + const account = data.accounts[id] + if (account) result.set(ServiceID.make(serviceID), account) + } + return result + }), + forService: Effect.fn("Auth.list")(function* (serviceID) { return Object.values((yield* SynchronizedRef.get(state)).accounts).filter((a) => a.serviceID === serviceID) }), diff --git a/packages/core/src/catalog.ts b/packages/core/src/catalog.ts index d318544854..5b53b92bd8 100644 --- a/packages/core/src/catalog.ts +++ b/packages/core/src/catalog.ts @@ -166,8 +166,14 @@ export const layer = Layer.effect( model: { get: (providerID, modelID) => draft.providers.get(providerID)?.models.get(modelID), update: (providerID, modelID, fn) => { - result.provider.update(providerID, () => {}) - const record = draft.providers.get(providerID)! + let record = draft.providers.get(providerID) + if (!record) { + record = castDraft({ + provider: ProviderV2.Info.empty(providerID), + models: new Map(), + }) + draft.providers.set(providerID, record) + } const model = record.models.get(modelID) ?? castDraft(ModelV2.Info.empty(providerID, modelID)) if (!record.models.has(modelID)) record.models.set(modelID, model) fn(model) @@ -190,6 +196,7 @@ export const layer = Layer.effect( }, finalize: Effect.fn("CatalogV2.finalize")(function* (catalog, reason) { if (reason !== "plugin.added") yield* plugin.trigger("catalog.transform", catalog, {}).pipe(Effect.asVoid) + if (!policy.hasStatements()) return for (const record of [...catalog.provider.list()]) { if ((yield* policy.evaluate("provider.use", record.provider.id, "allow")) === "deny") { catalog.provider.remove(record.provider.id) diff --git a/packages/core/src/model.ts b/packages/core/src/model.ts index 8cf02ddfe0..b0de0802a3 100644 --- a/packages/core/src/model.ts +++ b/packages/core/src/model.ts @@ -68,8 +68,8 @@ export class Info extends Schema.Class("ModelV2.Info")({ output: Schema.Int, }), }) { - static empty(providerID: ProviderV2.ID, modelID: ID) { - return new Info({ + static empty(providerID: ProviderV2.ID, modelID: ID): Info { + return { id: modelID, apiID: modelID, providerID, @@ -101,7 +101,7 @@ export class Info extends Schema.Class("ModelV2.Info")({ context: 0, output: 0, }, - }) + } } } diff --git a/packages/core/src/plugin.ts b/packages/core/src/plugin.ts index 1d854f18f5..7297ef0f3e 100644 --- a/packages/core/src/plugin.ts +++ b/packages/core/src/plugin.ts @@ -111,7 +111,14 @@ export const layer = Layer.effect( const existing = hooks.find((item) => item.id === input.id) if (existing) yield* Scope.close(existing.scope, Exit.void).pipe(Effect.ignore) const scope = yield* Scope.make() - const result = yield* input.effect.pipe(Scope.provide(scope)) + const result = yield* input.effect.pipe( + Scope.provide(scope), + Effect.withSpan("Plugin.load", { + attributes: { + "plugin.id": input.id, + }, + }), + ) hooks = [ ...hooks.filter((item) => item.id !== input.id), { diff --git a/packages/core/src/plugin/account.ts b/packages/core/src/plugin/account.ts index 56b544480d..68bb43674d 100644 --- a/packages/core/src/plugin/account.ts +++ b/packages/core/src/plugin/account.ts @@ -21,8 +21,10 @@ export const AccountPlugin = PluginV2.define({ return { "catalog.transform": Effect.fn(function* (evt) { + const active = yield* accounts.activeAll().pipe(Effect.orDie) + if (active.size === 0) return for (const item of evt.provider.list()) { - const account = yield* accounts.active(Auth.ServiceID.make(item.provider.id)).pipe(Effect.orDie) + const account = active.get(Auth.ServiceID.make(item.provider.id)) if (!account) continue evt.provider.update(item.provider.id, (provider) => { provider.enabled = { diff --git a/packages/core/src/policy.ts b/packages/core/src/policy.ts index 20367f4d98..9b7438f4ff 100644 --- a/packages/core/src/policy.ts +++ b/packages/core/src/policy.ts @@ -16,6 +16,7 @@ export class Info extends Schema.Class("Policy.Info")({ export interface Interface { readonly load: (statements: Info[]) => EffectRuntime.Effect readonly evaluate: (action: string, resource: string, fallback: Effect) => EffectRuntime.Effect + readonly hasStatements: () => boolean } export class Service extends Context.Service()("@opencode/v2/Policy") {} @@ -30,6 +31,7 @@ export const layer = Layer.effect( load: EffectRuntime.fn("Policy.load")(function* (input) { statements = input }), + hasStatements: () => statements.length > 0, evaluate: EffectRuntime.fn("Policy.evaluate")(function* (action, resource, fallback) { return ( statements.findLast( diff --git a/packages/core/src/provider.ts b/packages/core/src/provider.ts index 1c237d3ecf..31127f2f3c 100644 --- a/packages/core/src/provider.ts +++ b/packages/core/src/provider.ts @@ -101,8 +101,8 @@ export class Info extends Schema.Class("ProviderV2.Info")({ endpoint: Endpoint, options: Options, }) { - static empty(providerID: ID) { - return new Info({ + static empty(providerID: ID): Info { + return { id: providerID, name: providerID, enabled: false, @@ -118,6 +118,6 @@ export class Info extends Schema.Class("ProviderV2.Info")({ request: {}, }, }, - }) + } } } diff --git a/packages/core/src/state.ts b/packages/core/src/state.ts index b764699e08..aa3ab0e240 100644 --- a/packages/core/src/state.ts +++ b/packages/core/src/state.ts @@ -1,7 +1,7 @@ export * as State from "./state" import { Effect, Scope, Semaphore } from "effect" -import { createDraft, finishDraft, type Draft, type Objectish } from "immer" +import type { Draft, Objectish } from "immer" export type Transform = (editor: Editor) => void export type MakeEditor = (draft: Draft) => Editor @@ -24,17 +24,18 @@ export function create(options: Options }[] = [] const semaphore = Semaphore.makeUnsafe(1) - const commit = Effect.fn("State.commit")(function* (draft: Draft, reason?: string) { - const api = options.editor(draft) + const commit = Effect.fn("State.commit")(function* (next: State, reason?: string) { + const api = options.editor(next as Draft) if (options.finalize) yield* options.finalize(api, reason) - state = finishDraft(draft) as State + state = next }) const rebuild = Effect.fn("State.rebuild")(function* () { - const draft = createDraft(options.initial()) - const api = options.editor(draft) - for (const transform of transforms) transform.update(api) - yield* commit(draft) + const next = options.initial() + const api = options.editor(next as Draft) + for (const transform of transforms) + yield* Effect.sync(() => transform.update(api)).pipe(Effect.withSpan("State.rebuild.update", {})) + yield* commit(next) }, semaphore.withPermit) return { @@ -55,9 +56,9 @@ export function create(options: Options) + yield* update(api) + if (options.finalize) yield* options.finalize(api, reason) }, semaphore.withPermit), } } diff --git a/packages/core/test/location-layer.test.ts b/packages/core/test/location-layer.test.ts index 72c735f6c9..19ab931212 100644 --- a/packages/core/test/location-layer.test.ts +++ b/packages/core/test/location-layer.test.ts @@ -1,7 +1,7 @@ import fs from "fs/promises" import path from "path" import { describe, expect } from "bun:test" -import { Effect } from "effect" +import { Effect, Layer } from "effect" import { Catalog } from "@opencode-ai/core/catalog" import { LocationServiceMap } from "@opencode-ai/core/location-layer" import { PluginBoot } from "@opencode-ai/core/plugin/boot" @@ -9,8 +9,29 @@ import { ProviderV2 } from "@opencode-ai/core/provider" import { AbsolutePath } from "@opencode-ai/core/schema" import { tmpdir } from "./fixture/tmpdir" import { testEffect } from "./lib/effect" +import { AppFileSystem } from "../src/filesystem" +import { Auth } from "../src/auth" +import { EventV2 } from "../src/event" +import { Global } from "../src/global" +import { ModelsDev } from "../src/models-dev" +import { Npm } from "../src/npm" +import { Project } from "../src/project" -const it = testEffect(LocationServiceMap.layer) +const it = testEffect( + LocationServiceMap.layer.pipe( + Layer.provide( + Layer.mergeAll( + Project.defaultLayer, + EventV2.defaultLayer, + Auth.defaultLayer, + Npm.defaultLayer, + ModelsDev.defaultLayer, + AppFileSystem.defaultLayer, + Global.defaultLayer, + ), + ), + ), +) describe("LocationServiceMap", () => { it.live("isolates location state while sharing location policy with catalog", () => diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/global.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/global.ts index 56c741df19..fa9995ee4c 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/global.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/global.ts @@ -1,6 +1,8 @@ import { Config } from "@/config/config" import { EventV2 } from "@opencode-ai/core/event" import { InstanceDisposed } from "@/server/event" +import "@opencode-ai/core/account" +import "@/server/event" import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" import { described } from "./metadata" diff --git a/packages/opencode/test/server/httpapi-global.test.ts b/packages/opencode/test/server/httpapi-global.test.ts index 7cb8ec2dc4..a13792803c 100644 --- a/packages/opencode/test/server/httpapi-global.test.ts +++ b/packages/opencode/test/server/httpapi-global.test.ts @@ -19,6 +19,9 @@ const apiLayer = HttpRouter.serve( HttpApiBuilder.layer(RootHttpApi).pipe( Layer.provide([controlHandlers, globalHandlers]), Layer.provide([authorizationLayer, schemaErrorLayer]), + // Raw HttpApi routes expose an opaque handler context at the request boundary. + // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion + HttpRouter.provideRequest(Layer.succeedContext(Context.empty() as Context.Context)), ), { disableListenLog: true, disableLogger: true }, ).pipe( @@ -33,9 +36,6 @@ const apiLayer = HttpRouter.serve( }), ), Layer.provide(ServerAuth.Config.layer({ password: Option.none(), username: "opencode" })), - // Raw HttpApi routes expose an opaque handler context at the web boundary. - // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion - Layer.provide(Layer.succeedContext(Context.empty() as Context.Context)), ) const it = testEffect(apiLayer) diff --git a/packages/worktree/.gitignore b/packages/worktree/.gitignore new file mode 100644 index 0000000000..b83d22266a --- /dev/null +++ b/packages/worktree/.gitignore @@ -0,0 +1 @@ +/target/ diff --git a/packages/worktree/Cargo.lock b/packages/worktree/Cargo.lock new file mode 100644 index 0000000000..f446253de2 --- /dev/null +++ b/packages/worktree/Cargo.lock @@ -0,0 +1,992 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "cc" +version = "1.2.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libredox" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" +dependencies = [ + "libc", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "947e6816f7825b2b45027c2c32e7085da9934defa535de4a6a46b10a4d5257fa" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "log" +version = "0.4.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5" + +[[package]] +name = "memchr" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror", +] + +[[package]] +name = "rusqlite" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a22715a5d6deef63c637207afbe68d0c72c3f8d0022d7cf9714c442d6157606b" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "ulid" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "470dbf6591da1b39d43c14523b2b469c86879a53e8b758c8e090a470fe7b1fbe" +dependencies = [ + "rand", + "web-time", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "worktree" +version = "0.1.0" +dependencies = [ + "dirs", + "filetime", + "libc", + "rusqlite", + "tempfile", + "thiserror", + "ulid", + "walkdir", +] + +[[package]] +name = "worktree-cli" +version = "0.1.0" +dependencies = [ + "clap", + "worktree", +] + +[[package]] +name = "zerocopy" +version = "0.8.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/packages/worktree/Cargo.toml b/packages/worktree/Cargo.toml new file mode 100644 index 0000000000..e30a9d16bc --- /dev/null +++ b/packages/worktree/Cargo.toml @@ -0,0 +1,19 @@ +[workspace] +resolver = "2" +members = ["crates/core", "crates/cli"] + +[workspace.package] +edition = "2024" +license = "MIT" +version = "0.1.0" + +[workspace.dependencies] +clap = { version = "4.5", features = ["derive"] } +dirs = "6.0" +filetime = "0.2" +libc = "0.2" +rusqlite = { version = "0.35", features = ["bundled"] } +tempfile = "3.20" +thiserror = "2.0" +ulid = "1.2" +walkdir = "2.5" diff --git a/packages/worktree/crates/cli/Cargo.toml b/packages/worktree/crates/cli/Cargo.toml new file mode 100644 index 0000000000..e19bd5089a --- /dev/null +++ b/packages/worktree/crates/cli/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "worktree-cli" +version.workspace = true +edition.workspace = true +license.workspace = true + +[[bin]] +name = "worktree" +path = "src/main.rs" + +[dependencies] +clap.workspace = true +worktree = { path = "../core" } diff --git a/packages/worktree/crates/cli/src/main.rs b/packages/worktree/crates/cli/src/main.rs new file mode 100644 index 0000000000..03acc84b72 --- /dev/null +++ b/packages/worktree/crates/cli/src/main.rs @@ -0,0 +1,79 @@ +use clap::{Parser, Subcommand}; +use std::path::PathBuf; +use worktree::{Create, Link, Manager}; + +#[derive(Parser)] +#[command(name = "worktree")] +struct Cli { + #[arg(long, hide = true)] + database: Option, + #[command(subcommand)] + command: Command, +} + +#[derive(Subcommand)] +enum Command { + Create { + from: Option, + #[arg(long)] + name: Option, + #[arg(long)] + into: Option, + }, + Remove { + at: PathBuf, + }, + Link { + at: PathBuf, + #[arg(long)] + to: Option, + }, + Children { + of: PathBuf, + }, + Ancestors { + of: PathBuf, + }, +} + +fn main() { + if let Err(error) = run() { + eprintln!("worktree: {error}"); + std::process::exit(1); + } +} + +fn run() -> worktree::Result<()> { + let cli = Cli::parse(); + let mut manager = match cli.database { + Some(path) => Manager::open(path)?, + None => Manager::open_default()?, + }; + match cli.command { + Command::Create { from, name, into } => { + println!( + "{}", + manager + .create(Create { + from: from.unwrap_or(std::env::current_dir()?), + name, + into, + })? + .display() + ); + } + Command::Remove { at } => manager.remove(at)?, + Command::Link { at, to } => manager.link(Link { at, to })?, + Command::Children { of } => { + for path in manager.children(of)? { + println!("{}", path.display()); + } + } + Command::Ancestors { of } => { + for path in manager.ancestors(of)? { + println!("{}", path.display()); + } + } + } + Ok(()) +} diff --git a/packages/worktree/crates/core/Cargo.toml b/packages/worktree/crates/core/Cargo.toml new file mode 100644 index 0000000000..be96fc46aa --- /dev/null +++ b/packages/worktree/crates/core/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "worktree" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +dirs.workspace = true +filetime.workspace = true +libc.workspace = true +rusqlite.workspace = true +thiserror.workspace = true +ulid.workspace = true +walkdir.workspace = true + +[dev-dependencies] +tempfile.workspace = true diff --git a/packages/worktree/crates/core/src/copy.rs b/packages/worktree/crates/core/src/copy.rs new file mode 100644 index 0000000000..fa08c747c1 --- /dev/null +++ b/packages/worktree/crates/core/src/copy.rs @@ -0,0 +1,195 @@ +use crate::{Error, Result}; +#[cfg(target_os = "linux")] +use filetime::{FileTime, set_file_times}; +use std::fs; +#[cfg(target_os = "linux")] +use std::fs::{File, OpenOptions}; +use std::path::Path; +#[cfg(any(target_os = "linux", test))] +use walkdir::WalkDir; + +pub(crate) trait CopyStrategy { + fn copy_directory(&self, from: &Path, to: &Path) -> Result<()>; +} + +pub(crate) struct CowStrategy; + +impl CopyStrategy for CowStrategy { + fn copy_directory(&self, from: &Path, to: &Path) -> Result<()> { + #[cfg(target_os = "linux")] + return copy_directory_linux(from, to); + + #[cfg(target_os = "macos")] + return copy_directory_macos(from, to); + + #[cfg(not(any(target_os = "linux", target_os = "macos")))] + { + let _ = (from, to); + Err(Error::CowUnavailable( + "no copy-on-write strategy has been implemented for this platform".into(), + )) + } + } +} + +#[cfg(target_os = "macos")] +fn copy_directory_macos(from: &Path, to: &Path) -> Result<()> { + use std::ffi::CString; + use std::os::unix::ffi::OsStrExt; + + let source = CString::new(from.as_os_str().as_bytes()) + .map_err(|_| Error::Path(format!("path contains a null byte: {}", from.display())))?; + let destination = CString::new(to.as_os_str().as_bytes()) + .map_err(|_| Error::Path(format!("path contains a null byte: {}", to.display())))?; + let result = unsafe { libc::clonefile(source.as_ptr(), destination.as_ptr(), 0) }; + if result == 0 { + return Ok(()); + } + Err(Error::CowUnavailable(format!( + "failed to clone {}: {}", + from.display(), + std::io::Error::last_os_error() + ))) +} + +#[cfg(target_os = "linux")] +fn copy_directory_linux(from: &Path, to: &Path) -> Result<()> { + fs::create_dir(to)?; + fs::set_permissions(to, fs::metadata(from)?.permissions())?; + + let entries = WalkDir::new(from) + .min_depth(1) + .follow_links(false) + .into_iter() + .collect::, _>>()?; + + for entry in &entries { + let relative = entry + .path() + .strip_prefix(from) + .map_err(|error| Error::Path(error.to_string()))?; + let destination = to.join(relative); + let metadata = fs::symlink_metadata(entry.path())?; + if metadata.is_dir() { + fs::create_dir(&destination)?; + fs::set_permissions(&destination, metadata.permissions())?; + continue; + } + if metadata.is_symlink() { + copy_symlink(entry.path(), &destination)?; + continue; + } + if !metadata.is_file() { + return Err(Error::UnsupportedEntry(entry.path().to_path_buf())); + } + reflink_file(entry.path(), &destination)?; + fs::set_permissions(&destination, metadata.permissions())?; + set_file_times( + &destination, + FileTime::from_last_access_time(&metadata), + FileTime::from_last_modification_time(&metadata), + )?; + } + + for entry in entries + .iter() + .rev() + .filter(|entry| entry.file_type().is_dir()) + { + let destination = to.join( + entry + .path() + .strip_prefix(from) + .map_err(|error| Error::Path(error.to_string()))?, + ); + let metadata = fs::metadata(entry.path())?; + set_file_times( + &destination, + FileTime::from_last_access_time(&metadata), + FileTime::from_last_modification_time(&metadata), + )?; + } + + let metadata = fs::metadata(from)?; + set_file_times( + to, + FileTime::from_last_access_time(&metadata), + FileTime::from_last_modification_time(&metadata), + )?; + Ok(()) +} + +#[cfg(target_os = "linux")] +fn reflink_file(from: &Path, to: &Path) -> Result<()> { + use std::os::fd::AsRawFd; + + const FICLONE: libc::c_ulong = 0x4004_9409; + let source = File::open(from)?; + let destination = OpenOptions::new().write(true).create_new(true).open(to)?; + let result = unsafe { libc::ioctl(destination.as_raw_fd(), FICLONE, source.as_raw_fd()) }; + if result == 0 { + return Ok(()); + } + let error = std::io::Error::last_os_error(); + Err(Error::CowUnavailable(format!( + "failed to reflink {}: {}", + from.display(), + error + ))) +} + +#[cfg(unix)] +fn copy_symlink(from: &Path, to: &Path) -> Result<()> { + std::os::unix::fs::symlink(fs::read_link(from)?, to)?; + Ok(()) +} + +#[cfg(windows)] +fn copy_symlink(from: &Path, to: &Path) -> Result<()> { + let target = fs::read_link(from)?; + if fs::metadata(from)?.is_dir() { + std::os::windows::fs::symlink_dir(target, to)?; + return Ok(()); + } + std::os::windows::fs::symlink_file(target, to)?; + Ok(()) +} + +#[cfg(test)] +pub(crate) struct TestStrategy; + +#[cfg(test)] +impl CopyStrategy for TestStrategy { + fn copy_directory(&self, from: &Path, to: &Path) -> Result<()> { + fs::create_dir(to)?; + for entry in WalkDir::new(from).min_depth(1).follow_links(false) { + let entry = entry?; + let destination = to.join( + entry + .path() + .strip_prefix(from) + .map_err(|error| Error::Path(error.to_string()))?, + ); + if entry.file_type().is_dir() { + fs::create_dir(&destination)?; + continue; + } + if entry.file_type().is_symlink() { + copy_symlink(entry.path(), &destination)?; + continue; + } + fs::copy(entry.path(), destination)?; + } + Ok(()) + } +} + +#[cfg(test)] +pub(crate) struct FailureStrategy; + +#[cfg(test)] +impl CopyStrategy for FailureStrategy { + fn copy_directory(&self, _from: &Path, _to: &Path) -> Result<()> { + Err(Error::CowUnavailable("test failure".into())) + } +} diff --git a/packages/worktree/crates/core/src/git.rs b/packages/worktree/crates/core/src/git.rs new file mode 100644 index 0000000000..ed6769caa9 --- /dev/null +++ b/packages/worktree/crates/core/src/git.rs @@ -0,0 +1,75 @@ +use crate::{Error, Result}; +use std::fs; +use std::path::Path; +use std::process::Command; + +pub(crate) fn check_source(path: &Path) -> Result { + let git = path.join(".git"); + if !git.exists() { + return Ok(false); + } + if !git.is_dir() { + return Err(Error::UnsafeGit( + "linked Git worktree sources are not supported".into(), + )); + } + + for state in [ + "MERGE_HEAD", + "CHERRY_PICK_HEAD", + "REVERT_HEAD", + "BISECT_LOG", + "rebase-merge", + "rebase-apply", + "index.lock", + "HEAD.lock", + ] { + if git.join(state).exists() { + return Err(Error::UnsafeGit(format!("Git state in progress: {state}"))); + } + } + Ok(true) +} + +pub(crate) fn hide_marker(path: &Path) -> Result<()> { + let info = path.join(".git").join("info"); + fs::create_dir_all(&info)?; + let exclude = info.join("exclude"); + let existing = if exclude.exists() { + fs::read_to_string(&exclude)? + } else { + String::new() + }; + if existing.lines().any(|line| line.trim() == "/.worktree") { + return Ok(()); + } + let separator = if existing.is_empty() || existing.ends_with('\n') { + "" + } else { + "\n" + }; + fs::write(exclude, format!("{existing}{separator}/.worktree\n"))?; + Ok(()) +} + +pub(crate) fn detach_destination(path: &Path) -> Result<()> { + let head = Command::new("git") + .arg("-C") + .arg(path) + .args(["rev-parse", "--verify", "HEAD^{commit}"]) + .output()?; + if !head.status.success() { + return Ok(()); + } + let output = Command::new("git") + .arg("-C") + .arg(path) + .args(["switch", "--detach", "--quiet", "HEAD"]) + .output()?; + if output.status.success() { + return Ok(()); + } + Err(Error::UnsafeGit( + String::from_utf8_lossy(&output.stderr).trim().to_owned(), + )) +} diff --git a/packages/worktree/crates/core/src/lib.rs b/packages/worktree/crates/core/src/lib.rs new file mode 100644 index 0000000000..9914a14bd8 --- /dev/null +++ b/packages/worktree/crates/core/src/lib.rs @@ -0,0 +1,712 @@ +mod copy; +mod git; + +use copy::{CopyStrategy, CowStrategy}; +use rusqlite::{Connection, OptionalExtension, params}; +use std::fs; +use std::path::{Path, PathBuf}; +use thiserror::Error; +use ulid::Ulid; + +pub type Result = std::result::Result; + +#[derive(Debug, Error)] +pub enum Error { + #[error("{0}")] + Io(#[from] std::io::Error), + #[error("{0}")] + Database(#[from] rusqlite::Error), + #[error("{0}")] + Walk(#[from] walkdir::Error), + #[error("invalid path: {0}")] + Path(String), + #[error("copy-on-write cloning unavailable: {0}")] + CowUnavailable(String), + #[error("unsupported filesystem entry: {0}")] + UnsupportedEntry(PathBuf), + #[error("unsafe Git source: {0}")] + UnsafeGit(String), + #[error("worktree is not managed: {0}")] + NotManaged(PathBuf), + #[error("worktree marker does not match the registry at: {0}")] + MarkerMismatch(PathBuf), + #[error("worktree marker belongs to an unknown registry entry at: {0}")] + UnknownMarker(PathBuf), + #[error("worktree already exists: {0}")] + AlreadyExists(PathBuf), + #[error("cannot remove the original registered workspace: {0}")] + CannotRemoveRoot(PathBuf), + #[error("cannot reparent the original registered workspace: {0}")] + CannotLinkRoot(PathBuf), + #[error("cannot remove subtree while a recorded worktree path is missing: {0}")] + MissingWorktree(PathBuf), + #[error("cannot link a worktree to itself or its descendant")] + Cycle, + #[error("cannot copy a workspace into itself: {0}")] + InsideSource(PathBuf), +} + +pub struct Create { + pub from: PathBuf, + pub name: Option, + pub into: Option, +} + +pub struct Link { + pub at: PathBuf, + pub to: Option, +} + +#[derive(Clone)] +struct Record { + id: String, + parent_id: Option, + path: PathBuf, +} + +pub struct Manager { + database: Connection, + copier: Box, +} + +impl Manager { + pub fn open_default() -> Result { + let path = default_database_path()?; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + Self::open(path) + } + + pub fn open(path: impl AsRef) -> Result { + Self::with_copier(path, Box::new(CowStrategy)) + } + + fn with_copier(path: impl AsRef, copier: Box) -> Result { + let database = Connection::open(path)?; + database.execute_batch( + "PRAGMA foreign_keys = ON; + CREATE TABLE IF NOT EXISTS worktree ( + id TEXT PRIMARY KEY, + parent_id TEXT REFERENCES worktree(id) ON DELETE CASCADE, + path TEXT NOT NULL UNIQUE, + created_at INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS worktree_parent_id_idx ON worktree(parent_id);", + )?; + Ok(Self { database, copier }) + } + + pub fn create(&mut self, input: Create) -> Result { + let from = existing_directory(&input.from)?; + let git = git::check_source(&from)?; + let (source, register_source) = self.source(&from)?; + let root = self.root(&source)?; + let id = Ulid::new().to_string(); + let destination_parent = match input.into { + Some(path) => absolute_path(&path)?, + None => default_storage(&root.path)?, + }; + let name = destination_name(input.name, &id)?; + if destination_parent.join(&name).starts_with(&from) { + return Err(Error::InsideSource(destination_parent.join(name))); + } + fs::create_dir_all(&destination_parent)?; + let destination_parent = fs::canonicalize(destination_parent)?; + let destination = destination_parent.join(name); + if destination.starts_with(&from) { + return Err(Error::InsideSource(destination)); + } + if destination.exists() { + return Err(Error::AlreadyExists(destination)); + } + + if let Err(error) = self.copier.copy_directory(&from, &destination) { + let _ = fs::remove_dir_all(&destination); + return Err(error); + } + + let result = (|| { + write_marker(&destination, &id)?; + if git { + git::hide_marker(&destination)?; + git::detach_destination(&destination)?; + } + if register_source { + write_marker(&from, &source.id)?; + self.database.execute( + "INSERT INTO worktree (id, parent_id, path, created_at) VALUES (?1, NULL, ?2, ?3)", + params![source.id, path_text(&from)?, timestamp()], + )?; + } + if git { + git::hide_marker(&from)?; + } + self.database.execute( + "INSERT INTO worktree (id, parent_id, path, created_at) VALUES (?1, ?2, ?3, ?4)", + params![id, source.id, path_text(&destination)?, timestamp()], + )?; + Ok(destination.clone()) + })(); + if result.is_err() { + let _ = fs::remove_dir_all(&destination); + } + result + } + + pub fn remove(&mut self, at: impl AsRef) -> Result<()> { + let at = existing_directory(at.as_ref())?; + let record = self.record_at(&at)?; + if record.parent_id.is_none() { + return Err(Error::CannotRemoveRoot(at)); + } + verify_marker(&record)?; + let mut statement = self.database.prepare( + "WITH RECURSIVE subtree(id, path, depth) AS ( + SELECT id, path, 0 FROM worktree WHERE id = ?1 + UNION ALL + SELECT worktree.id, worktree.path, subtree.depth + 1 + FROM worktree JOIN subtree ON worktree.parent_id = subtree.id + ) SELECT id, path, depth FROM subtree ORDER BY depth DESC", + )?; + let rows = statement + .query_map([&record.id], |row| { + Ok(( + row.get::<_, String>(0)?, + PathBuf::from(row.get::<_, String>(1)?), + row.get::<_, i64>(2)?, + )) + })? + .collect::, _>>()?; + drop(statement); + for (id, path, _) in &rows { + if !path.exists() { + return Err(Error::MissingWorktree(path.clone())); + } + verify_marker(&Record { + id: id.clone(), + parent_id: None, + path: path.clone(), + })?; + } + for (id, path, _) in &rows { + fs::remove_dir_all(path)?; + self.database + .execute("DELETE FROM worktree WHERE id = ?1", [id])?; + } + Ok(()) + } + + pub fn link(&mut self, input: Link) -> Result<()> { + let at = existing_directory(&input.at)?; + let record = match read_marker(&at)? { + Some(id) => { + let record = self + .record_id(&id)? + .ok_or_else(|| Error::UnknownMarker(at.clone()))?; + if record.path != at { + if record.path.exists() { + return Err(Error::MarkerMismatch(at)); + } + self.database.execute( + "UPDATE worktree SET path = ?1 WHERE id = ?2", + params![path_text(&at)?, record.id], + )?; + } + Record { + path: at.clone(), + ..record + } + } + None => { + let record = self.record_at(&at)?; + write_marker(&at, &record.id)?; + record + } + }; + if at.join(".git").is_dir() { + git::hide_marker(&at)?; + } + let Some(to) = input.to else { + return Ok(()); + }; + if record.parent_id.is_none() { + return Err(Error::CannotLinkRoot(at)); + } + let parent = self.record_at(&existing_directory(&to)?)?; + if parent.id == record.id || self.is_descendant(&parent.id, &record.id)? { + return Err(Error::Cycle); + } + self.database.execute( + "UPDATE worktree SET parent_id = ?1 WHERE id = ?2", + params![parent.id, record.id], + )?; + Ok(()) + } + + pub fn children(&self, of: impl AsRef) -> Result> { + let record = self.record_at(&existing_directory(of.as_ref())?)?; + let mut statement = self + .database + .prepare("SELECT path FROM worktree WHERE parent_id = ?1 ORDER BY created_at, id")?; + Ok(statement + .query_map([record.id], |row| { + Ok(PathBuf::from(row.get::<_, String>(0)?)) + })? + .collect::, _>>()?) + } + + pub fn ancestors(&self, of: impl AsRef) -> Result> { + let record = self.record_at(&existing_directory(of.as_ref())?)?; + let mut paths = Vec::new(); + let mut parent_id = record.parent_id; + while let Some(id) = parent_id { + let parent = self + .record_id(&id)? + .ok_or_else(|| Error::NotManaged(record.path.clone()))?; + paths.push(parent.path); + parent_id = parent.parent_id; + } + Ok(paths) + } + + fn source(&self, path: &Path) -> Result<(Record, bool)> { + if let Some(id) = read_marker(path)? { + let record = self + .record_id(&id)? + .ok_or_else(|| Error::UnknownMarker(path.to_path_buf()))?; + if record.path != path { + return Err(Error::MarkerMismatch(path.to_path_buf())); + } + return Ok((record, false)); + } + if self.record_at_optional(path)?.is_some() { + return Err(Error::MarkerMismatch(path.to_path_buf())); + } + let id = Ulid::new().to_string(); + Ok(( + Record { + id, + parent_id: None, + path: path.to_path_buf(), + }, + true, + )) + } + + fn root(&self, record: &Record) -> Result { + let mut current = record.clone(); + while let Some(id) = current.parent_id.clone() { + current = self + .record_id(&id)? + .ok_or_else(|| Error::NotManaged(record.path.clone()))?; + } + Ok(current) + } + + fn record_at(&self, path: &Path) -> Result { + self.record_at_optional(path)? + .ok_or_else(|| Error::NotManaged(path.to_path_buf())) + } + + fn record_at_optional(&self, path: &Path) -> Result> { + self.database + .query_row( + "SELECT id, parent_id, path FROM worktree WHERE path = ?1", + [path_text(path)?], + |row| { + Ok(Record { + id: row.get(0)?, + parent_id: row.get(1)?, + path: PathBuf::from(row.get::<_, String>(2)?), + }) + }, + ) + .optional() + .map_err(Error::from) + } + + fn record_id(&self, id: &str) -> Result> { + self.database + .query_row( + "SELECT id, parent_id, path FROM worktree WHERE id = ?1", + [id], + |row| { + Ok(Record { + id: row.get(0)?, + parent_id: row.get(1)?, + path: PathBuf::from(row.get::<_, String>(2)?), + }) + }, + ) + .optional() + .map_err(Error::from) + } + + fn is_descendant(&self, candidate: &str, of: &str) -> Result { + Ok(self.database.query_row( + "WITH RECURSIVE descendants(id) AS ( + SELECT id FROM worktree WHERE parent_id = ?1 + UNION ALL + SELECT worktree.id FROM worktree JOIN descendants ON worktree.parent_id = descendants.id + ) SELECT EXISTS(SELECT 1 FROM descendants WHERE id = ?2)", + params![of, candidate], + |row| row.get(0), + )?) + } +} + +fn default_database_path() -> Result { + let base = dirs::data_local_dir() + .ok_or_else(|| Error::Path("user data directory is unavailable".into()))?; + Ok(base.join("worktree").join("worktree.sqlite")) +} + +fn existing_directory(path: &Path) -> Result { + let path = fs::canonicalize(path)?; + if !path.is_dir() { + return Err(Error::Path(format!("not a directory: {}", path.display()))); + } + Ok(path) +} + +fn absolute_path(path: &Path) -> Result { + if path.is_absolute() { + return Ok(path.to_path_buf()); + } + Ok(std::env::current_dir()?.join(path)) +} + +fn default_storage(root: &Path) -> Result { + let parent = root + .parent() + .ok_or_else(|| Error::Path(format!("workspace has no parent: {}", root.display())))?; + let name = root + .file_name() + .ok_or_else(|| Error::Path(format!("workspace has no name: {}", root.display())))?; + Ok(parent.join(".worktrees").join(name)) +} + +fn destination_name(name: Option, id: &str) -> Result { + let name = name.unwrap_or_else(|| id.to_owned()); + if name.is_empty() || name == "." || name == ".." || Path::new(&name).components().count() != 1 + { + return Err(Error::Path(format!("invalid worktree name: {name}"))); + } + Ok(name) +} + +fn marker(path: &Path) -> PathBuf { + path.join(".worktree") +} + +fn write_marker(path: &Path, id: &str) -> Result<()> { + fs::write(marker(path), format!("{id}\n"))?; + Ok(()) +} + +fn read_marker(path: &Path) -> Result> { + let marker = marker(path); + if !marker.exists() { + return Ok(None); + } + Ok(Some(fs::read_to_string(marker)?.trim().to_owned())) +} + +fn verify_marker(record: &Record) -> Result<()> { + if read_marker(&record.path)?.as_deref() == Some(&record.id) { + return Ok(()); + } + Err(Error::MarkerMismatch(record.path.clone())) +} + +fn path_text(path: &Path) -> Result { + path.to_str() + .map(ToOwned::to_owned) + .ok_or_else(|| Error::Path(format!("path is not valid UTF-8: {}", path.display()))) +} + +fn timestamp() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as i64 +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::copy::{FailureStrategy, TestStrategy}; + use std::process::Command; + use tempfile::TempDir; + + fn manager(temp: &TempDir) -> Manager { + Manager::with_copier(temp.path().join("registry.sqlite"), Box::new(TestStrategy)).unwrap() + } + + fn source(temp: &TempDir) -> PathBuf { + let source = temp.path().join("app"); + fs::create_dir(&source).unwrap(); + fs::write(source.join("file.txt"), "hello").unwrap(); + source + } + + #[test] + fn create_tracks_parentage_and_default_storage() { + let temp = TempDir::new().unwrap(); + let source = source(&temp); + let mut manager = manager(&temp); + let first = manager + .create(Create { + from: source.clone(), + name: Some("first".into()), + into: None, + }) + .unwrap(); + let second = manager + .create(Create { + from: first.clone(), + name: Some("second".into()), + into: None, + }) + .unwrap(); + + assert_eq!(first, temp.path().join(".worktrees/app/first")); + assert_eq!(second, temp.path().join(".worktrees/app/second")); + assert_ne!( + fs::read_to_string(source.join(".worktree")).unwrap(), + fs::read_to_string(first.join(".worktree")).unwrap() + ); + assert_eq!(manager.children(&source).unwrap(), vec![first.clone()]); + assert_eq!(manager.ancestors(&second).unwrap(), vec![first, source]); + } + + #[test] + fn remove_deletes_a_full_subtree() { + let temp = TempDir::new().unwrap(); + let source = source(&temp); + let mut manager = manager(&temp); + let first = manager + .create(Create { + from: source.clone(), + name: Some("first".into()), + into: None, + }) + .unwrap(); + let second = manager + .create(Create { + from: first.clone(), + name: Some("second".into()), + into: None, + }) + .unwrap(); + + manager.remove(&first).unwrap(); + + assert!(!first.exists()); + assert!(!second.exists()); + assert!(manager.children(&source).unwrap().is_empty()); + assert!(matches!( + manager.remove(&source), + Err(Error::CannotRemoveRoot(_)) + )); + } + + #[test] + fn remove_refuses_a_subtree_with_an_unlinked_move() { + let temp = TempDir::new().unwrap(); + let source = source(&temp); + let mut manager = manager(&temp); + let first = manager + .create(Create { + from: source, + name: Some("first".into()), + into: None, + }) + .unwrap(); + let second = manager + .create(Create { + from: first.clone(), + name: Some("second".into()), + into: None, + }) + .unwrap(); + fs::rename(&second, temp.path().join("moved")).unwrap(); + + assert!(matches!( + manager.remove(&first), + Err(Error::MissingWorktree(_)) + )); + assert!(first.exists()); + } + + #[test] + fn link_restores_moves_markers_and_reparents() { + let temp = TempDir::new().unwrap(); + let source = source(&temp); + let mut manager = manager(&temp); + let first = manager + .create(Create { + from: source.clone(), + name: Some("first".into()), + into: None, + }) + .unwrap(); + let second = manager + .create(Create { + from: source.clone(), + name: Some("second".into()), + into: None, + }) + .unwrap(); + let moved = temp.path().join("moved"); + fs::rename(&second, &moved).unwrap(); + + manager + .link(Link { + at: moved.clone(), + to: Some(first.clone()), + }) + .unwrap(); + assert_eq!( + manager.ancestors(&moved).unwrap(), + vec![first, source.clone()] + ); + + fs::remove_file(source.join(".worktree")).unwrap(); + manager + .link(Link { + at: source.clone(), + to: None, + }) + .unwrap(); + assert!(source.join(".worktree").exists()); + } + + #[test] + fn link_does_not_reparent_a_registered_source() { + let temp = TempDir::new().unwrap(); + let source = source(&temp); + let mut manager = manager(&temp); + let child = manager + .create(Create { + from: source.clone(), + name: Some("child".into()), + into: None, + }) + .unwrap(); + + assert!(matches!( + manager.link(Link { + at: source.clone(), + to: Some(child), + }), + Err(Error::CannotLinkRoot(_)) + )); + assert!(matches!( + manager.remove(&source), + Err(Error::CannotRemoveRoot(_)) + )); + } + + #[test] + fn git_copy_detaches_head_and_preserves_dirty_state() { + let temp = TempDir::new().unwrap(); + let source = source(&temp); + run(&source, &["init"]); + run(&source, &["config", "user.email", "test@example.com"]); + run(&source, &["config", "user.name", "Test"]); + run(&source, &["add", "file.txt"]); + run(&source, &["commit", "-m", "initial"]); + fs::write(source.join("file.txt"), "changed").unwrap(); + run(&source, &["add", "file.txt"]); + fs::write(source.join("untracked.txt"), "new").unwrap(); + let mut manager = manager(&temp); + + let destination = manager + .create(Create { + from: source.clone(), + name: Some("git".into()), + into: None, + }) + .unwrap(); + + assert!( + !Command::new("git") + .arg("-C") + .arg(&destination) + .args(["symbolic-ref", "-q", "HEAD"]) + .status() + .unwrap() + .success() + ); + let staged = Command::new("git") + .arg("-C") + .arg(&destination) + .args(["diff", "--cached", "--name-only"]) + .output() + .unwrap(); + assert!(String::from_utf8_lossy(&staged.stdout).contains("file.txt")); + assert!(destination.join("untracked.txt").exists()); + let status = Command::new("git") + .arg("-C") + .arg(&destination) + .args(["status", "--porcelain", "--", ".worktree"]) + .output() + .unwrap(); + assert!(status.stdout.is_empty()); + } + + #[test] + fn unsafe_git_source_is_rejected_without_registering_it() { + let temp = TempDir::new().unwrap(); + let source = source(&temp); + run(&source, &["init"]); + fs::write(source.join(".git/MERGE_HEAD"), "commit").unwrap(); + let mut manager = manager(&temp); + + assert!(matches!( + manager.create(Create { + from: source.clone(), + name: Some("unsafe".into()), + into: None, + }), + Err(Error::UnsafeGit(_)) + )); + assert!(!source.join(".worktree").exists()); + } + + #[test] + fn unavailable_cow_does_not_register_the_source() { + let temp = TempDir::new().unwrap(); + let source = source(&temp); + let mut manager = Manager::with_copier( + temp.path().join("registry.sqlite"), + Box::new(FailureStrategy), + ) + .unwrap(); + + assert!(matches!( + manager.create(Create { + from: source.clone(), + name: Some("failure".into()), + into: None, + }), + Err(Error::CowUnavailable(_)) + )); + assert!(!source.join(".worktree").exists()); + assert!(manager.record_at_optional(&source).unwrap().is_none()); + } + + fn run(path: &Path, args: &[&str]) { + assert!( + Command::new("git") + .arg("-C") + .arg(path) + .args(args) + .status() + .unwrap() + .success() + ); + } +} diff --git a/packages/worktree/specs.md b/packages/worktree/specs.md new file mode 100644 index 0000000000..61c3758b0b --- /dev/null +++ b/packages/worktree/specs.md @@ -0,0 +1,182 @@ +# Worktree Specs + +## Requirement + +`worktree` must be cross-platform as far as practical. Core semantics should work across macOS, Linux, and Windows. Copy-on-write is a platform/filesystem acceleration and must not define the product model. + +## API + +### `create` + +```ts +create(input: { + from: AbsolutePath + name?: string + into?: AbsolutePath +}): AbsolutePath +``` + +Default behavior: + +- Source is `from`. +- `name` defaults to a generated directory name. +- `into` defaults to the managed worktree directory. +- Copy the whole workspace, including dirty, staged, untracked, and ignored files. +- Detach `HEAD` in the new workspace. +- Return the path of the new workspace. + +If `from` is already a managed worktree, create copies that exact worktree. Do not resolve back to an earlier workspace. Metadata should record the immediate source worktree as its parent. + +Default storage is a hidden sibling directory of the original registered workspace: + +```text +/projects/app/ original workspace +/projects/.worktrees/app/task-a/ created worktree +/projects/.worktrees/app/task-b/ created worktree +``` + +- Created worktrees must not be stored inside the workspace being copied, because an exact copy would recursively contain existing worktrees. +- If `from` is an original unregistered workspace, its sibling `.worktrees//` directory becomes the default destination directory. +- If `from` is already managed, descendants use the default destination directory associated with the original workspace rather than nesting storage beside each descendant. +- If `into` is provided, use it instead of the default destination directory. +- If the original workspace is itself a filesystem mount root, its sibling default destination may not support copy-on-write with it; provide `into` on the same filesystem in that case. + +### `remove` + +```ts +remove(input: { + at: AbsolutePath +}): void +``` + +`remove` deletes a managed worktree and its full descendant subtree. + +- `at` must identify a worktree created by this tool; the registered source root cannot be removed. +- Resolve all descendants through `parent_id` and remove their directories deepest-first. +- Verify each existing directory's `.worktree` marker before deleting it. +- Refuse removal if any descendant path is missing, because it may be a moved workspace that has not been linked yet. +- After successful filesystem removal, delete the subtree records from the database. + +### `link` + +```ts +link(input: { + at: AbsolutePath + to?: AbsolutePath +}): void +``` + +`link` reconnects a moved managed worktree to its registry record and can change its parent. + +- Read the ULID from `.worktree` at `at`. +- Look up the existing worktree record by ULID. +- If its recorded path is `at`, leave its location unchanged. +- If its recorded path is different and missing, update it to `at`. +- If its recorded path is different and still exists, fail because this is a duplicate identity, not a move. +- If the ULID is unknown to the database, fail; `.worktree` alone does not include the ancestry needed to rebuild the record. +- If `.worktree` is missing, look up `at` by its absolute path. If it matches an existing record, recreate the marker with that record's ULID. +- If `.worktree` is missing and `at` does not match an existing record, fail. A moved workspace without its marker cannot be identified safely. +- If `to` is provided, set the worktree's parent to the managed worktree at `to`. +- Refuse `to` for an original registered workspace; only worktrees created by this tool can be reparented. +- Refuse `to` if it is `at` or a descendant of `at`, because reparenting must not create a cycle. + +### `children` + +```ts +children(input: { + of: AbsolutePath +}): AbsolutePath[] +``` + +`children` returns the direct managed children created from `of`. + +### `ancestors` + +```ts +ancestors(input: { + of: AbsolutePath +}): AbsolutePath[] +``` + +`ancestors` returns the managed ancestry of `of`, ordered from its immediate parent to the root workspace. + +## Metadata + +Metadata is stored in a central SQLite database in the platform-appropriate user data directory. + +SQLite is not overkill: multiple processes and agents may create, inspect, or remove worktrees concurrently. It provides cross-platform transactions and locking without building a safe JSON registry protocol. + +Start with one table: + +```sql +CREATE TABLE worktree ( + id TEXT PRIMARY KEY, + parent_id TEXT REFERENCES worktree(id) ON DELETE CASCADE, + path TEXT NOT NULL UNIQUE, + created_at INTEGER NOT NULL +); + +CREATE INDEX worktree_parent_id_idx ON worktree(parent_id); +``` + +- Every managed worktree has a stable generated `id`. +- `id` is a ULID generated when the workspace is first registered or created. +- `id` is stored in the central database and in a `.worktree` marker file at the root of the workspace. +- `.worktree` contains the worktree ULID and allows a moved workspace to be rediscovered and verified against the database. +- When a managed workspace is copied, the copied `.worktree` marker is replaced with the new workspace's ULID. +- The original registered workspace has `parent_id = NULL`. +- A created worktree has `parent_id` set to the source worktree `id`. +- `path` is its current location, not its identity. +- Provenance is a rooted tree. Descendants of any worktree can be listed through recursive queries over `parent_id`. +- `remove` deletes a whole subtree, so no surviving record depends on deleted ancestry. + +### Moved Worktrees + +If a worktree is moved outside the tool, its recorded path becomes missing. The tool cannot discover an arbitrary new location without being given a path or scanning a configured directory. + +When `link` is run against a directory containing `.worktree`, the tool reads its ULID and reconciles the database path if the recorded path no longer exists. + +If both the recorded path and the provided path exist with the same ULID, the tool must refuse automatic reconciliation because the directory was copied without assigning a new identity. + +## Git Integration + +Git support is an integration for directories that contain repositories; it does not define the core worktree model. + +When registering or creating from a Git repository: + +- Add `/.worktree` to `.git/info/exclude` so the identity marker does not appear in local Git status. +- Copy the directory with its staged, unstaged, untracked, ignored, and cached state intact. +- If `HEAD` resolves to a commit, detach `HEAD` in the created destination at that same commit. +- Preserve the copied index and working tree state while detaching. +- If the repository has no commits yet, leave its unborn branch state unchanged because there is no commit to detach to. + +Refuse creation from a Git repository when: + +- It is a linked Git worktree whose `.git` is not an independent directory. +- A merge, rebase, cherry-pick, revert, or bisect is in progress. +- Git lock or inconsistent index state makes an exact safe copy unclear. + +The tool does not create branches, commit changes, or otherwise replace normal Git commands. + +## Copy Strategies + +Copying is implemented behind a strategy boundary so platform-specific copy-on-write backends can be added independently. + +- The production strategy on Linux uses reflink cloning. +- The production strategy on macOS uses APFS `clonefile` directory cloning. +- If no implemented copy-on-write strategy succeeds, `create` fails. +- Full byte copying is not implemented as a fallback. +- Future strategies may add Windows copy-on-write support without changing the API. + +## Packaging + +The project ships four interfaces backed by the same implementation and metadata model: + +1. Native library containing the core API and implementation. +2. CLI package providing the `worktree` executable. +3. Bun FFI package for use from Bun applications. +4. Node FFI package for use from Node.js applications. + +The CLI and language bindings should remain thin and expose the same API semantics as the native library. + +For CLI ergonomics, `worktree create` defaults `from` to the current working directory when no source path is provided. From 02edad83b2833ad5535146349f09d2bb0a65af9a Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sun, 31 May 2026 12:06:03 -0400 Subject: [PATCH 036/840] test(tui): skip crashing keymap textarea renderer --- packages/opencode/test/cli/run/footer.view.test.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/opencode/test/cli/run/footer.view.test.tsx b/packages/opencode/test/cli/run/footer.view.test.tsx index 214f4637d3..0f7ddb532e 100644 --- a/packages/opencode/test/cli/run/footer.view.test.tsx +++ b/packages/opencode/test/cli/run/footer.view.test.tsx @@ -593,7 +593,9 @@ test("direct question body separates single-select checkmark from label", async } }) -test("direct custom answer submits through keymap return binding", async () => { +// OpenTUI currently segfaults while tearing down this textarea-backed keymap renderer. +// Re-enable after the runtime fix. +test.skip("direct custom answer submits through keymap return binding", async () => { const question = { id: "question-1", sessionID: "session-1", From a69b70d5ca18def96caceab71dc7e5cde79910e7 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sun, 31 May 2026 12:17:40 -0400 Subject: [PATCH 037/840] fix(core): allow skipping migration execution --- packages/core/src/database/migration.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/database/migration.ts b/packages/core/src/database/migration.ts index 42aebf02d1..f0a0301442 100644 --- a/packages/core/src/database/migration.ts +++ b/packages/core/src/database/migration.ts @@ -47,7 +47,7 @@ export function applyOnly(db: Database, input: Migration[]) { if (completed.has(migration.id)) continue yield* db.transaction((tx) => Effect.gen(function* () { - yield* migration.up(tx) + if (!process.env.OPENCODE_SKIP_MIGRATIONS) yield* migration.up(tx) yield* tx.run( sql`INSERT INTO ${sql.identifier("migration")} (id, time_completed) VALUES (${migration.id}, ${Date.now()})`, ) From 2f2fcc165439aec88a0e8e09c836d89c96977c6b Mon Sep 17 00:00:00 2001 From: Dax Date: Sun, 31 May 2026 13:34:14 -0400 Subject: [PATCH 038/840] fix(opencode): remove automatic full session diffs (#30127) --- .../src/cli/cmd/tui/routes/session/index.tsx | 3 +- packages/opencode/src/session/session.ts | 10 ++-- packages/opencode/src/session/summary.ts | 37 +++++++------- .../server/session-diff-missing-patch.test.ts | 50 ++++++++++++++----- .../test/session/snapshot-tool-race.test.ts | 6 ++- 5 files changed, 64 insertions(+), 42 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 5c0e5e73fe..4fdeb61f7d 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -210,7 +210,8 @@ export function Session() { const disabled = createMemo(() => permissions().length > 0 || questions().length > 0) const pending = createMemo(() => { - return messages().findLast((x) => x.role === "assistant" && !x.time.completed)?.id + const completed = messages().findLast((x) => x.role === "assistant" && x.time.completed)?.id + return messages().findLast((x) => x.role === "assistant" && !x.time.completed && (!completed || x.id > completed))?.id }) const lastAssistant = createMemo(() => { diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index f87a24a74e..bb6f1d6a1d 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -25,7 +25,6 @@ import { or } from "drizzle-orm" import type { SQL } from "drizzle-orm" import { PartTable, SessionTable } from "@opencode-ai/core/session/sql" import { ProjectTable } from "@opencode-ai/core/project/sql" -import { Storage } from "@/storage/storage" import * as Log from "@opencode-ai/core/util/log" import { MessageV2 } from "./message-v2" import type { InstanceContext } from "../project/instance-context" @@ -536,7 +535,7 @@ export type Patch = Omit, "time" | "share" | "summary" | "revert" export const layer: Layer.Layer< Service, never, - BackgroundJob.Service | Storage.Service | RuntimeFlags.Service | Database.Service | EventV2Bridge.Service + BackgroundJob.Service | RuntimeFlags.Service | Database.Service | EventV2Bridge.Service > = Layer.effect( Service, Effect.gen(function* () { @@ -544,7 +543,6 @@ export const layer: Layer.Layer< const database = yield* Database.Service const background = yield* BackgroundJob.Service const events = yield* EventV2Bridge.Service - const storage = yield* Storage.Service const flags = yield* RuntimeFlags.Service const locationForSession = Effect.fnUntraced(function* (sessionID: SessionID) { @@ -887,9 +885,8 @@ export const layer: Layer.Layer< }) const diff = Effect.fn("Session.diff")(function* (sessionID: SessionID) { - return yield* storage - .read(["session_diff", sessionID]) - .pipe(Effect.orElseSucceed((): Snapshot.FileDiff[] => [])) + void sessionID + return [] as Snapshot.FileDiff[] }) const messages: Interface["messages"] = Effect.fn("Session.messages")(function* (input) { @@ -1013,7 +1010,6 @@ export const layer: Layer.Layer< export const defaultLayer = layer.pipe( Layer.provide(BackgroundJob.defaultLayer), - Layer.provide(Storage.defaultLayer), Layer.provide(Database.defaultLayer), Layer.provide(EventV2Bridge.defaultLayer), Layer.provide(SessionV2.defaultLayer), diff --git a/packages/opencode/src/session/summary.ts b/packages/opencode/src/session/summary.ts index e32ce9803e..7dccec1f2e 100644 --- a/packages/opencode/src/session/summary.ts +++ b/packages/opencode/src/session/summary.ts @@ -2,10 +2,9 @@ import { Effect, Layer, Context, Schema } from "effect" import { SessionLegacy } from "@opencode-ai/core/session/legacy" import { EventV2Bridge } from "@/event-v2-bridge" import { Snapshot } from "@/snapshot" -import { Storage } from "@/storage/storage" import * as Session from "./session" -import { MessageV2 } from "./message-v2" import { SessionID, MessageID } from "./schema" +import { Config } from "@/config/config" function unquoteGitPath(input: string) { if (!input.startsWith('"')) return input @@ -76,8 +75,8 @@ export const layer = Layer.effect( Effect.gen(function* () { const sessions = yield* Session.Service const snapshot = yield* Snapshot.Service - const storage = yield* Storage.Service const events = yield* EventV2Bridge.Service + const config = yield* Config.Service const computeDiff = Effect.fn("SessionSummary.computeDiff")(function* (input: { messages: SessionLegacy.WithParts[] @@ -105,20 +104,18 @@ export const layer = Layer.effect( sessionID: SessionID messageID: MessageID }) { - const all = yield* sessions.messages({ sessionID: input.sessionID }).pipe(Effect.orDie) - if (!all.length) return - - const diffs = yield* computeDiff({ messages: all }) yield* sessions.setSummary({ sessionID: input.sessionID, summary: { - additions: diffs.reduce((sum, x) => sum + x.additions, 0), - deletions: diffs.reduce((sum, x) => sum + x.deletions, 0), - files: diffs.length, + additions: 0, + deletions: 0, + files: 0, }, }) - yield* storage.write(["session_diff", input.sessionID], diffs).pipe(Effect.ignore) - yield* events.publish(Session.Event.Diff, { sessionID: input.sessionID, diff: diffs }) + yield* events.publish(Session.Event.Diff, { sessionID: input.sessionID, diff: [] }) + if ((yield* config.get()).snapshot === false) return + const all = yield* sessions.messages({ sessionID: input.sessionID }).pipe(Effect.orDie) + if (!all.length) return const messages = all.filter( (m) => m.info.id === input.messageID || (m.info.role === "assistant" && m.info.parentID === input.messageID), @@ -131,18 +128,18 @@ export const layer = Layer.effect( }) const diff = Effect.fn("SessionSummary.diff")(function* (input: { sessionID: SessionID; messageID?: MessageID }) { - const diffs = yield* storage - .read(["session_diff", input.sessionID]) - .pipe(Effect.catch(() => Effect.succeed([] as Snapshot.FileDiff[]))) - const next = diffs.map((item) => { + if (!input.messageID) return [] + const message = (yield* sessions.messages({ sessionID: input.sessionID }).pipe(Effect.orDie)).find( + (item) => item.info.id === input.messageID, + ) + if (!message || message.info.role !== "user") return [] + const diffs = message.info.summary?.diffs ?? [] + return diffs.map((item) => { if (item.file === undefined) return item const file = unquoteGitPath(item.file) if (file === item.file) return item return { ...item, file } }) - const changed = next.some((item, i) => item.file !== diffs[i]?.file) - if (changed) yield* storage.write(["session_diff", input.sessionID], next).pipe(Effect.ignore) - return next }) return Service.of({ summarize, diff, computeDiff }) @@ -153,8 +150,8 @@ export const defaultLayer = Layer.suspend(() => layer.pipe( Layer.provide(Session.defaultLayer), Layer.provide(Snapshot.defaultLayer), - Layer.provide(Storage.defaultLayer), Layer.provide(EventV2Bridge.defaultLayer), + Layer.provide(Config.defaultLayer), ), ) diff --git a/packages/opencode/test/server/session-diff-missing-patch.test.ts b/packages/opencode/test/server/session-diff-missing-patch.test.ts index f7f22b4327..d77a23380a 100644 --- a/packages/opencode/test/server/session-diff-missing-patch.test.ts +++ b/packages/opencode/test/server/session-diff-missing-patch.test.ts @@ -4,16 +4,20 @@ * the response was Schema-encoded against `Snapshot.FileDiff` with * `patch: Schema.String` (required), so any session whose stored * `summary_diffs` had a row without `patch` returned HTTP 400 and the - * session never loaded. + * session never loaded. Legacy session-level diffs are no longer surfaced, + * but the endpoint remains compatible and must still return successfully. * * This test inserts a session row with a missing-patch diff entry and - * asserts that GET /session//diff returns 200 with the row intact. + * asserts that GET /session//diff returns 200 with empty data. */ import { afterEach, describe, expect } from "bun:test" import { Effect, Layer } from "effect" import { SessionPaths } from "@/server/routes/instance/httpapi/groups/session" import { Session } from "@/session/session" import { Storage } from "@/storage/storage" +import { SessionLegacy } from "@opencode-ai/core/session/legacy" +import { MessageID } from "@/session/schema" +import { ProviderV2 } from "@opencode-ai/core/provider" import { resetDatabase } from "../fixture/db" import { disposeAllInstances, TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" @@ -38,7 +42,7 @@ const withSession = (input?: Parameters[0]) => describe("session diff with missing patch (#26574)", () => { it.instance( - "GET /session//diff returns 200 when summary_diffs row has no patch", + "GET /session//diff ignores legacy session-level diff storage", () => Effect.gen(function* () { const test = yield* TestInstance @@ -57,15 +61,37 @@ describe("session diff with missing patch (#26574)", () => { ) expect(response.status).toBe(200) - const body = (yield* response.json) as Array<{ - file: string - patch?: string - additions: number - }> - expect(body).toHaveLength(1) - expect(body[0]?.file).toBe("legacy.txt") - expect(body[0]?.additions).toBe(1) - expect(body[0]?.patch).toBeUndefined() + expect(yield* response.json).toEqual([]) + }), + { git: true, config: { formatter: false, lsp: false } }, + ) + + it.instance( + "GET /session//diff returns requested turn diffs", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const session = yield* withSession({ title: "turn-diff" }) + const messageID = MessageID.ascending() + yield* Session.use.updateMessage({ + id: messageID, + sessionID: session.id, + role: "user", + time: { created: Date.now() }, + agent: "build", + model: { providerID: ProviderV2.ID.make("test"), modelID: ProviderV2.ModelID.make("model") }, + summary: { + diffs: [{ file: "turn.ts", additions: 1, deletions: 0, status: "modified" }], + }, + } satisfies SessionLegacy.User) + + const response = yield* requestInDirectory( + `${pathFor(SessionPaths.diff, { sessionID: session.id })}?messageID=${messageID}`, + test.directory, + ) + + expect(response.status).toBe(200) + expect(yield* response.json).toEqual([{ file: "turn.ts", additions: 1, deletions: 0, status: "modified" }]) }), { git: true, config: { formatter: false, lsp: false } }, ) diff --git a/packages/opencode/test/session/snapshot-tool-race.test.ts b/packages/opencode/test/session/snapshot-tool-race.test.ts index b5fed974a0..815303a532 100644 --- a/packages/opencode/test/session/snapshot-tool-race.test.ts +++ b/packages/opencode/test/session/snapshot-tool-race.test.ts @@ -257,15 +257,17 @@ it.live("tool execution produces non-empty session diff (snapshot race)", () => // Verify the tool call completed (in the first assistant message) const allMsgs = yield* MessageV2.filterCompactedEffect(session.id) + const user = allMsgs.find((msg): msg is SessionLegacy.WithParts & { info: SessionLegacy.User } => msg.info.role === "user") const tool = allMsgs .flatMap((m) => m.parts) .find((p): p is SessionLegacy.ToolPart => p.type === "tool" && p.tool === "bash") expect(tool?.state.status).toBe("completed") + if (!user) throw new Error("Expected user message") - // Poll for diff — summarize() is fire-and-forget + // Poll for the turn diff — summarize() is fire-and-forget. let diff: Array<{ file?: string }> = [] for (let i = 0; i < 50; i++) { - diff = yield* summary.diff({ sessionID: session.id }) + diff = yield* summary.diff({ sessionID: session.id, messageID: user.info.id }) if (diff.length > 0) break yield* Effect.sleep("100 millis") } From 542b082373556c178e74c37b390c1e06179f7aff Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sun, 31 May 2026 17:35:31 +0000 Subject: [PATCH 039/840] chore: generate --- packages/opencode/src/cli/cmd/tui/routes/session/index.tsx | 3 ++- packages/opencode/test/session/snapshot-tool-race.test.ts | 4 +++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 4fdeb61f7d..70fcd421cd 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -211,7 +211,8 @@ export function Session() { const pending = createMemo(() => { const completed = messages().findLast((x) => x.role === "assistant" && x.time.completed)?.id - return messages().findLast((x) => x.role === "assistant" && !x.time.completed && (!completed || x.id > completed))?.id + return messages().findLast((x) => x.role === "assistant" && !x.time.completed && (!completed || x.id > completed)) + ?.id }) const lastAssistant = createMemo(() => { diff --git a/packages/opencode/test/session/snapshot-tool-race.test.ts b/packages/opencode/test/session/snapshot-tool-race.test.ts index 815303a532..50e2a31f95 100644 --- a/packages/opencode/test/session/snapshot-tool-race.test.ts +++ b/packages/opencode/test/session/snapshot-tool-race.test.ts @@ -257,7 +257,9 @@ it.live("tool execution produces non-empty session diff (snapshot race)", () => // Verify the tool call completed (in the first assistant message) const allMsgs = yield* MessageV2.filterCompactedEffect(session.id) - const user = allMsgs.find((msg): msg is SessionLegacy.WithParts & { info: SessionLegacy.User } => msg.info.role === "user") + const user = allMsgs.find( + (msg): msg is SessionLegacy.WithParts & { info: SessionLegacy.User } => msg.info.role === "user", + ) const tool = allMsgs .flatMap((m) => m.parts) .find((p): p is SessionLegacy.ToolPart => p.type === "tool" && p.tool === "bash") From 31f94f205e20395d8cbf740d6f5b070df6736e18 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sun, 31 May 2026 13:59:27 -0400 Subject: [PATCH 040/840] refactor(worktree): move project out of repository --- packages/worktree/.gitignore | 1 - packages/worktree/Cargo.lock | 992 ---------------------- packages/worktree/Cargo.toml | 19 - packages/worktree/crates/cli/Cargo.toml | 13 - packages/worktree/crates/cli/src/main.rs | 79 -- packages/worktree/crates/core/Cargo.toml | 17 - packages/worktree/crates/core/src/copy.rs | 195 ----- packages/worktree/crates/core/src/git.rs | 75 -- packages/worktree/crates/core/src/lib.rs | 712 ---------------- packages/worktree/specs.md | 182 ---- 10 files changed, 2285 deletions(-) delete mode 100644 packages/worktree/.gitignore delete mode 100644 packages/worktree/Cargo.lock delete mode 100644 packages/worktree/Cargo.toml delete mode 100644 packages/worktree/crates/cli/Cargo.toml delete mode 100644 packages/worktree/crates/cli/src/main.rs delete mode 100644 packages/worktree/crates/core/Cargo.toml delete mode 100644 packages/worktree/crates/core/src/copy.rs delete mode 100644 packages/worktree/crates/core/src/git.rs delete mode 100644 packages/worktree/crates/core/src/lib.rs delete mode 100644 packages/worktree/specs.md diff --git a/packages/worktree/.gitignore b/packages/worktree/.gitignore deleted file mode 100644 index b83d22266a..0000000000 --- a/packages/worktree/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/target/ diff --git a/packages/worktree/Cargo.lock b/packages/worktree/Cargo.lock deleted file mode 100644 index f446253de2..0000000000 --- a/packages/worktree/Cargo.lock +++ /dev/null @@ -1,992 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "anstream" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" -dependencies = [ - "anstyle", - "anstyle-parse", - "anstyle-query", - "anstyle-wincon", - "colorchoice", - "is_terminal_polyfill", - "utf8parse", -] - -[[package]] -name = "anstyle" -version = "1.0.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" - -[[package]] -name = "anstyle-parse" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" -dependencies = [ - "utf8parse", -] - -[[package]] -name = "anstyle-query" -version = "1.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" -dependencies = [ - "windows-sys", -] - -[[package]] -name = "anstyle-wincon" -version = "3.0.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" -dependencies = [ - "anstyle", - "once_cell_polyfill", - "windows-sys", -] - -[[package]] -name = "anyhow" -version = "1.0.102" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" - -[[package]] -name = "bitflags" -version = "2.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" - -[[package]] -name = "bumpalo" -version = "3.20.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" - -[[package]] -name = "cc" -version = "1.2.63" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" -dependencies = [ - "find-msvc-tools", - "shlex", -] - -[[package]] -name = "cfg-if" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" - -[[package]] -name = "clap" -version = "4.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" -dependencies = [ - "clap_builder", - "clap_derive", -] - -[[package]] -name = "clap_builder" -version = "4.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" -dependencies = [ - "anstream", - "anstyle", - "clap_lex", - "strsim", -] - -[[package]] -name = "clap_derive" -version = "4.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "clap_lex" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" - -[[package]] -name = "colorchoice" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" - -[[package]] -name = "dirs" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" -dependencies = [ - "dirs-sys", -] - -[[package]] -name = "dirs-sys" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" -dependencies = [ - "libc", - "option-ext", - "redox_users", - "windows-sys", -] - -[[package]] -name = "equivalent" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" - -[[package]] -name = "errno" -version = "0.3.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" -dependencies = [ - "libc", - "windows-sys", -] - -[[package]] -name = "fallible-iterator" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" - -[[package]] -name = "fallible-streaming-iterator" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" - -[[package]] -name = "fastrand" -version = "2.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" - -[[package]] -name = "filetime" -version = "0.2.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" -dependencies = [ - "cfg-if", - "libc", -] - -[[package]] -name = "find-msvc-tools" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" - -[[package]] -name = "foldhash" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" - -[[package]] -name = "futures-core" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" - -[[package]] -name = "futures-task" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" - -[[package]] -name = "futures-util" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" -dependencies = [ - "futures-core", - "futures-task", - "pin-project-lite", - "slab", -] - -[[package]] -name = "getrandom" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" -dependencies = [ - "cfg-if", - "libc", - "wasi", -] - -[[package]] -name = "getrandom" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" -dependencies = [ - "cfg-if", - "libc", - "r-efi 5.3.0", - "wasip2", -] - -[[package]] -name = "getrandom" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" -dependencies = [ - "cfg-if", - "libc", - "r-efi 6.0.0", - "wasip2", - "wasip3", -] - -[[package]] -name = "hashbrown" -version = "0.15.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" -dependencies = [ - "foldhash", -] - -[[package]] -name = "hashbrown" -version = "0.17.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" - -[[package]] -name = "hashlink" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" -dependencies = [ - "hashbrown 0.15.5", -] - -[[package]] -name = "heck" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" - -[[package]] -name = "id-arena" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" - -[[package]] -name = "indexmap" -version = "2.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" -dependencies = [ - "equivalent", - "hashbrown 0.17.1", - "serde", - "serde_core", -] - -[[package]] -name = "is_terminal_polyfill" -version = "1.70.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" - -[[package]] -name = "itoa" -version = "1.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" - -[[package]] -name = "js-sys" -version = "0.3.99" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" -dependencies = [ - "cfg-if", - "futures-util", - "once_cell", - "wasm-bindgen", -] - -[[package]] -name = "leb128fmt" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" - -[[package]] -name = "libc" -version = "0.2.186" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" - -[[package]] -name = "libredox" -version = "0.1.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" -dependencies = [ - "libc", -] - -[[package]] -name = "libsqlite3-sys" -version = "0.33.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "947e6816f7825b2b45027c2c32e7085da9934defa535de4a6a46b10a4d5257fa" -dependencies = [ - "cc", - "pkg-config", - "vcpkg", -] - -[[package]] -name = "linux-raw-sys" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" - -[[package]] -name = "log" -version = "0.4.30" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5" - -[[package]] -name = "memchr" -version = "2.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" - -[[package]] -name = "once_cell" -version = "1.21.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" - -[[package]] -name = "once_cell_polyfill" -version = "1.70.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" - -[[package]] -name = "option-ext" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" - -[[package]] -name = "pin-project-lite" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" - -[[package]] -name = "pkg-config" -version = "0.3.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" - -[[package]] -name = "ppv-lite86" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] - -[[package]] -name = "prettyplease" -version = "0.2.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" -dependencies = [ - "proc-macro2", - "syn", -] - -[[package]] -name = "proc-macro2" -version = "1.0.106" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "quote" -version = "1.0.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "r-efi" -version = "5.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" - -[[package]] -name = "r-efi" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" - -[[package]] -name = "rand" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" -dependencies = [ - "rand_chacha", - "rand_core", -] - -[[package]] -name = "rand_chacha" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" -dependencies = [ - "ppv-lite86", - "rand_core", -] - -[[package]] -name = "rand_core" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" -dependencies = [ - "getrandom 0.3.4", -] - -[[package]] -name = "redox_users" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" -dependencies = [ - "getrandom 0.2.17", - "libredox", - "thiserror", -] - -[[package]] -name = "rusqlite" -version = "0.35.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a22715a5d6deef63c637207afbe68d0c72c3f8d0022d7cf9714c442d6157606b" -dependencies = [ - "bitflags", - "fallible-iterator", - "fallible-streaming-iterator", - "hashlink", - "libsqlite3-sys", - "smallvec", -] - -[[package]] -name = "rustix" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" -dependencies = [ - "bitflags", - "errno", - "libc", - "linux-raw-sys", - "windows-sys", -] - -[[package]] -name = "rustversion" -version = "1.0.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" - -[[package]] -name = "same-file" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" -dependencies = [ - "winapi-util", -] - -[[package]] -name = "semver" -version = "1.0.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" - -[[package]] -name = "serde" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" -dependencies = [ - "serde_core", -] - -[[package]] -name = "serde_core" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "serde_json" -version = "1.0.150" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" -dependencies = [ - "itoa", - "memchr", - "serde", - "serde_core", - "zmij", -] - -[[package]] -name = "shlex" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" - -[[package]] -name = "slab" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" - -[[package]] -name = "smallvec" -version = "1.15.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" - -[[package]] -name = "strsim" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" - -[[package]] -name = "syn" -version = "2.0.117" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "tempfile" -version = "3.27.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" -dependencies = [ - "fastrand", - "getrandom 0.4.2", - "once_cell", - "rustix", - "windows-sys", -] - -[[package]] -name = "thiserror" -version = "2.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" -dependencies = [ - "thiserror-impl", -] - -[[package]] -name = "thiserror-impl" -version = "2.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "ulid" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "470dbf6591da1b39d43c14523b2b469c86879a53e8b758c8e090a470fe7b1fbe" -dependencies = [ - "rand", - "web-time", -] - -[[package]] -name = "unicode-ident" -version = "1.0.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" - -[[package]] -name = "unicode-xid" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" - -[[package]] -name = "utf8parse" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" - -[[package]] -name = "vcpkg" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" - -[[package]] -name = "walkdir" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" -dependencies = [ - "same-file", - "winapi-util", -] - -[[package]] -name = "wasi" -version = "0.11.1+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" - -[[package]] -name = "wasip2" -version = "1.0.3+wasi-0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" -dependencies = [ - "wit-bindgen 0.57.1", -] - -[[package]] -name = "wasip3" -version = "0.4.0+wasi-0.3.0-rc-2026-01-06" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" -dependencies = [ - "wit-bindgen 0.51.0", -] - -[[package]] -name = "wasm-bindgen" -version = "0.2.122" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" -dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "wasm-bindgen-macro", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-macro" -version = "0.2.122" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.122" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" -dependencies = [ - "bumpalo", - "proc-macro2", - "quote", - "syn", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-shared" -version = "0.2.122" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "wasm-encoder" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" -dependencies = [ - "leb128fmt", - "wasmparser", -] - -[[package]] -name = "wasm-metadata" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" -dependencies = [ - "anyhow", - "indexmap", - "wasm-encoder", - "wasmparser", -] - -[[package]] -name = "wasmparser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" -dependencies = [ - "bitflags", - "hashbrown 0.15.5", - "indexmap", - "semver", -] - -[[package]] -name = "web-time" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "winapi-util" -version = "0.1.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" -dependencies = [ - "windows-sys", -] - -[[package]] -name = "windows-link" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" - -[[package]] -name = "windows-sys" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" -dependencies = [ - "windows-link", -] - -[[package]] -name = "wit-bindgen" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" -dependencies = [ - "wit-bindgen-rust-macro", -] - -[[package]] -name = "wit-bindgen" -version = "0.57.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" - -[[package]] -name = "wit-bindgen-core" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" -dependencies = [ - "anyhow", - "heck", - "wit-parser", -] - -[[package]] -name = "wit-bindgen-rust" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" -dependencies = [ - "anyhow", - "heck", - "indexmap", - "prettyplease", - "syn", - "wasm-metadata", - "wit-bindgen-core", - "wit-component", -] - -[[package]] -name = "wit-bindgen-rust-macro" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" -dependencies = [ - "anyhow", - "prettyplease", - "proc-macro2", - "quote", - "syn", - "wit-bindgen-core", - "wit-bindgen-rust", -] - -[[package]] -name = "wit-component" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" -dependencies = [ - "anyhow", - "bitflags", - "indexmap", - "log", - "serde", - "serde_derive", - "serde_json", - "wasm-encoder", - "wasm-metadata", - "wasmparser", - "wit-parser", -] - -[[package]] -name = "wit-parser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" -dependencies = [ - "anyhow", - "id-arena", - "indexmap", - "log", - "semver", - "serde", - "serde_derive", - "serde_json", - "unicode-xid", - "wasmparser", -] - -[[package]] -name = "worktree" -version = "0.1.0" -dependencies = [ - "dirs", - "filetime", - "libc", - "rusqlite", - "tempfile", - "thiserror", - "ulid", - "walkdir", -] - -[[package]] -name = "worktree-cli" -version = "0.1.0" -dependencies = [ - "clap", - "worktree", -] - -[[package]] -name = "zerocopy" -version = "0.8.50" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.50" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "zmij" -version = "1.0.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/packages/worktree/Cargo.toml b/packages/worktree/Cargo.toml deleted file mode 100644 index e30a9d16bc..0000000000 --- a/packages/worktree/Cargo.toml +++ /dev/null @@ -1,19 +0,0 @@ -[workspace] -resolver = "2" -members = ["crates/core", "crates/cli"] - -[workspace.package] -edition = "2024" -license = "MIT" -version = "0.1.0" - -[workspace.dependencies] -clap = { version = "4.5", features = ["derive"] } -dirs = "6.0" -filetime = "0.2" -libc = "0.2" -rusqlite = { version = "0.35", features = ["bundled"] } -tempfile = "3.20" -thiserror = "2.0" -ulid = "1.2" -walkdir = "2.5" diff --git a/packages/worktree/crates/cli/Cargo.toml b/packages/worktree/crates/cli/Cargo.toml deleted file mode 100644 index e19bd5089a..0000000000 --- a/packages/worktree/crates/cli/Cargo.toml +++ /dev/null @@ -1,13 +0,0 @@ -[package] -name = "worktree-cli" -version.workspace = true -edition.workspace = true -license.workspace = true - -[[bin]] -name = "worktree" -path = "src/main.rs" - -[dependencies] -clap.workspace = true -worktree = { path = "../core" } diff --git a/packages/worktree/crates/cli/src/main.rs b/packages/worktree/crates/cli/src/main.rs deleted file mode 100644 index 03acc84b72..0000000000 --- a/packages/worktree/crates/cli/src/main.rs +++ /dev/null @@ -1,79 +0,0 @@ -use clap::{Parser, Subcommand}; -use std::path::PathBuf; -use worktree::{Create, Link, Manager}; - -#[derive(Parser)] -#[command(name = "worktree")] -struct Cli { - #[arg(long, hide = true)] - database: Option, - #[command(subcommand)] - command: Command, -} - -#[derive(Subcommand)] -enum Command { - Create { - from: Option, - #[arg(long)] - name: Option, - #[arg(long)] - into: Option, - }, - Remove { - at: PathBuf, - }, - Link { - at: PathBuf, - #[arg(long)] - to: Option, - }, - Children { - of: PathBuf, - }, - Ancestors { - of: PathBuf, - }, -} - -fn main() { - if let Err(error) = run() { - eprintln!("worktree: {error}"); - std::process::exit(1); - } -} - -fn run() -> worktree::Result<()> { - let cli = Cli::parse(); - let mut manager = match cli.database { - Some(path) => Manager::open(path)?, - None => Manager::open_default()?, - }; - match cli.command { - Command::Create { from, name, into } => { - println!( - "{}", - manager - .create(Create { - from: from.unwrap_or(std::env::current_dir()?), - name, - into, - })? - .display() - ); - } - Command::Remove { at } => manager.remove(at)?, - Command::Link { at, to } => manager.link(Link { at, to })?, - Command::Children { of } => { - for path in manager.children(of)? { - println!("{}", path.display()); - } - } - Command::Ancestors { of } => { - for path in manager.ancestors(of)? { - println!("{}", path.display()); - } - } - } - Ok(()) -} diff --git a/packages/worktree/crates/core/Cargo.toml b/packages/worktree/crates/core/Cargo.toml deleted file mode 100644 index be96fc46aa..0000000000 --- a/packages/worktree/crates/core/Cargo.toml +++ /dev/null @@ -1,17 +0,0 @@ -[package] -name = "worktree" -version.workspace = true -edition.workspace = true -license.workspace = true - -[dependencies] -dirs.workspace = true -filetime.workspace = true -libc.workspace = true -rusqlite.workspace = true -thiserror.workspace = true -ulid.workspace = true -walkdir.workspace = true - -[dev-dependencies] -tempfile.workspace = true diff --git a/packages/worktree/crates/core/src/copy.rs b/packages/worktree/crates/core/src/copy.rs deleted file mode 100644 index fa08c747c1..0000000000 --- a/packages/worktree/crates/core/src/copy.rs +++ /dev/null @@ -1,195 +0,0 @@ -use crate::{Error, Result}; -#[cfg(target_os = "linux")] -use filetime::{FileTime, set_file_times}; -use std::fs; -#[cfg(target_os = "linux")] -use std::fs::{File, OpenOptions}; -use std::path::Path; -#[cfg(any(target_os = "linux", test))] -use walkdir::WalkDir; - -pub(crate) trait CopyStrategy { - fn copy_directory(&self, from: &Path, to: &Path) -> Result<()>; -} - -pub(crate) struct CowStrategy; - -impl CopyStrategy for CowStrategy { - fn copy_directory(&self, from: &Path, to: &Path) -> Result<()> { - #[cfg(target_os = "linux")] - return copy_directory_linux(from, to); - - #[cfg(target_os = "macos")] - return copy_directory_macos(from, to); - - #[cfg(not(any(target_os = "linux", target_os = "macos")))] - { - let _ = (from, to); - Err(Error::CowUnavailable( - "no copy-on-write strategy has been implemented for this platform".into(), - )) - } - } -} - -#[cfg(target_os = "macos")] -fn copy_directory_macos(from: &Path, to: &Path) -> Result<()> { - use std::ffi::CString; - use std::os::unix::ffi::OsStrExt; - - let source = CString::new(from.as_os_str().as_bytes()) - .map_err(|_| Error::Path(format!("path contains a null byte: {}", from.display())))?; - let destination = CString::new(to.as_os_str().as_bytes()) - .map_err(|_| Error::Path(format!("path contains a null byte: {}", to.display())))?; - let result = unsafe { libc::clonefile(source.as_ptr(), destination.as_ptr(), 0) }; - if result == 0 { - return Ok(()); - } - Err(Error::CowUnavailable(format!( - "failed to clone {}: {}", - from.display(), - std::io::Error::last_os_error() - ))) -} - -#[cfg(target_os = "linux")] -fn copy_directory_linux(from: &Path, to: &Path) -> Result<()> { - fs::create_dir(to)?; - fs::set_permissions(to, fs::metadata(from)?.permissions())?; - - let entries = WalkDir::new(from) - .min_depth(1) - .follow_links(false) - .into_iter() - .collect::, _>>()?; - - for entry in &entries { - let relative = entry - .path() - .strip_prefix(from) - .map_err(|error| Error::Path(error.to_string()))?; - let destination = to.join(relative); - let metadata = fs::symlink_metadata(entry.path())?; - if metadata.is_dir() { - fs::create_dir(&destination)?; - fs::set_permissions(&destination, metadata.permissions())?; - continue; - } - if metadata.is_symlink() { - copy_symlink(entry.path(), &destination)?; - continue; - } - if !metadata.is_file() { - return Err(Error::UnsupportedEntry(entry.path().to_path_buf())); - } - reflink_file(entry.path(), &destination)?; - fs::set_permissions(&destination, metadata.permissions())?; - set_file_times( - &destination, - FileTime::from_last_access_time(&metadata), - FileTime::from_last_modification_time(&metadata), - )?; - } - - for entry in entries - .iter() - .rev() - .filter(|entry| entry.file_type().is_dir()) - { - let destination = to.join( - entry - .path() - .strip_prefix(from) - .map_err(|error| Error::Path(error.to_string()))?, - ); - let metadata = fs::metadata(entry.path())?; - set_file_times( - &destination, - FileTime::from_last_access_time(&metadata), - FileTime::from_last_modification_time(&metadata), - )?; - } - - let metadata = fs::metadata(from)?; - set_file_times( - to, - FileTime::from_last_access_time(&metadata), - FileTime::from_last_modification_time(&metadata), - )?; - Ok(()) -} - -#[cfg(target_os = "linux")] -fn reflink_file(from: &Path, to: &Path) -> Result<()> { - use std::os::fd::AsRawFd; - - const FICLONE: libc::c_ulong = 0x4004_9409; - let source = File::open(from)?; - let destination = OpenOptions::new().write(true).create_new(true).open(to)?; - let result = unsafe { libc::ioctl(destination.as_raw_fd(), FICLONE, source.as_raw_fd()) }; - if result == 0 { - return Ok(()); - } - let error = std::io::Error::last_os_error(); - Err(Error::CowUnavailable(format!( - "failed to reflink {}: {}", - from.display(), - error - ))) -} - -#[cfg(unix)] -fn copy_symlink(from: &Path, to: &Path) -> Result<()> { - std::os::unix::fs::symlink(fs::read_link(from)?, to)?; - Ok(()) -} - -#[cfg(windows)] -fn copy_symlink(from: &Path, to: &Path) -> Result<()> { - let target = fs::read_link(from)?; - if fs::metadata(from)?.is_dir() { - std::os::windows::fs::symlink_dir(target, to)?; - return Ok(()); - } - std::os::windows::fs::symlink_file(target, to)?; - Ok(()) -} - -#[cfg(test)] -pub(crate) struct TestStrategy; - -#[cfg(test)] -impl CopyStrategy for TestStrategy { - fn copy_directory(&self, from: &Path, to: &Path) -> Result<()> { - fs::create_dir(to)?; - for entry in WalkDir::new(from).min_depth(1).follow_links(false) { - let entry = entry?; - let destination = to.join( - entry - .path() - .strip_prefix(from) - .map_err(|error| Error::Path(error.to_string()))?, - ); - if entry.file_type().is_dir() { - fs::create_dir(&destination)?; - continue; - } - if entry.file_type().is_symlink() { - copy_symlink(entry.path(), &destination)?; - continue; - } - fs::copy(entry.path(), destination)?; - } - Ok(()) - } -} - -#[cfg(test)] -pub(crate) struct FailureStrategy; - -#[cfg(test)] -impl CopyStrategy for FailureStrategy { - fn copy_directory(&self, _from: &Path, _to: &Path) -> Result<()> { - Err(Error::CowUnavailable("test failure".into())) - } -} diff --git a/packages/worktree/crates/core/src/git.rs b/packages/worktree/crates/core/src/git.rs deleted file mode 100644 index ed6769caa9..0000000000 --- a/packages/worktree/crates/core/src/git.rs +++ /dev/null @@ -1,75 +0,0 @@ -use crate::{Error, Result}; -use std::fs; -use std::path::Path; -use std::process::Command; - -pub(crate) fn check_source(path: &Path) -> Result { - let git = path.join(".git"); - if !git.exists() { - return Ok(false); - } - if !git.is_dir() { - return Err(Error::UnsafeGit( - "linked Git worktree sources are not supported".into(), - )); - } - - for state in [ - "MERGE_HEAD", - "CHERRY_PICK_HEAD", - "REVERT_HEAD", - "BISECT_LOG", - "rebase-merge", - "rebase-apply", - "index.lock", - "HEAD.lock", - ] { - if git.join(state).exists() { - return Err(Error::UnsafeGit(format!("Git state in progress: {state}"))); - } - } - Ok(true) -} - -pub(crate) fn hide_marker(path: &Path) -> Result<()> { - let info = path.join(".git").join("info"); - fs::create_dir_all(&info)?; - let exclude = info.join("exclude"); - let existing = if exclude.exists() { - fs::read_to_string(&exclude)? - } else { - String::new() - }; - if existing.lines().any(|line| line.trim() == "/.worktree") { - return Ok(()); - } - let separator = if existing.is_empty() || existing.ends_with('\n') { - "" - } else { - "\n" - }; - fs::write(exclude, format!("{existing}{separator}/.worktree\n"))?; - Ok(()) -} - -pub(crate) fn detach_destination(path: &Path) -> Result<()> { - let head = Command::new("git") - .arg("-C") - .arg(path) - .args(["rev-parse", "--verify", "HEAD^{commit}"]) - .output()?; - if !head.status.success() { - return Ok(()); - } - let output = Command::new("git") - .arg("-C") - .arg(path) - .args(["switch", "--detach", "--quiet", "HEAD"]) - .output()?; - if output.status.success() { - return Ok(()); - } - Err(Error::UnsafeGit( - String::from_utf8_lossy(&output.stderr).trim().to_owned(), - )) -} diff --git a/packages/worktree/crates/core/src/lib.rs b/packages/worktree/crates/core/src/lib.rs deleted file mode 100644 index 9914a14bd8..0000000000 --- a/packages/worktree/crates/core/src/lib.rs +++ /dev/null @@ -1,712 +0,0 @@ -mod copy; -mod git; - -use copy::{CopyStrategy, CowStrategy}; -use rusqlite::{Connection, OptionalExtension, params}; -use std::fs; -use std::path::{Path, PathBuf}; -use thiserror::Error; -use ulid::Ulid; - -pub type Result = std::result::Result; - -#[derive(Debug, Error)] -pub enum Error { - #[error("{0}")] - Io(#[from] std::io::Error), - #[error("{0}")] - Database(#[from] rusqlite::Error), - #[error("{0}")] - Walk(#[from] walkdir::Error), - #[error("invalid path: {0}")] - Path(String), - #[error("copy-on-write cloning unavailable: {0}")] - CowUnavailable(String), - #[error("unsupported filesystem entry: {0}")] - UnsupportedEntry(PathBuf), - #[error("unsafe Git source: {0}")] - UnsafeGit(String), - #[error("worktree is not managed: {0}")] - NotManaged(PathBuf), - #[error("worktree marker does not match the registry at: {0}")] - MarkerMismatch(PathBuf), - #[error("worktree marker belongs to an unknown registry entry at: {0}")] - UnknownMarker(PathBuf), - #[error("worktree already exists: {0}")] - AlreadyExists(PathBuf), - #[error("cannot remove the original registered workspace: {0}")] - CannotRemoveRoot(PathBuf), - #[error("cannot reparent the original registered workspace: {0}")] - CannotLinkRoot(PathBuf), - #[error("cannot remove subtree while a recorded worktree path is missing: {0}")] - MissingWorktree(PathBuf), - #[error("cannot link a worktree to itself or its descendant")] - Cycle, - #[error("cannot copy a workspace into itself: {0}")] - InsideSource(PathBuf), -} - -pub struct Create { - pub from: PathBuf, - pub name: Option, - pub into: Option, -} - -pub struct Link { - pub at: PathBuf, - pub to: Option, -} - -#[derive(Clone)] -struct Record { - id: String, - parent_id: Option, - path: PathBuf, -} - -pub struct Manager { - database: Connection, - copier: Box, -} - -impl Manager { - pub fn open_default() -> Result { - let path = default_database_path()?; - if let Some(parent) = path.parent() { - fs::create_dir_all(parent)?; - } - Self::open(path) - } - - pub fn open(path: impl AsRef) -> Result { - Self::with_copier(path, Box::new(CowStrategy)) - } - - fn with_copier(path: impl AsRef, copier: Box) -> Result { - let database = Connection::open(path)?; - database.execute_batch( - "PRAGMA foreign_keys = ON; - CREATE TABLE IF NOT EXISTS worktree ( - id TEXT PRIMARY KEY, - parent_id TEXT REFERENCES worktree(id) ON DELETE CASCADE, - path TEXT NOT NULL UNIQUE, - created_at INTEGER NOT NULL - ); - CREATE INDEX IF NOT EXISTS worktree_parent_id_idx ON worktree(parent_id);", - )?; - Ok(Self { database, copier }) - } - - pub fn create(&mut self, input: Create) -> Result { - let from = existing_directory(&input.from)?; - let git = git::check_source(&from)?; - let (source, register_source) = self.source(&from)?; - let root = self.root(&source)?; - let id = Ulid::new().to_string(); - let destination_parent = match input.into { - Some(path) => absolute_path(&path)?, - None => default_storage(&root.path)?, - }; - let name = destination_name(input.name, &id)?; - if destination_parent.join(&name).starts_with(&from) { - return Err(Error::InsideSource(destination_parent.join(name))); - } - fs::create_dir_all(&destination_parent)?; - let destination_parent = fs::canonicalize(destination_parent)?; - let destination = destination_parent.join(name); - if destination.starts_with(&from) { - return Err(Error::InsideSource(destination)); - } - if destination.exists() { - return Err(Error::AlreadyExists(destination)); - } - - if let Err(error) = self.copier.copy_directory(&from, &destination) { - let _ = fs::remove_dir_all(&destination); - return Err(error); - } - - let result = (|| { - write_marker(&destination, &id)?; - if git { - git::hide_marker(&destination)?; - git::detach_destination(&destination)?; - } - if register_source { - write_marker(&from, &source.id)?; - self.database.execute( - "INSERT INTO worktree (id, parent_id, path, created_at) VALUES (?1, NULL, ?2, ?3)", - params![source.id, path_text(&from)?, timestamp()], - )?; - } - if git { - git::hide_marker(&from)?; - } - self.database.execute( - "INSERT INTO worktree (id, parent_id, path, created_at) VALUES (?1, ?2, ?3, ?4)", - params![id, source.id, path_text(&destination)?, timestamp()], - )?; - Ok(destination.clone()) - })(); - if result.is_err() { - let _ = fs::remove_dir_all(&destination); - } - result - } - - pub fn remove(&mut self, at: impl AsRef) -> Result<()> { - let at = existing_directory(at.as_ref())?; - let record = self.record_at(&at)?; - if record.parent_id.is_none() { - return Err(Error::CannotRemoveRoot(at)); - } - verify_marker(&record)?; - let mut statement = self.database.prepare( - "WITH RECURSIVE subtree(id, path, depth) AS ( - SELECT id, path, 0 FROM worktree WHERE id = ?1 - UNION ALL - SELECT worktree.id, worktree.path, subtree.depth + 1 - FROM worktree JOIN subtree ON worktree.parent_id = subtree.id - ) SELECT id, path, depth FROM subtree ORDER BY depth DESC", - )?; - let rows = statement - .query_map([&record.id], |row| { - Ok(( - row.get::<_, String>(0)?, - PathBuf::from(row.get::<_, String>(1)?), - row.get::<_, i64>(2)?, - )) - })? - .collect::, _>>()?; - drop(statement); - for (id, path, _) in &rows { - if !path.exists() { - return Err(Error::MissingWorktree(path.clone())); - } - verify_marker(&Record { - id: id.clone(), - parent_id: None, - path: path.clone(), - })?; - } - for (id, path, _) in &rows { - fs::remove_dir_all(path)?; - self.database - .execute("DELETE FROM worktree WHERE id = ?1", [id])?; - } - Ok(()) - } - - pub fn link(&mut self, input: Link) -> Result<()> { - let at = existing_directory(&input.at)?; - let record = match read_marker(&at)? { - Some(id) => { - let record = self - .record_id(&id)? - .ok_or_else(|| Error::UnknownMarker(at.clone()))?; - if record.path != at { - if record.path.exists() { - return Err(Error::MarkerMismatch(at)); - } - self.database.execute( - "UPDATE worktree SET path = ?1 WHERE id = ?2", - params![path_text(&at)?, record.id], - )?; - } - Record { - path: at.clone(), - ..record - } - } - None => { - let record = self.record_at(&at)?; - write_marker(&at, &record.id)?; - record - } - }; - if at.join(".git").is_dir() { - git::hide_marker(&at)?; - } - let Some(to) = input.to else { - return Ok(()); - }; - if record.parent_id.is_none() { - return Err(Error::CannotLinkRoot(at)); - } - let parent = self.record_at(&existing_directory(&to)?)?; - if parent.id == record.id || self.is_descendant(&parent.id, &record.id)? { - return Err(Error::Cycle); - } - self.database.execute( - "UPDATE worktree SET parent_id = ?1 WHERE id = ?2", - params![parent.id, record.id], - )?; - Ok(()) - } - - pub fn children(&self, of: impl AsRef) -> Result> { - let record = self.record_at(&existing_directory(of.as_ref())?)?; - let mut statement = self - .database - .prepare("SELECT path FROM worktree WHERE parent_id = ?1 ORDER BY created_at, id")?; - Ok(statement - .query_map([record.id], |row| { - Ok(PathBuf::from(row.get::<_, String>(0)?)) - })? - .collect::, _>>()?) - } - - pub fn ancestors(&self, of: impl AsRef) -> Result> { - let record = self.record_at(&existing_directory(of.as_ref())?)?; - let mut paths = Vec::new(); - let mut parent_id = record.parent_id; - while let Some(id) = parent_id { - let parent = self - .record_id(&id)? - .ok_or_else(|| Error::NotManaged(record.path.clone()))?; - paths.push(parent.path); - parent_id = parent.parent_id; - } - Ok(paths) - } - - fn source(&self, path: &Path) -> Result<(Record, bool)> { - if let Some(id) = read_marker(path)? { - let record = self - .record_id(&id)? - .ok_or_else(|| Error::UnknownMarker(path.to_path_buf()))?; - if record.path != path { - return Err(Error::MarkerMismatch(path.to_path_buf())); - } - return Ok((record, false)); - } - if self.record_at_optional(path)?.is_some() { - return Err(Error::MarkerMismatch(path.to_path_buf())); - } - let id = Ulid::new().to_string(); - Ok(( - Record { - id, - parent_id: None, - path: path.to_path_buf(), - }, - true, - )) - } - - fn root(&self, record: &Record) -> Result { - let mut current = record.clone(); - while let Some(id) = current.parent_id.clone() { - current = self - .record_id(&id)? - .ok_or_else(|| Error::NotManaged(record.path.clone()))?; - } - Ok(current) - } - - fn record_at(&self, path: &Path) -> Result { - self.record_at_optional(path)? - .ok_or_else(|| Error::NotManaged(path.to_path_buf())) - } - - fn record_at_optional(&self, path: &Path) -> Result> { - self.database - .query_row( - "SELECT id, parent_id, path FROM worktree WHERE path = ?1", - [path_text(path)?], - |row| { - Ok(Record { - id: row.get(0)?, - parent_id: row.get(1)?, - path: PathBuf::from(row.get::<_, String>(2)?), - }) - }, - ) - .optional() - .map_err(Error::from) - } - - fn record_id(&self, id: &str) -> Result> { - self.database - .query_row( - "SELECT id, parent_id, path FROM worktree WHERE id = ?1", - [id], - |row| { - Ok(Record { - id: row.get(0)?, - parent_id: row.get(1)?, - path: PathBuf::from(row.get::<_, String>(2)?), - }) - }, - ) - .optional() - .map_err(Error::from) - } - - fn is_descendant(&self, candidate: &str, of: &str) -> Result { - Ok(self.database.query_row( - "WITH RECURSIVE descendants(id) AS ( - SELECT id FROM worktree WHERE parent_id = ?1 - UNION ALL - SELECT worktree.id FROM worktree JOIN descendants ON worktree.parent_id = descendants.id - ) SELECT EXISTS(SELECT 1 FROM descendants WHERE id = ?2)", - params![of, candidate], - |row| row.get(0), - )?) - } -} - -fn default_database_path() -> Result { - let base = dirs::data_local_dir() - .ok_or_else(|| Error::Path("user data directory is unavailable".into()))?; - Ok(base.join("worktree").join("worktree.sqlite")) -} - -fn existing_directory(path: &Path) -> Result { - let path = fs::canonicalize(path)?; - if !path.is_dir() { - return Err(Error::Path(format!("not a directory: {}", path.display()))); - } - Ok(path) -} - -fn absolute_path(path: &Path) -> Result { - if path.is_absolute() { - return Ok(path.to_path_buf()); - } - Ok(std::env::current_dir()?.join(path)) -} - -fn default_storage(root: &Path) -> Result { - let parent = root - .parent() - .ok_or_else(|| Error::Path(format!("workspace has no parent: {}", root.display())))?; - let name = root - .file_name() - .ok_or_else(|| Error::Path(format!("workspace has no name: {}", root.display())))?; - Ok(parent.join(".worktrees").join(name)) -} - -fn destination_name(name: Option, id: &str) -> Result { - let name = name.unwrap_or_else(|| id.to_owned()); - if name.is_empty() || name == "." || name == ".." || Path::new(&name).components().count() != 1 - { - return Err(Error::Path(format!("invalid worktree name: {name}"))); - } - Ok(name) -} - -fn marker(path: &Path) -> PathBuf { - path.join(".worktree") -} - -fn write_marker(path: &Path, id: &str) -> Result<()> { - fs::write(marker(path), format!("{id}\n"))?; - Ok(()) -} - -fn read_marker(path: &Path) -> Result> { - let marker = marker(path); - if !marker.exists() { - return Ok(None); - } - Ok(Some(fs::read_to_string(marker)?.trim().to_owned())) -} - -fn verify_marker(record: &Record) -> Result<()> { - if read_marker(&record.path)?.as_deref() == Some(&record.id) { - return Ok(()); - } - Err(Error::MarkerMismatch(record.path.clone())) -} - -fn path_text(path: &Path) -> Result { - path.to_str() - .map(ToOwned::to_owned) - .ok_or_else(|| Error::Path(format!("path is not valid UTF-8: {}", path.display()))) -} - -fn timestamp() -> i64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_millis() as i64 -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::copy::{FailureStrategy, TestStrategy}; - use std::process::Command; - use tempfile::TempDir; - - fn manager(temp: &TempDir) -> Manager { - Manager::with_copier(temp.path().join("registry.sqlite"), Box::new(TestStrategy)).unwrap() - } - - fn source(temp: &TempDir) -> PathBuf { - let source = temp.path().join("app"); - fs::create_dir(&source).unwrap(); - fs::write(source.join("file.txt"), "hello").unwrap(); - source - } - - #[test] - fn create_tracks_parentage_and_default_storage() { - let temp = TempDir::new().unwrap(); - let source = source(&temp); - let mut manager = manager(&temp); - let first = manager - .create(Create { - from: source.clone(), - name: Some("first".into()), - into: None, - }) - .unwrap(); - let second = manager - .create(Create { - from: first.clone(), - name: Some("second".into()), - into: None, - }) - .unwrap(); - - assert_eq!(first, temp.path().join(".worktrees/app/first")); - assert_eq!(second, temp.path().join(".worktrees/app/second")); - assert_ne!( - fs::read_to_string(source.join(".worktree")).unwrap(), - fs::read_to_string(first.join(".worktree")).unwrap() - ); - assert_eq!(manager.children(&source).unwrap(), vec![first.clone()]); - assert_eq!(manager.ancestors(&second).unwrap(), vec![first, source]); - } - - #[test] - fn remove_deletes_a_full_subtree() { - let temp = TempDir::new().unwrap(); - let source = source(&temp); - let mut manager = manager(&temp); - let first = manager - .create(Create { - from: source.clone(), - name: Some("first".into()), - into: None, - }) - .unwrap(); - let second = manager - .create(Create { - from: first.clone(), - name: Some("second".into()), - into: None, - }) - .unwrap(); - - manager.remove(&first).unwrap(); - - assert!(!first.exists()); - assert!(!second.exists()); - assert!(manager.children(&source).unwrap().is_empty()); - assert!(matches!( - manager.remove(&source), - Err(Error::CannotRemoveRoot(_)) - )); - } - - #[test] - fn remove_refuses_a_subtree_with_an_unlinked_move() { - let temp = TempDir::new().unwrap(); - let source = source(&temp); - let mut manager = manager(&temp); - let first = manager - .create(Create { - from: source, - name: Some("first".into()), - into: None, - }) - .unwrap(); - let second = manager - .create(Create { - from: first.clone(), - name: Some("second".into()), - into: None, - }) - .unwrap(); - fs::rename(&second, temp.path().join("moved")).unwrap(); - - assert!(matches!( - manager.remove(&first), - Err(Error::MissingWorktree(_)) - )); - assert!(first.exists()); - } - - #[test] - fn link_restores_moves_markers_and_reparents() { - let temp = TempDir::new().unwrap(); - let source = source(&temp); - let mut manager = manager(&temp); - let first = manager - .create(Create { - from: source.clone(), - name: Some("first".into()), - into: None, - }) - .unwrap(); - let second = manager - .create(Create { - from: source.clone(), - name: Some("second".into()), - into: None, - }) - .unwrap(); - let moved = temp.path().join("moved"); - fs::rename(&second, &moved).unwrap(); - - manager - .link(Link { - at: moved.clone(), - to: Some(first.clone()), - }) - .unwrap(); - assert_eq!( - manager.ancestors(&moved).unwrap(), - vec![first, source.clone()] - ); - - fs::remove_file(source.join(".worktree")).unwrap(); - manager - .link(Link { - at: source.clone(), - to: None, - }) - .unwrap(); - assert!(source.join(".worktree").exists()); - } - - #[test] - fn link_does_not_reparent_a_registered_source() { - let temp = TempDir::new().unwrap(); - let source = source(&temp); - let mut manager = manager(&temp); - let child = manager - .create(Create { - from: source.clone(), - name: Some("child".into()), - into: None, - }) - .unwrap(); - - assert!(matches!( - manager.link(Link { - at: source.clone(), - to: Some(child), - }), - Err(Error::CannotLinkRoot(_)) - )); - assert!(matches!( - manager.remove(&source), - Err(Error::CannotRemoveRoot(_)) - )); - } - - #[test] - fn git_copy_detaches_head_and_preserves_dirty_state() { - let temp = TempDir::new().unwrap(); - let source = source(&temp); - run(&source, &["init"]); - run(&source, &["config", "user.email", "test@example.com"]); - run(&source, &["config", "user.name", "Test"]); - run(&source, &["add", "file.txt"]); - run(&source, &["commit", "-m", "initial"]); - fs::write(source.join("file.txt"), "changed").unwrap(); - run(&source, &["add", "file.txt"]); - fs::write(source.join("untracked.txt"), "new").unwrap(); - let mut manager = manager(&temp); - - let destination = manager - .create(Create { - from: source.clone(), - name: Some("git".into()), - into: None, - }) - .unwrap(); - - assert!( - !Command::new("git") - .arg("-C") - .arg(&destination) - .args(["symbolic-ref", "-q", "HEAD"]) - .status() - .unwrap() - .success() - ); - let staged = Command::new("git") - .arg("-C") - .arg(&destination) - .args(["diff", "--cached", "--name-only"]) - .output() - .unwrap(); - assert!(String::from_utf8_lossy(&staged.stdout).contains("file.txt")); - assert!(destination.join("untracked.txt").exists()); - let status = Command::new("git") - .arg("-C") - .arg(&destination) - .args(["status", "--porcelain", "--", ".worktree"]) - .output() - .unwrap(); - assert!(status.stdout.is_empty()); - } - - #[test] - fn unsafe_git_source_is_rejected_without_registering_it() { - let temp = TempDir::new().unwrap(); - let source = source(&temp); - run(&source, &["init"]); - fs::write(source.join(".git/MERGE_HEAD"), "commit").unwrap(); - let mut manager = manager(&temp); - - assert!(matches!( - manager.create(Create { - from: source.clone(), - name: Some("unsafe".into()), - into: None, - }), - Err(Error::UnsafeGit(_)) - )); - assert!(!source.join(".worktree").exists()); - } - - #[test] - fn unavailable_cow_does_not_register_the_source() { - let temp = TempDir::new().unwrap(); - let source = source(&temp); - let mut manager = Manager::with_copier( - temp.path().join("registry.sqlite"), - Box::new(FailureStrategy), - ) - .unwrap(); - - assert!(matches!( - manager.create(Create { - from: source.clone(), - name: Some("failure".into()), - into: None, - }), - Err(Error::CowUnavailable(_)) - )); - assert!(!source.join(".worktree").exists()); - assert!(manager.record_at_optional(&source).unwrap().is_none()); - } - - fn run(path: &Path, args: &[&str]) { - assert!( - Command::new("git") - .arg("-C") - .arg(path) - .args(args) - .status() - .unwrap() - .success() - ); - } -} diff --git a/packages/worktree/specs.md b/packages/worktree/specs.md deleted file mode 100644 index 61c3758b0b..0000000000 --- a/packages/worktree/specs.md +++ /dev/null @@ -1,182 +0,0 @@ -# Worktree Specs - -## Requirement - -`worktree` must be cross-platform as far as practical. Core semantics should work across macOS, Linux, and Windows. Copy-on-write is a platform/filesystem acceleration and must not define the product model. - -## API - -### `create` - -```ts -create(input: { - from: AbsolutePath - name?: string - into?: AbsolutePath -}): AbsolutePath -``` - -Default behavior: - -- Source is `from`. -- `name` defaults to a generated directory name. -- `into` defaults to the managed worktree directory. -- Copy the whole workspace, including dirty, staged, untracked, and ignored files. -- Detach `HEAD` in the new workspace. -- Return the path of the new workspace. - -If `from` is already a managed worktree, create copies that exact worktree. Do not resolve back to an earlier workspace. Metadata should record the immediate source worktree as its parent. - -Default storage is a hidden sibling directory of the original registered workspace: - -```text -/projects/app/ original workspace -/projects/.worktrees/app/task-a/ created worktree -/projects/.worktrees/app/task-b/ created worktree -``` - -- Created worktrees must not be stored inside the workspace being copied, because an exact copy would recursively contain existing worktrees. -- If `from` is an original unregistered workspace, its sibling `.worktrees//` directory becomes the default destination directory. -- If `from` is already managed, descendants use the default destination directory associated with the original workspace rather than nesting storage beside each descendant. -- If `into` is provided, use it instead of the default destination directory. -- If the original workspace is itself a filesystem mount root, its sibling default destination may not support copy-on-write with it; provide `into` on the same filesystem in that case. - -### `remove` - -```ts -remove(input: { - at: AbsolutePath -}): void -``` - -`remove` deletes a managed worktree and its full descendant subtree. - -- `at` must identify a worktree created by this tool; the registered source root cannot be removed. -- Resolve all descendants through `parent_id` and remove their directories deepest-first. -- Verify each existing directory's `.worktree` marker before deleting it. -- Refuse removal if any descendant path is missing, because it may be a moved workspace that has not been linked yet. -- After successful filesystem removal, delete the subtree records from the database. - -### `link` - -```ts -link(input: { - at: AbsolutePath - to?: AbsolutePath -}): void -``` - -`link` reconnects a moved managed worktree to its registry record and can change its parent. - -- Read the ULID from `.worktree` at `at`. -- Look up the existing worktree record by ULID. -- If its recorded path is `at`, leave its location unchanged. -- If its recorded path is different and missing, update it to `at`. -- If its recorded path is different and still exists, fail because this is a duplicate identity, not a move. -- If the ULID is unknown to the database, fail; `.worktree` alone does not include the ancestry needed to rebuild the record. -- If `.worktree` is missing, look up `at` by its absolute path. If it matches an existing record, recreate the marker with that record's ULID. -- If `.worktree` is missing and `at` does not match an existing record, fail. A moved workspace without its marker cannot be identified safely. -- If `to` is provided, set the worktree's parent to the managed worktree at `to`. -- Refuse `to` for an original registered workspace; only worktrees created by this tool can be reparented. -- Refuse `to` if it is `at` or a descendant of `at`, because reparenting must not create a cycle. - -### `children` - -```ts -children(input: { - of: AbsolutePath -}): AbsolutePath[] -``` - -`children` returns the direct managed children created from `of`. - -### `ancestors` - -```ts -ancestors(input: { - of: AbsolutePath -}): AbsolutePath[] -``` - -`ancestors` returns the managed ancestry of `of`, ordered from its immediate parent to the root workspace. - -## Metadata - -Metadata is stored in a central SQLite database in the platform-appropriate user data directory. - -SQLite is not overkill: multiple processes and agents may create, inspect, or remove worktrees concurrently. It provides cross-platform transactions and locking without building a safe JSON registry protocol. - -Start with one table: - -```sql -CREATE TABLE worktree ( - id TEXT PRIMARY KEY, - parent_id TEXT REFERENCES worktree(id) ON DELETE CASCADE, - path TEXT NOT NULL UNIQUE, - created_at INTEGER NOT NULL -); - -CREATE INDEX worktree_parent_id_idx ON worktree(parent_id); -``` - -- Every managed worktree has a stable generated `id`. -- `id` is a ULID generated when the workspace is first registered or created. -- `id` is stored in the central database and in a `.worktree` marker file at the root of the workspace. -- `.worktree` contains the worktree ULID and allows a moved workspace to be rediscovered and verified against the database. -- When a managed workspace is copied, the copied `.worktree` marker is replaced with the new workspace's ULID. -- The original registered workspace has `parent_id = NULL`. -- A created worktree has `parent_id` set to the source worktree `id`. -- `path` is its current location, not its identity. -- Provenance is a rooted tree. Descendants of any worktree can be listed through recursive queries over `parent_id`. -- `remove` deletes a whole subtree, so no surviving record depends on deleted ancestry. - -### Moved Worktrees - -If a worktree is moved outside the tool, its recorded path becomes missing. The tool cannot discover an arbitrary new location without being given a path or scanning a configured directory. - -When `link` is run against a directory containing `.worktree`, the tool reads its ULID and reconciles the database path if the recorded path no longer exists. - -If both the recorded path and the provided path exist with the same ULID, the tool must refuse automatic reconciliation because the directory was copied without assigning a new identity. - -## Git Integration - -Git support is an integration for directories that contain repositories; it does not define the core worktree model. - -When registering or creating from a Git repository: - -- Add `/.worktree` to `.git/info/exclude` so the identity marker does not appear in local Git status. -- Copy the directory with its staged, unstaged, untracked, ignored, and cached state intact. -- If `HEAD` resolves to a commit, detach `HEAD` in the created destination at that same commit. -- Preserve the copied index and working tree state while detaching. -- If the repository has no commits yet, leave its unborn branch state unchanged because there is no commit to detach to. - -Refuse creation from a Git repository when: - -- It is a linked Git worktree whose `.git` is not an independent directory. -- A merge, rebase, cherry-pick, revert, or bisect is in progress. -- Git lock or inconsistent index state makes an exact safe copy unclear. - -The tool does not create branches, commit changes, or otherwise replace normal Git commands. - -## Copy Strategies - -Copying is implemented behind a strategy boundary so platform-specific copy-on-write backends can be added independently. - -- The production strategy on Linux uses reflink cloning. -- The production strategy on macOS uses APFS `clonefile` directory cloning. -- If no implemented copy-on-write strategy succeeds, `create` fails. -- Full byte copying is not implemented as a fallback. -- Future strategies may add Windows copy-on-write support without changing the API. - -## Packaging - -The project ships four interfaces backed by the same implementation and metadata model: - -1. Native library containing the core API and implementation. -2. CLI package providing the `worktree` executable. -3. Bun FFI package for use from Bun applications. -4. Node FFI package for use from Node.js applications. - -The CLI and language bindings should remain thin and expose the same API semantics as the native library. - -For CLI ergonomics, `worktree create` defaults `from` to the current working directory when no source path is provided. From 8f2afba7a58197fa5d120fe31025663363806f77 Mon Sep 17 00:00:00 2001 From: Frank Date: Sun, 31 May 2026 13:59:44 -0400 Subject: [PATCH 041/840] zen: deepseek flash --- packages/web/src/content/docs/ar/go.mdx | 19 ++++++++++++++++++- packages/web/src/content/docs/ar/zen.mdx | 3 +++ packages/web/src/content/docs/bs/go.mdx | 17 +++++++++++++++++ packages/web/src/content/docs/bs/zen.mdx | 3 +++ packages/web/src/content/docs/da/go.mdx | 17 +++++++++++++++++ packages/web/src/content/docs/da/zen.mdx | 3 +++ packages/web/src/content/docs/de/go.mdx | 17 +++++++++++++++++ packages/web/src/content/docs/de/zen.mdx | 3 +++ packages/web/src/content/docs/es/go.mdx | 17 +++++++++++++++++ packages/web/src/content/docs/es/zen.mdx | 3 +++ packages/web/src/content/docs/fr/go.mdx | 19 ++++++++++++++++++- packages/web/src/content/docs/fr/zen.mdx | 3 +++ packages/web/src/content/docs/go.mdx | 19 ++++++++++++++++++- packages/web/src/content/docs/it/go.mdx | 17 +++++++++++++++++ packages/web/src/content/docs/it/zen.mdx | 3 +++ packages/web/src/content/docs/ja/go.mdx | 17 +++++++++++++++++ packages/web/src/content/docs/ja/zen.mdx | 3 +++ packages/web/src/content/docs/ko/go.mdx | 19 ++++++++++++++++++- packages/web/src/content/docs/ko/zen.mdx | 3 +++ packages/web/src/content/docs/nb/go.mdx | 17 +++++++++++++++++ packages/web/src/content/docs/nb/zen.mdx | 3 +++ packages/web/src/content/docs/pl/go.mdx | 19 ++++++++++++++++++- packages/web/src/content/docs/pl/zen.mdx | 3 +++ packages/web/src/content/docs/pt-br/go.mdx | 19 ++++++++++++++++++- packages/web/src/content/docs/pt-br/zen.mdx | 3 +++ packages/web/src/content/docs/ru/go.mdx | 19 ++++++++++++++++++- packages/web/src/content/docs/ru/zen.mdx | 3 +++ packages/web/src/content/docs/th/go.mdx | 19 ++++++++++++++++++- packages/web/src/content/docs/th/zen.mdx | 3 +++ packages/web/src/content/docs/tr/go.mdx | 19 ++++++++++++++++++- packages/web/src/content/docs/tr/zen.mdx | 3 +++ packages/web/src/content/docs/zen.mdx | 3 +++ packages/web/src/content/docs/zh-cn/go.mdx | 17 +++++++++++++++++ packages/web/src/content/docs/zh-cn/zen.mdx | 3 +++ packages/web/src/content/docs/zh-tw/go.mdx | 19 ++++++++++++++++++- packages/web/src/content/docs/zh-tw/zen.mdx | 3 +++ 36 files changed, 370 insertions(+), 10 deletions(-) diff --git a/packages/web/src/content/docs/ar/go.mdx b/packages/web/src/content/docs/ar/go.mdx index e764ff502a..faa354ba23 100644 --- a/packages/web/src/content/docs/ar/go.mdx +++ b/packages/web/src/content/docs/ar/go.mdx @@ -93,7 +93,7 @@ OpenCode Go هو اشتراك منخفض التكلفة — **$5 للشهر ال | DeepSeek V4 Pro | 3,450 | 8,550 | 17,150 | | DeepSeek V4 Flash | 31,650 | 79,050 | 158,150 | -تستند التقديرات إلى متوسطات أنماط الطلبات المرصودة: +تستند التقديرات إلى متوسط أنماط الطلبات المرصودة: - GLM-5/5.1 — ‏700 input، و52,000 cached، و150 output tokens لكل طلب - Kimi K2.5/K2.6 — ‏870 input، و55,000 cached، و200 output tokens لكل طلب @@ -105,6 +105,23 @@ OpenCode Go هو اشتراك منخفض التكلفة — **$5 للشهر ال - MiMo-V2.5 — ‏830 input، و71,500 cached، و295 output tokens لكل طلب - MiMo-V2.5-Pro — ‏790 input، و86,000 cached، و305 output tokens لكل طلب +تستند التقديرات أيضًا إلى الأسعار التالية لكل 1M tokens: + +| النموذج | الإدخال | الإخراج | القراءة المخزنة | الكتابة المخزنة | +| ----------------- | ------- | ------- | --------------- | --------------- | +| GLM-5.1 | $1.40 | $4.40 | $0.26 | - | +| GLM-5 | $1.00 | $3.20 | $0.20 | - | +| Kimi K2.6 | $0.95 | $4.00 | $0.16 | - | +| Kimi K2.5 | $0.60 | $3.00 | $0.10 | - | +| MiMo V2.5 | $0.14 | $0.28 | $0.0028 | - | +| MiMo V2.5 Pro | $1.74 | $3.48 | $0.0145 | - | +| MiniMax M2.7 | $0.30 | $1.20 | $0.06 | $0.375 | +| MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 | +| Qwen3.7 Max | $2.50 | $7.50 | $0.50 | $3.125 | +| Qwen3.6 Plus | $0.50 | $3.00 | $0.05 | $0.625 | +| DeepSeek V4 Pro | $1.74 | $3.48 | $0.0145 | - | +| DeepSeek V4 Flash | $0.14 | $0.28 | $0.0028 | - | + يمكنك تتبّع استخدامك الحالي في **console**. :::tip diff --git a/packages/web/src/content/docs/ar/zen.mdx b/packages/web/src/content/docs/ar/zen.mdx index 0be4bc7a5a..cc69e7ce06 100644 --- a/packages/web/src/content/docs/ar/zen.mdx +++ b/packages/web/src/content/docs/ar/zen.mdx @@ -85,8 +85,10 @@ OpenCode Zen هي بوابة AI تتيح لك الوصول إلى هذه الن | Gemini 3.5 Flash | gemini-3.5-flash | `https://opencode.ai/zen/v1/models/gemini-3.5-flash` | `@ai-sdk/google` | | Gemini 3.1 Pro | gemini-3.1-pro | `https://opencode.ai/zen/v1/models/gemini-3.1-pro` | `@ai-sdk/google` | | Gemini 3 Flash | gemini-3-flash | `https://opencode.ai/zen/v1/models/gemini-3-flash` | `@ai-sdk/google` | +| Qwen3.7 Max | qwen3.7-max | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| DeepSeek V4 Flash | deepseek-v4-flash | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | GLM 5.1 | glm-5.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | @@ -132,6 +134,7 @@ https://opencode.ai/zen/v1/models | Qwen3.7 Max | $2.50 | $7.50 | $0.50 | $3.125 | | Qwen3.6 Plus | $0.50 | $3.00 | $0.05 | $0.625 | | Qwen3.5 Plus | $0.20 | $1.20 | $0.02 | $0.25 | +| DeepSeek V4 Flash | $0.14 | $0.28 | $0.03 | - | | Grok Build 0.1 | $1.00 | $2.00 | $0.20 | - | | Claude Opus 4.8 | $5.00 | $25.00 | $0.50 | $6.25 | | Claude Opus 4.7 | $5.00 | $25.00 | $0.50 | $6.25 | diff --git a/packages/web/src/content/docs/bs/go.mdx b/packages/web/src/content/docs/bs/go.mdx index 3ddf637f36..8cd0491d6f 100644 --- a/packages/web/src/content/docs/bs/go.mdx +++ b/packages/web/src/content/docs/bs/go.mdx @@ -115,6 +115,23 @@ Procjene se zasnivaju na zapaženim prosječnim obrascima zahtjeva: - MiMo-V2.5 — 830 ulaznih, 71,500 keširanih, 295 izlaznih tokena po zahtjevu - MiMo-V2.5-Pro — 790 ulaznih, 86,000 keširanih, 305 izlaznih tokena po zahtjevu +Procjene se također zasnivaju na sljedećim cijenama po 1M tokena: + +| Model | Input | Output | Cached Read | Cached Write | +| ----------------- | ----- | ------ | ----------- | ------------ | +| GLM-5.1 | $1.40 | $4.40 | $0.26 | - | +| GLM-5 | $1.00 | $3.20 | $0.20 | - | +| Kimi K2.6 | $0.95 | $4.00 | $0.16 | - | +| Kimi K2.5 | $0.60 | $3.00 | $0.10 | - | +| MiMo V2.5 | $0.14 | $0.28 | $0.0028 | - | +| MiMo V2.5 Pro | $1.74 | $3.48 | $0.0145 | - | +| MiniMax M2.7 | $0.30 | $1.20 | $0.06 | $0.375 | +| MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 | +| Qwen3.7 Max | $2.50 | $7.50 | $0.50 | $3.125 | +| Qwen3.6 Plus | $0.50 | $3.00 | $0.05 | $0.625 | +| DeepSeek V4 Pro | $1.74 | $3.48 | $0.0145 | - | +| DeepSeek V4 Flash | $0.14 | $0.28 | $0.0028 | - | + Svoju trenutnu potrošnju možete pratiti u **konzoli**. :::tip diff --git a/packages/web/src/content/docs/bs/zen.mdx b/packages/web/src/content/docs/bs/zen.mdx index 465730f490..1659748fc3 100644 --- a/packages/web/src/content/docs/bs/zen.mdx +++ b/packages/web/src/content/docs/bs/zen.mdx @@ -90,8 +90,10 @@ Našim modelima možete pristupiti i preko sljedećih API endpointa. | Gemini 3.5 Flash | gemini-3.5-flash | `https://opencode.ai/zen/v1/models/gemini-3.5-flash` | `@ai-sdk/google` | | Gemini 3.1 Pro | gemini-3.1-pro | `https://opencode.ai/zen/v1/models/gemini-3.1-pro` | `@ai-sdk/google` | | Gemini 3 Flash | gemini-3-flash | `https://opencode.ai/zen/v1/models/gemini-3-flash` | `@ai-sdk/google` | +| Qwen3.7 Max | qwen3.7-max | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| DeepSeek V4 Flash | deepseek-v4-flash | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | GLM 5.1 | glm-5.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | @@ -139,6 +141,7 @@ Podržavamo pay-as-you-go model. Ispod su cijene **po 1M tokena**. | Qwen3.7 Max | $2.50 | $7.50 | $0.50 | $3.125 | | Qwen3.6 Plus | $0.50 | $3.00 | $0.05 | $0.625 | | Qwen3.5 Plus | $0.20 | $1.20 | $0.02 | $0.25 | +| DeepSeek V4 Flash | $0.14 | $0.28 | $0.03 | - | | Grok Build 0.1 | $1.00 | $2.00 | $0.20 | - | | Claude Opus 4.8 | $5.00 | $25.00 | $0.50 | $6.25 | | Claude Opus 4.7 | $5.00 | $25.00 | $0.50 | $6.25 | diff --git a/packages/web/src/content/docs/da/go.mdx b/packages/web/src/content/docs/da/go.mdx index 371234b1e1..a64de349d4 100644 --- a/packages/web/src/content/docs/da/go.mdx +++ b/packages/web/src/content/docs/da/go.mdx @@ -115,6 +115,23 @@ Estimaterne er baseret på observerede gennemsnitlige anmodningsmønstre: - MiMo-V2.5 — 830 input, 71.500 cachelagrede, 295 output-tokens pr. anmodning - MiMo-V2.5-Pro — 790 input, 86.000 cachelagrede, 305 output-tokens pr. anmodning +Estimaterne er også baseret på følgende priser pr. 1M tokens: + +| Model | Input | Output | Cached Read | Cached Write | +| ----------------- | ----- | ------ | ----------- | ------------ | +| GLM-5.1 | $1.40 | $4.40 | $0.26 | - | +| GLM-5 | $1.00 | $3.20 | $0.20 | - | +| Kimi K2.6 | $0.95 | $4.00 | $0.16 | - | +| Kimi K2.5 | $0.60 | $3.00 | $0.10 | - | +| MiMo V2.5 | $0.14 | $0.28 | $0.0028 | - | +| MiMo V2.5 Pro | $1.74 | $3.48 | $0.0145 | - | +| MiniMax M2.7 | $0.30 | $1.20 | $0.06 | $0.375 | +| MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 | +| Qwen3.7 Max | $2.50 | $7.50 | $0.50 | $3.125 | +| Qwen3.6 Plus | $0.50 | $3.00 | $0.05 | $0.625 | +| DeepSeek V4 Pro | $1.74 | $3.48 | $0.0145 | - | +| DeepSeek V4 Flash | $0.14 | $0.28 | $0.0028 | - | + Du kan spore dit nuværende forbrug i **konsollen**. :::tip diff --git a/packages/web/src/content/docs/da/zen.mdx b/packages/web/src/content/docs/da/zen.mdx index a577b82c10..c61d97d881 100644 --- a/packages/web/src/content/docs/da/zen.mdx +++ b/packages/web/src/content/docs/da/zen.mdx @@ -90,8 +90,10 @@ Du kan også få adgang til vores modeller gennem følgende API-endpoints. | Gemini 3.5 Flash | gemini-3.5-flash | `https://opencode.ai/zen/v1/models/gemini-3.5-flash` | `@ai-sdk/google` | | Gemini 3.1 Pro | gemini-3.1-pro | `https://opencode.ai/zen/v1/models/gemini-3.1-pro` | `@ai-sdk/google` | | Gemini 3 Flash | gemini-3-flash | `https://opencode.ai/zen/v1/models/gemini-3-flash` | `@ai-sdk/google` | +| Qwen3.7 Max | qwen3.7-max | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| DeepSeek V4 Flash | deepseek-v4-flash | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | GLM 5.1 | glm-5.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | @@ -139,6 +141,7 @@ Vi understøtter en pay-as-you-go-model. Nedenfor er priserne **pr. 1M tokens**. | Qwen3.7 Max | $2.50 | $7.50 | $0.50 | $3.125 | | Qwen3.6 Plus | $0.50 | $3.00 | $0.05 | $0.625 | | Qwen3.5 Plus | $0.20 | $1.20 | $0.02 | $0.25 | +| DeepSeek V4 Flash | $0.14 | $0.28 | $0.03 | - | | Grok Build 0.1 | $1.00 | $2.00 | $0.20 | - | | Claude Opus 4.8 | $5.00 | $25.00 | $0.50 | $6.25 | | Claude Opus 4.7 | $5.00 | $25.00 | $0.50 | $6.25 | diff --git a/packages/web/src/content/docs/de/go.mdx b/packages/web/src/content/docs/de/go.mdx index 38a229f6bc..c5346cc3c2 100644 --- a/packages/web/src/content/docs/de/go.mdx +++ b/packages/web/src/content/docs/de/go.mdx @@ -107,6 +107,23 @@ Die Schätzungen basieren auf beobachteten durchschnittlichen Anfragemustern: - MiMo-V2.5 — 830 Input-, 71.500 Cached-, 295 Output-Tokens pro Anfrage - MiMo-V2.5-Pro — 790 Input-, 86.000 Cached-, 305 Output-Tokens pro Anfrage +Die Schätzungen basieren außerdem auf den folgenden Preisen pro 1M Tokens: + +| Model | Input | Output | Cached Read | Cached Write | +| ----------------- | ----- | ------ | ----------- | ------------ | +| GLM-5.1 | $1.40 | $4.40 | $0.26 | - | +| GLM-5 | $1.00 | $3.20 | $0.20 | - | +| Kimi K2.6 | $0.95 | $4.00 | $0.16 | - | +| Kimi K2.5 | $0.60 | $3.00 | $0.10 | - | +| MiMo V2.5 | $0.14 | $0.28 | $0.0028 | - | +| MiMo V2.5 Pro | $1.74 | $3.48 | $0.0145 | - | +| MiniMax M2.7 | $0.30 | $1.20 | $0.06 | $0.375 | +| MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 | +| Qwen3.7 Max | $2.50 | $7.50 | $0.50 | $3.125 | +| Qwen3.6 Plus | $0.50 | $3.00 | $0.05 | $0.625 | +| DeepSeek V4 Pro | $1.74 | $3.48 | $0.0145 | - | +| DeepSeek V4 Flash | $0.14 | $0.28 | $0.0028 | - | + Du kannst deine aktuelle Nutzung in der **Console** verfolgen. :::tip diff --git a/packages/web/src/content/docs/de/zen.mdx b/packages/web/src/content/docs/de/zen.mdx index 8f881a2eaf..1fa139d05d 100644 --- a/packages/web/src/content/docs/de/zen.mdx +++ b/packages/web/src/content/docs/de/zen.mdx @@ -81,8 +81,10 @@ Du kannst auch über die folgenden API-Endpunkte auf unsere Modelle zugreifen. | Gemini 3.5 Flash | gemini-3.5-flash | `https://opencode.ai/zen/v1/models/gemini-3.5-flash` | `@ai-sdk/google` | | Gemini 3.1 Pro | gemini-3.1-pro | `https://opencode.ai/zen/v1/models/gemini-3.1-pro` | `@ai-sdk/google` | | Gemini 3 Flash | gemini-3-flash | `https://opencode.ai/zen/v1/models/gemini-3-flash` | `@ai-sdk/google` | +| Qwen3.7 Max | qwen3.7-max | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| DeepSeek V4 Flash | deepseek-v4-flash | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | GLM 5.1 | glm-5.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | @@ -128,6 +130,7 @@ Wir unterstützen ein Pay-as-you-go-Modell. Unten findest du die Preise **pro 1M | Qwen3.7 Max | $2.50 | $7.50 | $0.50 | $3.125 | | Qwen3.6 Plus | $0.50 | $3.00 | $0.05 | $0.625 | | Qwen3.5 Plus | $0.20 | $1.20 | $0.02 | $0.25 | +| DeepSeek V4 Flash | $0.14 | $0.28 | $0.03 | - | | Grok Build 0.1 | $1.00 | $2.00 | $0.20 | - | | Claude Opus 4.8 | $5.00 | $25.00 | $0.50 | $6.25 | | Claude Opus 4.7 | $5.00 | $25.00 | $0.50 | $6.25 | diff --git a/packages/web/src/content/docs/es/go.mdx b/packages/web/src/content/docs/es/go.mdx index 899e9acd25..34bb6602eb 100644 --- a/packages/web/src/content/docs/es/go.mdx +++ b/packages/web/src/content/docs/es/go.mdx @@ -115,6 +115,23 @@ Las estimaciones se basan en los patrones de peticiones promedio observados: - MiMo-V2.5 — 830 tokens de entrada, 71,500 en caché, 295 tokens de salida por petición - MiMo-V2.5-Pro — 790 tokens de entrada, 86,000 en caché, 305 tokens de salida por petición +Las estimaciones también se basan en los siguientes precios por 1M tokens: + +| Modelo | Entrada | Salida | Lectura en caché | Escritura en caché | +| ----------------- | ------- | ------ | ---------------- | ------------------ | +| GLM-5.1 | $1.40 | $4.40 | $0.26 | - | +| GLM-5 | $1.00 | $3.20 | $0.20 | - | +| Kimi K2.6 | $0.95 | $4.00 | $0.16 | - | +| Kimi K2.5 | $0.60 | $3.00 | $0.10 | - | +| MiMo V2.5 | $0.14 | $0.28 | $0.0028 | - | +| MiMo V2.5 Pro | $1.74 | $3.48 | $0.0145 | - | +| MiniMax M2.7 | $0.30 | $1.20 | $0.06 | $0.375 | +| MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 | +| Qwen3.7 Max | $2.50 | $7.50 | $0.50 | $3.125 | +| Qwen3.6 Plus | $0.50 | $3.00 | $0.05 | $0.625 | +| DeepSeek V4 Pro | $1.74 | $3.48 | $0.0145 | - | +| DeepSeek V4 Flash | $0.14 | $0.28 | $0.0028 | - | + Puedes realizar un seguimiento de tu uso actual en la **consola**. :::tip diff --git a/packages/web/src/content/docs/es/zen.mdx b/packages/web/src/content/docs/es/zen.mdx index 7bb184bb4f..359aed418e 100644 --- a/packages/web/src/content/docs/es/zen.mdx +++ b/packages/web/src/content/docs/es/zen.mdx @@ -90,8 +90,10 @@ También puedes acceder a nuestros modelos a través de los siguientes endpoints | Gemini 3.5 Flash | gemini-3.5-flash | `https://opencode.ai/zen/v1/models/gemini-3.5-flash` | `@ai-sdk/google` | | Gemini 3.1 Pro | gemini-3.1-pro | `https://opencode.ai/zen/v1/models/gemini-3.1-pro` | `@ai-sdk/google` | | Gemini 3 Flash | gemini-3-flash | `https://opencode.ai/zen/v1/models/gemini-3-flash` | `@ai-sdk/google` | +| Qwen3.7 Max | qwen3.7-max | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| DeepSeek V4 Flash | deepseek-v4-flash | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | GLM 5.1 | glm-5.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | @@ -139,6 +141,7 @@ Admitimos un modelo de pago por uso. A continuación se muestran los precios **p | Qwen3.7 Max | $2.50 | $7.50 | $0.50 | $3.125 | | Qwen3.6 Plus | $0.50 | $3.00 | $0.05 | $0.625 | | Qwen3.5 Plus | $0.20 | $1.20 | $0.02 | $0.25 | +| DeepSeek V4 Flash | $0.14 | $0.28 | $0.03 | - | | Grok Build 0.1 | $1.00 | $2.00 | $0.20 | - | | Claude Opus 4.8 | $5.00 | $25.00 | $0.50 | $6.25 | | Claude Opus 4.7 | $5.00 | $25.00 | $0.50 | $6.25 | diff --git a/packages/web/src/content/docs/fr/go.mdx b/packages/web/src/content/docs/fr/go.mdx index ac2455eead..994c17afd6 100644 --- a/packages/web/src/content/docs/fr/go.mdx +++ b/packages/web/src/content/docs/fr/go.mdx @@ -93,7 +93,7 @@ Le tableau ci-dessous fournit une estimation du nombre de requêtes basée sur d | DeepSeek V4 Pro | 3,450 | 8,550 | 17,150 | | DeepSeek V4 Flash | 31,650 | 79,050 | 158,150 | -Les estimations sont basées sur les modèles de requêtes moyens observés : +Les estimations sont basées sur les schémas de requêtes moyens observés : - GLM-5/5.1 — 700 tokens en entrée, 52,000 en cache, 150 tokens en sortie par requête - Kimi K2.5/K2.6 — 870 tokens en entrée, 55,000 en cache, 200 tokens en sortie par requête @@ -105,6 +105,23 @@ Les estimations sont basées sur les modèles de requêtes moyens observés : - MiMo-V2.5 — 830 tokens en entrée, 71,500 en cache, 295 tokens en sortie par requête - MiMo-V2.5-Pro — 790 tokens en entrée, 86,000 en cache, 305 tokens en sortie par requête +Les estimations sont également basées sur les prix suivants par 1M tokens : + +| Modèle | Input | Output | Cached Read | Cached Write | +| ----------------- | ----- | ------ | ----------- | ------------ | +| GLM-5.1 | $1.40 | $4.40 | $0.26 | - | +| GLM-5 | $1.00 | $3.20 | $0.20 | - | +| Kimi K2.6 | $0.95 | $4.00 | $0.16 | - | +| Kimi K2.5 | $0.60 | $3.00 | $0.10 | - | +| MiMo V2.5 | $0.14 | $0.28 | $0.0028 | - | +| MiMo V2.5 Pro | $1.74 | $3.48 | $0.0145 | - | +| MiniMax M2.7 | $0.30 | $1.20 | $0.06 | $0.375 | +| MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 | +| Qwen3.7 Max | $2.50 | $7.50 | $0.50 | $3.125 | +| Qwen3.6 Plus | $0.50 | $3.00 | $0.05 | $0.625 | +| DeepSeek V4 Pro | $1.74 | $3.48 | $0.0145 | - | +| DeepSeek V4 Flash | $0.14 | $0.28 | $0.0028 | - | + Vous pouvez suivre votre utilisation actuelle dans la **console**. :::tip diff --git a/packages/web/src/content/docs/fr/zen.mdx b/packages/web/src/content/docs/fr/zen.mdx index de2908e474..2e508a2e18 100644 --- a/packages/web/src/content/docs/fr/zen.mdx +++ b/packages/web/src/content/docs/fr/zen.mdx @@ -81,8 +81,10 @@ Vous pouvez également accéder à nos modèles via les points de terminaison AP | Gemini 3.5 Flash | gemini-3.5-flash | `https://opencode.ai/zen/v1/models/gemini-3.5-flash` | `@ai-sdk/google` | | Gemini 3.1 Pro | gemini-3.1-pro | `https://opencode.ai/zen/v1/models/gemini-3.1-pro` | `@ai-sdk/google` | | Gemini 3 Flash | gemini-3-flash | `https://opencode.ai/zen/v1/models/gemini-3-flash` | `@ai-sdk/google` | +| Qwen3.7 Max | qwen3.7-max | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| DeepSeek V4 Flash | deepseek-v4-flash | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | GLM 5.1 | glm-5.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | @@ -128,6 +130,7 @@ Nous prenons en charge un modèle de paiement à l'utilisation. Vous trouverez c | Qwen3.7 Max | $2.50 | $7.50 | $0.50 | $3.125 | | Qwen3.6 Plus | $0.50 | $3.00 | $0.05 | $0.625 | | Qwen3.5 Plus | $0.20 | $1.20 | $0.02 | $0.25 | +| DeepSeek V4 Flash | $0.14 | $0.28 | $0.03 | - | | Grok Build 0.1 | $1.00 | $2.00 | $0.20 | - | | Claude Opus 4.8 | $5.00 | $25.00 | $0.50 | $6.25 | | Claude Opus 4.7 | $5.00 | $25.00 | $0.50 | $6.25 | diff --git a/packages/web/src/content/docs/go.mdx b/packages/web/src/content/docs/go.mdx index 91aad08773..a960b51ae9 100644 --- a/packages/web/src/content/docs/go.mdx +++ b/packages/web/src/content/docs/go.mdx @@ -103,7 +103,7 @@ The table below provides an estimated request count based on typical Go usage pa | DeepSeek V4 Pro | 3,450 | 8,550 | 17,150 | | DeepSeek V4 Flash | 31,650 | 79,050 | 158,150 | -Estimates are based on observed average request patterns: +The estimates are based on observed average request patterns: - GLM-5/5.1 — 700 input, 52,000 cached, 150 output tokens per request - Kimi K2.5/K2.6 — 870 input, 55,000 cached, 200 output tokens per request @@ -115,6 +115,23 @@ Estimates are based on observed average request patterns: - Qwen3.7 Max — 420 input, 66,000 cached, 200 output tokens per request - Qwen3.6 Plus — 500 input, 57,000 cached, 190 output tokens per request +The estimates are also based on the following prices per 1M tokens: + +| Model | Input | Output | Cached Read | Cached Write | +| ----------------- | ----- | ------ | ----------- | ------------ | +| GLM-5.1 | $1.40 | $4.40 | $0.26 | - | +| GLM-5 | $1.00 | $3.20 | $0.20 | - | +| Kimi K2.6 | $0.95 | $4.00 | $0.16 | - | +| Kimi K2.5 | $0.60 | $3.00 | $0.10 | - | +| MiMo V2.5 | $0.14 | $0.28 | $0.0028 | - | +| MiMo V2.5 Pro | $1.74 | $3.48 | $0.0145 | - | +| MiniMax M2.7 | $0.30 | $1.20 | $0.06 | $0.375 | +| MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 | +| Qwen3.7 Max | $2.50 | $7.50 | $0.50 | $3.125 | +| Qwen3.6 Plus | $0.50 | $3.00 | $0.05 | $0.625 | +| DeepSeek V4 Pro | $1.74 | $3.48 | $0.0145 | - | +| DeepSeek V4 Flash | $0.14 | $0.28 | $0.0028 | - | + You can track your current usage in the **console**. :::tip diff --git a/packages/web/src/content/docs/it/go.mdx b/packages/web/src/content/docs/it/go.mdx index 812e16a209..33b9a29c21 100644 --- a/packages/web/src/content/docs/it/go.mdx +++ b/packages/web/src/content/docs/it/go.mdx @@ -113,6 +113,23 @@ Le stime si basano sui pattern medi di richieste osservati: - MiMo-V2.5 — 830 di input, 71.500 in cache, 295 token di output per richiesta - MiMo-V2.5-Pro — 790 di input, 86.000 in cache, 305 token di output per richiesta +Le stime si basano anche sui seguenti prezzi per 1M token: + +| Modello | Input | Output | Cached Read | Cached Write | +| ----------------- | ----- | ------ | ----------- | ------------ | +| GLM-5.1 | $1.40 | $4.40 | $0.26 | - | +| GLM-5 | $1.00 | $3.20 | $0.20 | - | +| Kimi K2.6 | $0.95 | $4.00 | $0.16 | - | +| Kimi K2.5 | $0.60 | $3.00 | $0.10 | - | +| MiMo V2.5 | $0.14 | $0.28 | $0.0028 | - | +| MiMo V2.5 Pro | $1.74 | $3.48 | $0.0145 | - | +| MiniMax M2.7 | $0.30 | $1.20 | $0.06 | $0.375 | +| MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 | +| Qwen3.7 Max | $2.50 | $7.50 | $0.50 | $3.125 | +| Qwen3.6 Plus | $0.50 | $3.00 | $0.05 | $0.625 | +| DeepSeek V4 Pro | $1.74 | $3.48 | $0.0145 | - | +| DeepSeek V4 Flash | $0.14 | $0.28 | $0.0028 | - | + Puoi monitorare il tuo utilizzo attuale nella **console**. :::tip diff --git a/packages/web/src/content/docs/it/zen.mdx b/packages/web/src/content/docs/it/zen.mdx index 36447028ff..82138d0b25 100644 --- a/packages/web/src/content/docs/it/zen.mdx +++ b/packages/web/src/content/docs/it/zen.mdx @@ -90,8 +90,10 @@ Puoi anche accedere ai nostri modelli tramite i seguenti endpoint API. | Gemini 3.5 Flash | gemini-3.5-flash | `https://opencode.ai/zen/v1/models/gemini-3.5-flash` | `@ai-sdk/google` | | Gemini 3.1 Pro | gemini-3.1-pro | `https://opencode.ai/zen/v1/models/gemini-3.1-pro` | `@ai-sdk/google` | | Gemini 3 Flash | gemini-3-flash | `https://opencode.ai/zen/v1/models/gemini-3-flash` | `@ai-sdk/google` | +| Qwen3.7 Max | qwen3.7-max | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| DeepSeek V4 Flash | deepseek-v4-flash | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | GLM 5.1 | glm-5.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | @@ -139,6 +141,7 @@ Supportiamo un modello pay-as-you-go. Qui sotto trovi i prezzi **per 1M token**. | Qwen3.7 Max | $2.50 | $7.50 | $0.50 | $3.125 | | Qwen3.6 Plus | $0.50 | $3.00 | $0.05 | $0.625 | | Qwen3.5 Plus | $0.20 | $1.20 | $0.02 | $0.25 | +| DeepSeek V4 Flash | $0.14 | $0.28 | $0.03 | - | | Grok Build 0.1 | $1.00 | $2.00 | $0.20 | - | | Claude Opus 4.8 | $5.00 | $25.00 | $0.50 | $6.25 | | Claude Opus 4.7 | $5.00 | $25.00 | $0.50 | $6.25 | diff --git a/packages/web/src/content/docs/ja/go.mdx b/packages/web/src/content/docs/ja/go.mdx index 3a37968e9d..67eecc8196 100644 --- a/packages/web/src/content/docs/ja/go.mdx +++ b/packages/web/src/content/docs/ja/go.mdx @@ -105,6 +105,23 @@ OpenCode Goには以下の制限が含まれています: - MiMo-V2.5 — リクエストあたり 入力 830トークン、キャッシュ 71,500トークン、出力 295トークン - MiMo-V2.5-Pro — リクエストあたり 入力 790トークン、キャッシュ 86,000トークン、出力 305トークン +推定値は、100万トークンあたりの以下の価格にも基づいています: + +| Model | Input | Output | Cached Read | Cached Write | +| ----------------- | ----- | ------ | ----------- | ------------ | +| GLM-5.1 | $1.40 | $4.40 | $0.26 | - | +| GLM-5 | $1.00 | $3.20 | $0.20 | - | +| Kimi K2.6 | $0.95 | $4.00 | $0.16 | - | +| Kimi K2.5 | $0.60 | $3.00 | $0.10 | - | +| MiMo V2.5 | $0.14 | $0.28 | $0.0028 | - | +| MiMo V2.5 Pro | $1.74 | $3.48 | $0.0145 | - | +| MiniMax M2.7 | $0.30 | $1.20 | $0.06 | $0.375 | +| MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 | +| Qwen3.7 Max | $2.50 | $7.50 | $0.50 | $3.125 | +| Qwen3.6 Plus | $0.50 | $3.00 | $0.05 | $0.625 | +| DeepSeek V4 Pro | $1.74 | $3.48 | $0.0145 | - | +| DeepSeek V4 Flash | $0.14 | $0.28 | $0.0028 | - | + 現在の利用状況は**コンソール**で追跡できます。 :::tip diff --git a/packages/web/src/content/docs/ja/zen.mdx b/packages/web/src/content/docs/ja/zen.mdx index dc8339e18b..f66c72ebe6 100644 --- a/packages/web/src/content/docs/ja/zen.mdx +++ b/packages/web/src/content/docs/ja/zen.mdx @@ -81,8 +81,10 @@ OpenCode Zen は、OpenCode のほかのプロバイダーと同じように動 | Gemini 3.5 Flash | gemini-3.5-flash | `https://opencode.ai/zen/v1/models/gemini-3.5-flash` | `@ai-sdk/google` | | Gemini 3.1 Pro | gemini-3.1-pro | `https://opencode.ai/zen/v1/models/gemini-3.1-pro` | `@ai-sdk/google` | | Gemini 3 Flash | gemini-3-flash | `https://opencode.ai/zen/v1/models/gemini-3-flash` | `@ai-sdk/google` | +| Qwen3.7 Max | qwen3.7-max | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| DeepSeek V4 Flash | deepseek-v4-flash | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | GLM 5.1 | glm-5.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | @@ -128,6 +130,7 @@ https://opencode.ai/zen/v1/models | Qwen3.7 Max | $2.50 | $7.50 | $0.50 | $3.125 | | Qwen3.6 Plus | $0.50 | $3.00 | $0.05 | $0.625 | | Qwen3.5 Plus | $0.20 | $1.20 | $0.02 | $0.25 | +| DeepSeek V4 Flash | $0.14 | $0.28 | $0.03 | - | | Grok Build 0.1 | $1.00 | $2.00 | $0.20 | - | | Claude Opus 4.8 | $5.00 | $25.00 | $0.50 | $6.25 | | Claude Opus 4.7 | $5.00 | $25.00 | $0.50 | $6.25 | diff --git a/packages/web/src/content/docs/ko/go.mdx b/packages/web/src/content/docs/ko/go.mdx index 2573a53300..aabb1d03e1 100644 --- a/packages/web/src/content/docs/ko/go.mdx +++ b/packages/web/src/content/docs/ko/go.mdx @@ -93,7 +93,7 @@ OpenCode Go에는 다음과 같은 한도가 포함됩니다. | DeepSeek V4 Pro | 3,450 | 8,550 | 17,150 | | DeepSeek V4 Flash | 31,650 | 79,050 | 158,150 | -예상치는 관찰된 평균 요청 패턴을 기준으로 합니다. +이 예상치는 관찰된 평균 요청 패턴을 기준으로 합니다. - GLM-5/5.1 — 요청당 입력 700, 캐시 52,000, 출력 토큰 150 - Kimi K2.5/K2.6 — 요청당 입력 870, 캐시 55,000, 출력 토큰 200 @@ -105,6 +105,23 @@ OpenCode Go에는 다음과 같은 한도가 포함됩니다. - MiMo-V2.5 — 요청당 입력 830, 캐시 71,500, 출력 토큰 295 - MiMo-V2.5-Pro — 요청당 입력 790, 캐시 86,000, 출력 토큰 305 +이 예상치는 또한 1M tokens당 다음 가격을 기준으로 합니다. + +| Model | Input | Output | Cached Read | Cached Write | +| ----------------- | ----- | ------ | ----------- | ------------ | +| GLM-5.1 | $1.40 | $4.40 | $0.26 | - | +| GLM-5 | $1.00 | $3.20 | $0.20 | - | +| Kimi K2.6 | $0.95 | $4.00 | $0.16 | - | +| Kimi K2.5 | $0.60 | $3.00 | $0.10 | - | +| MiMo V2.5 | $0.14 | $0.28 | $0.0028 | - | +| MiMo V2.5 Pro | $1.74 | $3.48 | $0.0145 | - | +| MiniMax M2.7 | $0.30 | $1.20 | $0.06 | $0.375 | +| MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 | +| Qwen3.7 Max | $2.50 | $7.50 | $0.50 | $3.125 | +| Qwen3.6 Plus | $0.50 | $3.00 | $0.05 | $0.625 | +| DeepSeek V4 Pro | $1.74 | $3.48 | $0.0145 | - | +| DeepSeek V4 Flash | $0.14 | $0.28 | $0.0028 | - | + 현재 사용량은 **console**에서 확인할 수 있습니다. :::tip diff --git a/packages/web/src/content/docs/ko/zen.mdx b/packages/web/src/content/docs/ko/zen.mdx index 43fba61384..5a574c46c6 100644 --- a/packages/web/src/content/docs/ko/zen.mdx +++ b/packages/web/src/content/docs/ko/zen.mdx @@ -81,8 +81,10 @@ OpenCode Zen은 OpenCode의 다른 provider와 똑같이 작동합니다. | Gemini 3.5 Flash | gemini-3.5-flash | `https://opencode.ai/zen/v1/models/gemini-3.5-flash` | `@ai-sdk/google` | | Gemini 3.1 Pro | gemini-3.1-pro | `https://opencode.ai/zen/v1/models/gemini-3.1-pro` | `@ai-sdk/google` | | Gemini 3 Flash | gemini-3-flash | `https://opencode.ai/zen/v1/models/gemini-3-flash` | `@ai-sdk/google` | +| Qwen3.7 Max | qwen3.7-max | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| DeepSeek V4 Flash | deepseek-v4-flash | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | GLM 5.1 | glm-5.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | @@ -128,6 +130,7 @@ https://opencode.ai/zen/v1/models | Qwen3.7 Max | $2.50 | $7.50 | $0.50 | $3.125 | | Qwen3.6 Plus | $0.50 | $3.00 | $0.05 | $0.625 | | Qwen3.5 Plus | $0.20 | $1.20 | $0.02 | $0.25 | +| DeepSeek V4 Flash | $0.14 | $0.28 | $0.03 | - | | Grok Build 0.1 | $1.00 | $2.00 | $0.20 | - | | Claude Opus 4.8 | $5.00 | $25.00 | $0.50 | $6.25 | | Claude Opus 4.7 | $5.00 | $25.00 | $0.50 | $6.25 | diff --git a/packages/web/src/content/docs/nb/go.mdx b/packages/web/src/content/docs/nb/go.mdx index 6a086e2cc2..af1636877b 100644 --- a/packages/web/src/content/docs/nb/go.mdx +++ b/packages/web/src/content/docs/nb/go.mdx @@ -115,6 +115,23 @@ Estimatene er basert på observerte gjennomsnittlige forespørselsmønstre: - MiMo-V2.5 — 830 input, 71 500 bufret, 295 output-tokens per forespørsel - MiMo-V2.5-Pro — 790 input, 86 000 bufret, 305 output-tokens per forespørsel +Estimatene er også basert på følgende priser per 1M tokens: + +| Model | Input | Output | Cached Read | Cached Write | +| ----------------- | ----- | ------ | ----------- | ------------ | +| GLM-5.1 | $1.40 | $4.40 | $0.26 | - | +| GLM-5 | $1.00 | $3.20 | $0.20 | - | +| Kimi K2.6 | $0.95 | $4.00 | $0.16 | - | +| Kimi K2.5 | $0.60 | $3.00 | $0.10 | - | +| MiMo V2.5 | $0.14 | $0.28 | $0.0028 | - | +| MiMo V2.5 Pro | $1.74 | $3.48 | $0.0145 | - | +| MiniMax M2.7 | $0.30 | $1.20 | $0.06 | $0.375 | +| MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 | +| Qwen3.7 Max | $2.50 | $7.50 | $0.50 | $3.125 | +| Qwen3.6 Plus | $0.50 | $3.00 | $0.05 | $0.625 | +| DeepSeek V4 Pro | $1.74 | $3.48 | $0.0145 | - | +| DeepSeek V4 Flash | $0.14 | $0.28 | $0.0028 | - | + Du kan spore din nåværende bruk i **konsollen**. :::tip diff --git a/packages/web/src/content/docs/nb/zen.mdx b/packages/web/src/content/docs/nb/zen.mdx index 95c2bc50fc..e16abe43d3 100644 --- a/packages/web/src/content/docs/nb/zen.mdx +++ b/packages/web/src/content/docs/nb/zen.mdx @@ -90,8 +90,10 @@ Du kan også få tilgang til modellene våre gjennom følgende API-endepunkter. | Gemini 3.5 Flash | gemini-3.5-flash | `https://opencode.ai/zen/v1/models/gemini-3.5-flash` | `@ai-sdk/google` | | Gemini 3.1 Pro | gemini-3.1-pro | `https://opencode.ai/zen/v1/models/gemini-3.1-pro` | `@ai-sdk/google` | | Gemini 3 Flash | gemini-3-flash | `https://opencode.ai/zen/v1/models/gemini-3-flash` | `@ai-sdk/google` | +| Qwen3.7 Max | qwen3.7-max | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| DeepSeek V4 Flash | deepseek-v4-flash | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | GLM 5.1 | glm-5.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | @@ -139,6 +141,7 @@ Vi støtter en pay-as-you-go-modell. Nedenfor er prisene **per 1M tokens**. | Qwen3.7 Max | $2.50 | $7.50 | $0.50 | $3.125 | | Qwen3.6 Plus | $0.50 | $3.00 | $0.05 | $0.625 | | Qwen3.5 Plus | $0.20 | $1.20 | $0.02 | $0.25 | +| DeepSeek V4 Flash | $0.14 | $0.28 | $0.03 | - | | Grok Build 0.1 | $1.00 | $2.00 | $0.20 | - | | Claude Opus 4.8 | $5.00 | $25.00 | $0.50 | $6.25 | | Claude Opus 4.7 | $5.00 | $25.00 | $0.50 | $6.25 | diff --git a/packages/web/src/content/docs/pl/go.mdx b/packages/web/src/content/docs/pl/go.mdx index 870ed058d3..e13df2706d 100644 --- a/packages/web/src/content/docs/pl/go.mdx +++ b/packages/web/src/content/docs/pl/go.mdx @@ -97,7 +97,7 @@ Poniższa tabela przedstawia szacunkową liczbę żądań na podstawie typowych | DeepSeek V4 Pro | 3,450 | 8,550 | 17,150 | | DeepSeek V4 Flash | 31,650 | 79,050 | 158,150 | -Szacunki opierają się na zaobserwowanych średnich wzorcach żądań: +Szacunki te opierają się na zaobserwowanych średnich wzorcach żądań: - GLM-5/5.1 — 700 tokenów wejściowych, 52 000 w pamięci podręcznej, 150 tokenów wyjściowych na żądanie - Kimi K2.5/K2.6 — 870 tokenów wejściowych, 55 000 w pamięci podręcznej, 200 tokenów wyjściowych na żądanie @@ -109,6 +109,23 @@ Szacunki opierają się na zaobserwowanych średnich wzorcach żądań: - MiMo-V2.5 — 830 tokenów wejściowych, 71 500 w pamięci podręcznej, 295 tokenów wyjściowych na żądanie - MiMo-V2.5-Pro — 790 tokenów wejściowych, 86 000 w pamięci podręcznej, 305 tokenów wyjściowych na żądanie +Szacunki opierają się również na następujących cenach za 1M tokenów: + +| Model | Wejście | Wyjście | Odczyt z cache | Zapis do cache | +| ----------------- | ------- | ------- | -------------- | -------------- | +| GLM-5.1 | $1.40 | $4.40 | $0.26 | - | +| GLM-5 | $1.00 | $3.20 | $0.20 | - | +| Kimi K2.6 | $0.95 | $4.00 | $0.16 | - | +| Kimi K2.5 | $0.60 | $3.00 | $0.10 | - | +| MiMo V2.5 | $0.14 | $0.28 | $0.0028 | - | +| MiMo V2.5 Pro | $1.74 | $3.48 | $0.0145 | - | +| MiniMax M2.7 | $0.30 | $1.20 | $0.06 | $0.375 | +| MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 | +| Qwen3.7 Max | $2.50 | $7.50 | $0.50 | $3.125 | +| Qwen3.6 Plus | $0.50 | $3.00 | $0.05 | $0.625 | +| DeepSeek V4 Pro | $1.74 | $3.48 | $0.0145 | - | +| DeepSeek V4 Flash | $0.14 | $0.28 | $0.0028 | - | + Możesz śledzić swoje bieżące zużycie w **konsoli**. :::tip diff --git a/packages/web/src/content/docs/pl/zen.mdx b/packages/web/src/content/docs/pl/zen.mdx index adde58949f..e1fcd4610e 100644 --- a/packages/web/src/content/docs/pl/zen.mdx +++ b/packages/web/src/content/docs/pl/zen.mdx @@ -90,8 +90,10 @@ Możesz też uzyskać dostęp do naszych modeli przez poniższe endpointy API. | Gemini 3.5 Flash | gemini-3.5-flash | `https://opencode.ai/zen/v1/models/gemini-3.5-flash` | `@ai-sdk/google` | | Gemini 3.1 Pro | gemini-3.1-pro | `https://opencode.ai/zen/v1/models/gemini-3.1-pro` | `@ai-sdk/google` | | Gemini 3 Flash | gemini-3-flash | `https://opencode.ai/zen/v1/models/gemini-3-flash` | `@ai-sdk/google` | +| Qwen3.7 Max | qwen3.7-max | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| DeepSeek V4 Flash | deepseek-v4-flash | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | GLM 5.1 | glm-5.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | @@ -139,6 +141,7 @@ Obsługujemy model pay-as-you-go. Poniżej znajdują się ceny **za 1M tokenów* | Qwen3.7 Max | $2.50 | $7.50 | $0.50 | $3.125 | | Qwen3.6 Plus | $0.50 | $3.00 | $0.05 | $0.625 | | Qwen3.5 Plus | $0.20 | $1.20 | $0.02 | $0.25 | +| DeepSeek V4 Flash | $0.14 | $0.28 | $0.03 | - | | Grok Build 0.1 | $1.00 | $2.00 | $0.20 | - | | Claude Opus 4.8 | $5.00 | $25.00 | $0.50 | $6.25 | | Claude Opus 4.7 | $5.00 | $25.00 | $0.50 | $6.25 | diff --git a/packages/web/src/content/docs/pt-br/go.mdx b/packages/web/src/content/docs/pt-br/go.mdx index 55695074a6..a156ba2870 100644 --- a/packages/web/src/content/docs/pt-br/go.mdx +++ b/packages/web/src/content/docs/pt-br/go.mdx @@ -103,7 +103,7 @@ A tabela abaixo fornece uma contagem estimada de requisições com base nos padr | DeepSeek V4 Pro | 3,450 | 8,550 | 17,150 | | DeepSeek V4 Flash | 31,650 | 79,050 | 158,150 | -As estimativas baseiam-se nos padrões médios de requisições observados: +As estimativas se baseiam nos padrões médios de requisições observados: - GLM-5/5.1 — 700 tokens de entrada, 52.000 em cache, 150 tokens de saída por requisição - Kimi K2.5/K2.6 — 870 tokens de entrada, 55.000 em cache, 200 tokens de saída por requisição @@ -115,6 +115,23 @@ As estimativas baseiam-se nos padrões médios de requisições observados: - MiMo-V2.5 — 830 tokens de entrada, 71.500 em cache, 295 tokens de saída por requisição - MiMo-V2.5-Pro — 790 tokens de entrada, 86.000 em cache, 305 tokens de saída por requisição +As estimativas também se baseiam nos seguintes preços por 1M tokens: + +| Modelo | Entrada | Saída | Leitura em cache | Escrita em cache | +| ----------------- | ------- | ----- | ---------------- | ---------------- | +| GLM-5.1 | $1.40 | $4.40 | $0.26 | - | +| GLM-5 | $1.00 | $3.20 | $0.20 | - | +| Kimi K2.6 | $0.95 | $4.00 | $0.16 | - | +| Kimi K2.5 | $0.60 | $3.00 | $0.10 | - | +| MiMo V2.5 | $0.14 | $0.28 | $0.0028 | - | +| MiMo V2.5 Pro | $1.74 | $3.48 | $0.0145 | - | +| MiniMax M2.7 | $0.30 | $1.20 | $0.06 | $0.375 | +| MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 | +| Qwen3.7 Max | $2.50 | $7.50 | $0.50 | $3.125 | +| Qwen3.6 Plus | $0.50 | $3.00 | $0.05 | $0.625 | +| DeepSeek V4 Pro | $1.74 | $3.48 | $0.0145 | - | +| DeepSeek V4 Flash | $0.14 | $0.28 | $0.0028 | - | + Você pode acompanhar o seu uso atual no **console**. :::tip diff --git a/packages/web/src/content/docs/pt-br/zen.mdx b/packages/web/src/content/docs/pt-br/zen.mdx index 25366ad2ea..4331cd6548 100644 --- a/packages/web/src/content/docs/pt-br/zen.mdx +++ b/packages/web/src/content/docs/pt-br/zen.mdx @@ -81,8 +81,10 @@ Você também pode acessar nossos modelos pelos seguintes endpoints de API. | Gemini 3.5 Flash | gemini-3.5-flash | `https://opencode.ai/zen/v1/models/gemini-3.5-flash` | `@ai-sdk/google` | | Gemini 3.1 Pro | gemini-3.1-pro | `https://opencode.ai/zen/v1/models/gemini-3.1-pro` | `@ai-sdk/google` | | Gemini 3 Flash | gemini-3-flash | `https://opencode.ai/zen/v1/models/gemini-3-flash` | `@ai-sdk/google` | +| Qwen3.7 Max | qwen3.7-max | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| DeepSeek V4 Flash | deepseek-v4-flash | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | GLM 5.1 | glm-5.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | @@ -128,6 +130,7 @@ Oferecemos um modelo pay-as-you-go. Abaixo estão os preços **por 1M tokens**. | Qwen3.7 Max | $2.50 | $7.50 | $0.50 | $3.125 | | Qwen3.6 Plus | $0.50 | $3.00 | $0.05 | $0.625 | | Qwen3.5 Plus | $0.20 | $1.20 | $0.02 | $0.25 | +| DeepSeek V4 Flash | $0.14 | $0.28 | $0.03 | - | | Grok Build 0.1 | $1.00 | $2.00 | $0.20 | - | | Claude Opus 4.8 | $5.00 | $25.00 | $0.50 | $6.25 | | Claude Opus 4.7 | $5.00 | $25.00 | $0.50 | $6.25 | diff --git a/packages/web/src/content/docs/ru/go.mdx b/packages/web/src/content/docs/ru/go.mdx index 3e1b65c97f..b799960b40 100644 --- a/packages/web/src/content/docs/ru/go.mdx +++ b/packages/web/src/content/docs/ru/go.mdx @@ -103,7 +103,7 @@ OpenCode Go включает следующие лимиты: | DeepSeek V4 Pro | 3,450 | 8,550 | 17,150 | | DeepSeek V4 Flash | 31,650 | 79,050 | 158,150 | -Оценки основаны на наблюдаемых средних показателях запросов: +Эти оценки основаны на наблюдаемых средних показателях запросов: - GLM-5/5.1 — 700 входных, 52,000 кешированных, 150 выходных токенов на запрос - Kimi K2.5/K2.6 — 870 входных, 55,000 кешированных, 200 выходных токенов на запрос @@ -115,6 +115,23 @@ OpenCode Go включает следующие лимиты: - MiMo-V2.5 — 830 входных, 71,500 кешированных, 295 выходных токенов на запрос - MiMo-V2.5-Pro — 790 входных, 86,000 кешированных, 305 выходных токенов на запрос +Эти оценки также основаны на следующих ценах за 1M токенов: + +| Model | Input | Output | Cached Read | Cached Write | +| ----------------- | ----- | ------ | ----------- | ------------ | +| GLM-5.1 | $1.40 | $4.40 | $0.26 | - | +| GLM-5 | $1.00 | $3.20 | $0.20 | - | +| Kimi K2.6 | $0.95 | $4.00 | $0.16 | - | +| Kimi K2.5 | $0.60 | $3.00 | $0.10 | - | +| MiMo V2.5 | $0.14 | $0.28 | $0.0028 | - | +| MiMo V2.5 Pro | $1.74 | $3.48 | $0.0145 | - | +| MiniMax M2.7 | $0.30 | $1.20 | $0.06 | $0.375 | +| MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 | +| Qwen3.7 Max | $2.50 | $7.50 | $0.50 | $3.125 | +| Qwen3.6 Plus | $0.50 | $3.00 | $0.05 | $0.625 | +| DeepSeek V4 Pro | $1.74 | $3.48 | $0.0145 | - | +| DeepSeek V4 Flash | $0.14 | $0.28 | $0.0028 | - | + Вы можете отслеживать текущее использование в **консоли**. :::tip diff --git a/packages/web/src/content/docs/ru/zen.mdx b/packages/web/src/content/docs/ru/zen.mdx index c8e0acd2bb..3f22dbdc7e 100644 --- a/packages/web/src/content/docs/ru/zen.mdx +++ b/packages/web/src/content/docs/ru/zen.mdx @@ -90,8 +90,10 @@ OpenCode Zen работает как любой другой провайдер | Gemini 3.5 Flash | gemini-3.5-flash | `https://opencode.ai/zen/v1/models/gemini-3.5-flash` | `@ai-sdk/google` | | Gemini 3.1 Pro | gemini-3.1-pro | `https://opencode.ai/zen/v1/models/gemini-3.1-pro` | `@ai-sdk/google` | | Gemini 3 Flash | gemini-3-flash | `https://opencode.ai/zen/v1/models/gemini-3-flash` | `@ai-sdk/google` | +| Qwen3.7 Max | qwen3.7-max | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| DeepSeek V4 Flash | deepseek-v4-flash | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | GLM 5.1 | glm-5.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | @@ -139,6 +141,7 @@ https://opencode.ai/zen/v1/models | Qwen3.7 Max | $2.50 | $7.50 | $0.50 | $3.125 | | Qwen3.6 Plus | $0.50 | $3.00 | $0.05 | $0.625 | | Qwen3.5 Plus | $0.20 | $1.20 | $0.02 | $0.25 | +| DeepSeek V4 Flash | $0.14 | $0.28 | $0.03 | - | | Grok Build 0.1 | $1.00 | $2.00 | $0.20 | - | | Claude Opus 4.8 | $5.00 | $25.00 | $0.50 | $6.25 | | Claude Opus 4.7 | $5.00 | $25.00 | $0.50 | $6.25 | diff --git a/packages/web/src/content/docs/th/go.mdx b/packages/web/src/content/docs/th/go.mdx index eef1348029..bafc0bfa0d 100644 --- a/packages/web/src/content/docs/th/go.mdx +++ b/packages/web/src/content/docs/th/go.mdx @@ -93,7 +93,7 @@ OpenCode Go มีขีดจำกัดดังต่อไปนี้: | DeepSeek V4 Pro | 3,450 | 8,550 | 17,150 | | DeepSeek V4 Flash | 31,650 | 79,050 | 158,150 | -การประมาณการอ้างอิงจากรูปแบบการใช้งาน request โดยเฉลี่ยที่สังเกตพบ: +การประมาณการนี้อ้างอิงจากรูปแบบการใช้งาน request โดยเฉลี่ยที่สังเกตพบ: - GLM-5/5.1 — 700 input, 52,000 cached, 150 output tokens ต่อ request - Kimi K2.5/K2.6 — 870 input, 55,000 cached, 200 output tokens ต่อ request @@ -105,6 +105,23 @@ OpenCode Go มีขีดจำกัดดังต่อไปนี้: - MiMo-V2.5 — 830 input, 71,500 cached, 295 output tokens ต่อ request - MiMo-V2.5-Pro — 790 input, 86,000 cached, 305 output tokens ต่อ request +การประมาณการนี้ยังอ้างอิงจากราคาต่อ 1M tokens ดังต่อไปนี้: + +| Model | Input | Output | Cached Read | Cached Write | +| ----------------- | ----- | ------ | ----------- | ------------ | +| GLM-5.1 | $1.40 | $4.40 | $0.26 | - | +| GLM-5 | $1.00 | $3.20 | $0.20 | - | +| Kimi K2.6 | $0.95 | $4.00 | $0.16 | - | +| Kimi K2.5 | $0.60 | $3.00 | $0.10 | - | +| MiMo V2.5 | $0.14 | $0.28 | $0.0028 | - | +| MiMo V2.5 Pro | $1.74 | $3.48 | $0.0145 | - | +| MiniMax M2.7 | $0.30 | $1.20 | $0.06 | $0.375 | +| MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 | +| Qwen3.7 Max | $2.50 | $7.50 | $0.50 | $3.125 | +| Qwen3.6 Plus | $0.50 | $3.00 | $0.05 | $0.625 | +| DeepSeek V4 Pro | $1.74 | $3.48 | $0.0145 | - | +| DeepSeek V4 Flash | $0.14 | $0.28 | $0.0028 | - | + คุณสามารถติดตามการใช้งานปัจจุบันของคุณได้ใน **console** :::tip diff --git a/packages/web/src/content/docs/th/zen.mdx b/packages/web/src/content/docs/th/zen.mdx index ad33507e47..38082a7482 100644 --- a/packages/web/src/content/docs/th/zen.mdx +++ b/packages/web/src/content/docs/th/zen.mdx @@ -83,8 +83,10 @@ OpenCode Zen ทำงานเหมือน provider อื่น ๆ ใน | Gemini 3.5 Flash | gemini-3.5-flash | `https://opencode.ai/zen/v1/models/gemini-3.5-flash` | `@ai-sdk/google` | | Gemini 3.1 Pro | gemini-3.1-pro | `https://opencode.ai/zen/v1/models/gemini-3.1-pro` | `@ai-sdk/google` | | Gemini 3 Flash | gemini-3-flash | `https://opencode.ai/zen/v1/models/gemini-3-flash` | `@ai-sdk/google` | +| Qwen3.7 Max | qwen3.7-max | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| DeepSeek V4 Flash | deepseek-v4-flash | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | GLM 5.1 | glm-5.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | @@ -130,6 +132,7 @@ https://opencode.ai/zen/v1/models | Qwen3.7 Max | $2.50 | $7.50 | $0.50 | $3.125 | | Qwen3.6 Plus | $0.50 | $3.00 | $0.05 | $0.625 | | Qwen3.5 Plus | $0.20 | $1.20 | $0.02 | $0.25 | +| DeepSeek V4 Flash | $0.14 | $0.28 | $0.03 | - | | Grok Build 0.1 | $1.00 | $2.00 | $0.20 | - | | Claude Opus 4.8 | $5.00 | $25.00 | $0.50 | $6.25 | | Claude Opus 4.7 | $5.00 | $25.00 | $0.50 | $6.25 | diff --git a/packages/web/src/content/docs/tr/go.mdx b/packages/web/src/content/docs/tr/go.mdx index bfde41f86a..1fe63c84fd 100644 --- a/packages/web/src/content/docs/tr/go.mdx +++ b/packages/web/src/content/docs/tr/go.mdx @@ -93,7 +93,7 @@ Aşağıdaki tablo, tipik Go kullanım modellerine dayalı tahmini bir istek say | DeepSeek V4 Pro | 3,450 | 8,550 | 17,150 | | DeepSeek V4 Flash | 31,650 | 79,050 | 158,150 | -Tahminler, gözlemlenen ortalama istek modellerine dayanmaktadır: +Tahminler, gözlemlenen ortalama istek modellerine dayanır: - GLM-5/5.1 — İstek başına 700 girdi, 52.000 önbelleğe alınmış, 150 çıktı token'ı - Kimi K2.5/K2.6 — İstek başına 870 girdi, 55.000 önbelleğe alınmış, 200 çıktı token'ı @@ -105,6 +105,23 @@ Tahminler, gözlemlenen ortalama istek modellerine dayanmaktadır: - MiMo-V2.5 — İstek başına 830 girdi, 71.500 önbelleğe alınmış, 295 çıktı token'ı - MiMo-V2.5-Pro — İstek başına 790 girdi, 86.000 önbelleğe alınmış, 305 çıktı token'ı +Tahminler ayrıca 1M token başına aşağıdaki fiyatlara da dayanır: + +| Model | Input | Output | Cached Read | Cached Write | +| ----------------- | ----- | ------ | ----------- | ------------ | +| GLM-5.1 | $1.40 | $4.40 | $0.26 | - | +| GLM-5 | $1.00 | $3.20 | $0.20 | - | +| Kimi K2.6 | $0.95 | $4.00 | $0.16 | - | +| Kimi K2.5 | $0.60 | $3.00 | $0.10 | - | +| MiMo V2.5 | $0.14 | $0.28 | $0.0028 | - | +| MiMo V2.5 Pro | $1.74 | $3.48 | $0.0145 | - | +| MiniMax M2.7 | $0.30 | $1.20 | $0.06 | $0.375 | +| MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 | +| Qwen3.7 Max | $2.50 | $7.50 | $0.50 | $3.125 | +| Qwen3.6 Plus | $0.50 | $3.00 | $0.05 | $0.625 | +| DeepSeek V4 Pro | $1.74 | $3.48 | $0.0145 | - | +| DeepSeek V4 Flash | $0.14 | $0.28 | $0.0028 | - | + Mevcut kullanımınızı **konsoldan** takip edebilirsiniz. :::tip diff --git a/packages/web/src/content/docs/tr/zen.mdx b/packages/web/src/content/docs/tr/zen.mdx index 67375c8ced..6118ff2ee3 100644 --- a/packages/web/src/content/docs/tr/zen.mdx +++ b/packages/web/src/content/docs/tr/zen.mdx @@ -81,8 +81,10 @@ Modellerimize aşağıdaki API uç noktaları aracılığıyla da erişebilirsin | Gemini 3.5 Flash | gemini-3.5-flash | `https://opencode.ai/zen/v1/models/gemini-3.5-flash` | `@ai-sdk/google` | | Gemini 3.1 Pro | gemini-3.1-pro | `https://opencode.ai/zen/v1/models/gemini-3.1-pro` | `@ai-sdk/google` | | Gemini 3 Flash | gemini-3-flash | `https://opencode.ai/zen/v1/models/gemini-3-flash` | `@ai-sdk/google` | +| Qwen3.7 Max | qwen3.7-max | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| DeepSeek V4 Flash | deepseek-v4-flash | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | GLM 5.1 | glm-5.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | @@ -128,6 +130,7 @@ Kullandıkça öde modelini destekliyoruz. Aşağıda **1M token başına** fiya | Qwen3.7 Max | $2.50 | $7.50 | $0.50 | $3.125 | | Qwen3.6 Plus | $0.50 | $3.00 | $0.05 | $0.625 | | Qwen3.5 Plus | $0.20 | $1.20 | $0.02 | $0.25 | +| DeepSeek V4 Flash | $0.14 | $0.28 | $0.03 | - | | Grok Build 0.1 | $1.00 | $2.00 | $0.20 | - | | Claude Opus 4.8 | $5.00 | $25.00 | $0.50 | $6.25 | | Claude Opus 4.7 | $5.00 | $25.00 | $0.50 | $6.25 | diff --git a/packages/web/src/content/docs/zen.mdx b/packages/web/src/content/docs/zen.mdx index b61ae45444..e16b36d366 100644 --- a/packages/web/src/content/docs/zen.mdx +++ b/packages/web/src/content/docs/zen.mdx @@ -90,8 +90,10 @@ You can also access our models through the following API endpoints. | Gemini 3.5 Flash | gemini-3.5-flash | `https://opencode.ai/zen/v1/models/gemini-3.5-flash` | `@ai-sdk/google` | | Gemini 3.1 Pro | gemini-3.1-pro | `https://opencode.ai/zen/v1/models/gemini-3.1-pro` | `@ai-sdk/google` | | Gemini 3 Flash | gemini-3-flash | `https://opencode.ai/zen/v1/models/gemini-3-flash` | `@ai-sdk/google` | +| Qwen3.7 Max | qwen3.7-max | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| DeepSeek V4 Flash | deepseek-v4-flash | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | GLM 5.1 | glm-5.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | @@ -139,6 +141,7 @@ We support a pay-as-you-go model. Below are the prices **per 1M tokens**. | Qwen3.7 Max | $2.50 | $7.50 | $0.50 | $3.125 | | Qwen3.6 Plus | $0.50 | $3.00 | $0.05 | $0.625 | | Qwen3.5 Plus | $0.20 | $1.20 | $0.02 | $0.25 | +| DeepSeek V4 Flash | $0.14 | $0.28 | $0.03 | - | | Grok Build 0.1 | $1.00 | $2.00 | $0.20 | - | | Claude Opus 4.8 | $5.00 | $25.00 | $0.50 | $6.25 | | Claude Opus 4.7 | $5.00 | $25.00 | $0.50 | $6.25 | diff --git a/packages/web/src/content/docs/zh-cn/go.mdx b/packages/web/src/content/docs/zh-cn/go.mdx index b45f9d9631..9d57a765bd 100644 --- a/packages/web/src/content/docs/zh-cn/go.mdx +++ b/packages/web/src/content/docs/zh-cn/go.mdx @@ -105,6 +105,23 @@ OpenCode Go 包含以下限制: - Qwen3.7 Max — 每次请求 420 个输入 token,66,000 个缓存 token,200 个输出 token - Qwen3.6 Plus — 每次请求 500 个输入 token,57,000 个缓存 token,190 个输出 token +预估值还基于以下每 1M tokens 的价格: + +| 模型 | 输入 | 输出 | 缓存读取 | 缓存写入 | +| ----------------- | ----- | ----- | -------- | -------- | +| GLM-5.1 | $1.40 | $4.40 | $0.26 | - | +| GLM-5 | $1.00 | $3.20 | $0.20 | - | +| Kimi K2.6 | $0.95 | $4.00 | $0.16 | - | +| Kimi K2.5 | $0.60 | $3.00 | $0.10 | - | +| MiMo V2.5 | $0.14 | $0.28 | $0.0028 | - | +| MiMo V2.5 Pro | $1.74 | $3.48 | $0.0145 | - | +| MiniMax M2.7 | $0.30 | $1.20 | $0.06 | $0.375 | +| MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 | +| Qwen3.7 Max | $2.50 | $7.50 | $0.50 | $3.125 | +| Qwen3.6 Plus | $0.50 | $3.00 | $0.05 | $0.625 | +| DeepSeek V4 Pro | $1.74 | $3.48 | $0.0145 | - | +| DeepSeek V4 Flash | $0.14 | $0.28 | $0.0028 | - | + 你可以在 **控制台** 中跟踪你当前的使用情况。 :::tip diff --git a/packages/web/src/content/docs/zh-cn/zen.mdx b/packages/web/src/content/docs/zh-cn/zen.mdx index 80de0ad927..6b1c7d766e 100644 --- a/packages/web/src/content/docs/zh-cn/zen.mdx +++ b/packages/web/src/content/docs/zh-cn/zen.mdx @@ -81,8 +81,10 @@ OpenCode Zen 的工作方式与 OpenCode 中的任何其他提供商相同。 | Gemini 3.5 Flash | gemini-3.5-flash | `https://opencode.ai/zen/v1/models/gemini-3.5-flash` | `@ai-sdk/google` | | Gemini 3.1 Pro | gemini-3.1-pro | `https://opencode.ai/zen/v1/models/gemini-3.1-pro` | `@ai-sdk/google` | | Gemini 3 Flash | gemini-3-flash | `https://opencode.ai/zen/v1/models/gemini-3-flash` | `@ai-sdk/google` | +| Qwen3.7 Max | qwen3.7-max | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| DeepSeek V4 Flash | deepseek-v4-flash | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | GLM 5.1 | glm-5.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | @@ -128,6 +130,7 @@ https://opencode.ai/zen/v1/models | Qwen3.7 Max | $2.50 | $7.50 | $0.50 | $3.125 | | Qwen3.6 Plus | $0.50 | $3.00 | $0.05 | $0.625 | | Qwen3.5 Plus | $0.20 | $1.20 | $0.02 | $0.25 | +| DeepSeek V4 Flash | $0.14 | $0.28 | $0.03 | - | | Grok Build 0.1 | $1.00 | $2.00 | $0.20 | - | | Claude Opus 4.8 | $5.00 | $25.00 | $0.50 | $6.25 | | Claude Opus 4.7 | $5.00 | $25.00 | $0.50 | $6.25 | diff --git a/packages/web/src/content/docs/zh-tw/go.mdx b/packages/web/src/content/docs/zh-tw/go.mdx index e314a4db21..efe050dbaf 100644 --- a/packages/web/src/content/docs/zh-tw/go.mdx +++ b/packages/web/src/content/docs/zh-tw/go.mdx @@ -93,7 +93,7 @@ OpenCode Go 包含以下限制: | DeepSeek V4 Pro | 3,450 | 8,550 | 17,150 | | DeepSeek V4 Flash | 31,650 | 79,050 | 158,150 | -預估值是基於觀察到的平均請求模式: +這些預估值是基於觀察到的平均請求模式: - GLM-5/5.1 — 每次請求 700 個輸入 token、52,000 個快取 token、150 個輸出 token - Kimi K2.5/K2.6 — 每次請求 870 個輸入 token、55,000 個快取 token、200 個輸出 token @@ -105,6 +105,23 @@ OpenCode Go 包含以下限制: - MiMo-V2.5 — 每次請求 830 個輸入 token、71,500 個快取 token、295 個輸出 token - MiMo-V2.5-Pro — 每次請求 790 個輸入 token、86,000 個快取 token、305 個輸出 token +這些預估值也基於以下每 1M tokens 的價格: + +| 模型 | 輸入 | 輸出 | 快取讀取 | 快取寫入 | +| ----------------- | ----- | ----- | -------- | -------- | +| GLM-5.1 | $1.40 | $4.40 | $0.26 | - | +| GLM-5 | $1.00 | $3.20 | $0.20 | - | +| Kimi K2.6 | $0.95 | $4.00 | $0.16 | - | +| Kimi K2.5 | $0.60 | $3.00 | $0.10 | - | +| MiMo V2.5 | $0.14 | $0.28 | $0.0028 | - | +| MiMo V2.5 Pro | $1.74 | $3.48 | $0.0145 | - | +| MiniMax M2.7 | $0.30 | $1.20 | $0.06 | $0.375 | +| MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 | +| Qwen3.7 Max | $2.50 | $7.50 | $0.50 | $3.125 | +| Qwen3.6 Plus | $0.50 | $3.00 | $0.05 | $0.625 | +| DeepSeek V4 Pro | $1.74 | $3.48 | $0.0145 | - | +| DeepSeek V4 Flash | $0.14 | $0.28 | $0.0028 | - | + 您可以在 **console** 中追蹤您目前的使用量。 :::tip diff --git a/packages/web/src/content/docs/zh-tw/zen.mdx b/packages/web/src/content/docs/zh-tw/zen.mdx index f3aef05cf5..00c65b0783 100644 --- a/packages/web/src/content/docs/zh-tw/zen.mdx +++ b/packages/web/src/content/docs/zh-tw/zen.mdx @@ -85,8 +85,10 @@ OpenCode Zen 的運作方式和 OpenCode 中的其他供應商一樣。 | Gemini 3.5 Flash | gemini-3.5-flash | `https://opencode.ai/zen/v1/models/gemini-3.5-flash` | `@ai-sdk/google` | | Gemini 3.1 Pro | gemini-3.1-pro | `https://opencode.ai/zen/v1/models/gemini-3.1-pro` | `@ai-sdk/google` | | Gemini 3 Flash | gemini-3-flash | `https://opencode.ai/zen/v1/models/gemini-3-flash` | `@ai-sdk/google` | +| Qwen3.7 Max | qwen3.7-max | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| DeepSeek V4 Flash | deepseek-v4-flash | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | GLM 5.1 | glm-5.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | @@ -133,6 +135,7 @@ https://opencode.ai/zen/v1/models | Qwen3.7 Max | $2.50 | $7.50 | $0.50 | $3.125 | | Qwen3.6 Plus | $0.50 | $3.00 | $0.05 | $0.625 | | Qwen3.5 Plus | $0.20 | $1.20 | $0.02 | $0.25 | +| DeepSeek V4 Flash | $0.14 | $0.28 | $0.03 | - | | Grok Build 0.1 | $1.00 | $2.00 | $0.20 | - | | Claude Opus 4.8 | $5.00 | $25.00 | $0.50 | $6.25 | | Claude Opus 4.7 | $5.00 | $25.00 | $0.50 | $6.25 | From abaabdcb738652e83526af08a6d805f1d5fd5afc Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Sun, 31 May 2026 18:30:29 +0000 Subject: [PATCH 042/840] fix(tui): remount session view on session switch (#30129) Co-authored-by: opencode-agent[bot] --- packages/opencode/src/cli/cmd/tui/app.tsx | 4 +++- packages/opencode/src/worktree/index.ts | 17 +++++++++++++---- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 68803d0d11..95321f71b2 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -1082,7 +1082,9 @@ function App(props: { onSnapshot?: () => Promise }) { - + + {(_) => } + {plugin()} diff --git a/packages/opencode/src/worktree/index.ts b/packages/opencode/src/worktree/index.ts index 6565555737..7a866c24c9 100644 --- a/packages/opencode/src/worktree/index.ts +++ b/packages/opencode/src/worktree/index.ts @@ -384,10 +384,19 @@ export const layer: Layer.Layer< function cleanDirectory(target: string) { return Effect.tryPromise({ - try: () => - import("fs/promises").then((fsp) => - fsp.rm(target, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }), - ), + try: async () => { + const fsp = await import("fs/promises") + const attempts = process.platform === "win32" ? 50 : 5 + for (const attempt of Array.from({ length: attempts }, (_, i) => i)) { + try { + await fsp.rm(target, { recursive: true, force: true }) + return + } catch (error) { + if (attempt === attempts - 1) throw error + await new Promise((resolve) => setTimeout(resolve, 100)) + } + } + }, catch: (error) => new RemoveFailedError({ message: errorMessage(error) || "Failed to remove git worktree directory" }), }) From c57379833e3f25967c03653482fc3131b3068c04 Mon Sep 17 00:00:00 2001 From: Frank Date: Sun, 31 May 2026 15:11:15 -0400 Subject: [PATCH 043/840] go: minimax m3 --- packages/cli/sst-env.d.ts | 10 ++++++++++ packages/console/app/src/i18n/ar.ts | 8 ++++---- packages/console/app/src/i18n/br.ts | 8 ++++---- packages/console/app/src/i18n/da.ts | 8 ++++---- packages/console/app/src/i18n/de.ts | 8 ++++---- packages/console/app/src/i18n/en.ts | 8 ++++---- packages/console/app/src/i18n/es.ts | 8 ++++---- packages/console/app/src/i18n/fr.ts | 8 ++++---- packages/console/app/src/i18n/it.ts | 8 ++++---- packages/console/app/src/i18n/ja.ts | 8 ++++---- packages/console/app/src/i18n/ko.ts | 8 ++++---- packages/console/app/src/i18n/no.ts | 8 ++++---- packages/console/app/src/i18n/pl.ts | 8 ++++---- packages/console/app/src/i18n/ru.ts | 8 ++++---- packages/console/app/src/i18n/th.ts | 8 ++++---- packages/console/app/src/i18n/tr.ts | 8 ++++---- packages/console/app/src/i18n/uk.ts | 8 ++++---- packages/console/app/src/i18n/zh.ts | 8 ++++---- packages/console/app/src/i18n/zht.ts | 8 ++++---- packages/console/app/src/routes/go/index.tsx | 12 +++++++----- .../src/routes/workspace/[id]/go/lite-section.tsx | 1 + packages/effect-sqlite-node/sst-env.d.ts | 10 ++++++++++ packages/web/src/content/docs/ar/go.mdx | 5 +++++ packages/web/src/content/docs/ar/zen.mdx | 2 +- packages/web/src/content/docs/bs/go.mdx | 5 +++++ packages/web/src/content/docs/bs/zen.mdx | 2 +- packages/web/src/content/docs/da/go.mdx | 5 +++++ packages/web/src/content/docs/da/zen.mdx | 2 +- packages/web/src/content/docs/de/go.mdx | 5 +++++ packages/web/src/content/docs/de/zen.mdx | 2 +- packages/web/src/content/docs/es/go.mdx | 5 +++++ packages/web/src/content/docs/es/zen.mdx | 2 +- packages/web/src/content/docs/fr/go.mdx | 5 +++++ packages/web/src/content/docs/fr/zen.mdx | 2 +- packages/web/src/content/docs/go.mdx | 5 +++++ packages/web/src/content/docs/it/go.mdx | 5 +++++ packages/web/src/content/docs/it/zen.mdx | 2 +- packages/web/src/content/docs/ja/go.mdx | 5 +++++ packages/web/src/content/docs/ja/zen.mdx | 2 +- packages/web/src/content/docs/ko/go.mdx | 5 +++++ packages/web/src/content/docs/ko/zen.mdx | 2 +- packages/web/src/content/docs/nb/go.mdx | 5 +++++ packages/web/src/content/docs/nb/zen.mdx | 2 +- packages/web/src/content/docs/pl/go.mdx | 5 +++++ packages/web/src/content/docs/pl/zen.mdx | 2 +- packages/web/src/content/docs/pt-br/go.mdx | 5 +++++ packages/web/src/content/docs/pt-br/zen.mdx | 2 +- packages/web/src/content/docs/ru/go.mdx | 5 +++++ packages/web/src/content/docs/ru/zen.mdx | 2 +- packages/web/src/content/docs/th/go.mdx | 5 +++++ packages/web/src/content/docs/th/zen.mdx | 2 +- packages/web/src/content/docs/tr/go.mdx | 5 +++++ packages/web/src/content/docs/tr/zen.mdx | 2 +- packages/web/src/content/docs/zen.mdx | 2 +- packages/web/src/content/docs/zh-cn/go.mdx | 5 +++++ packages/web/src/content/docs/zh-cn/zen.mdx | 2 +- packages/web/src/content/docs/zh-tw/go.mdx | 5 +++++ packages/web/src/content/docs/zh-tw/zen.mdx | 2 +- 58 files changed, 208 insertions(+), 95 deletions(-) create mode 100644 packages/cli/sst-env.d.ts create mode 100644 packages/effect-sqlite-node/sst-env.d.ts diff --git a/packages/cli/sst-env.d.ts b/packages/cli/sst-env.d.ts new file mode 100644 index 0000000000..64441936d7 --- /dev/null +++ b/packages/cli/sst-env.d.ts @@ -0,0 +1,10 @@ +/* This file is auto-generated by SST. Do not edit. */ +/* tslint:disable */ +/* eslint-disable */ +/* deno-fmt-ignore-file */ +/* biome-ignore-all lint: auto-generated */ + +/// + +import "sst" +export {} \ No newline at end of file diff --git a/packages/console/app/src/i18n/ar.ts b/packages/console/app/src/i18n/ar.ts index f9efb62e55..4219792618 100644 --- a/packages/console/app/src/i18n/ar.ts +++ b/packages/console/app/src/i18n/ar.ts @@ -249,7 +249,7 @@ export const dict = { "go.title": "OpenCode Go | نماذج برمجة منخفضة التكلفة للجميع", "go.meta.description": - "يبدأ Go من $5 للشهر الأول، ثم $10/شهر، مع حدود طلب سخية لمدة 5 ساعات لـ GLM-5.1 وGLM-5 وKimi K2.5 وKimi K2.6 وMiMo-V2.5-Pro وMiMo-V2.5 وQwen3.7 Max وQwen3.6 Plus وMiniMax M2.5 وMiniMax M2.7 وDeepSeek V4 Pro وDeepSeek V4 Flash.", + "يبدأ Go من $5 للشهر الأول، ثم $10/شهر، مع حدود طلب سخية لمدة 5 ساعات لـ GLM-5.1 وGLM-5 وKimi K2.5 وKimi K2.6 وMiMo-V2.5-Pro وMiMo-V2.5 وQwen3.7 Max وQwen3.6 Plus وMiniMax M2.5 وMiniMax M2.7 وMiniMax M3 وDeepSeek V4 Pro وDeepSeek V4 Flash.", "go.hero.title": "نماذج برمجة منخفضة التكلفة للجميع", "go.hero.body": "يجلب Go البرمجة الوكيلة للمبرمجين حول العالم. يوفر حدودًا سخية ووصولًا موثوقًا إلى أقوى النماذج مفتوحة المصدر، حتى تتمكن من البناء باستخدام وكلاء أقوياء دون القلق بشأن التكلفة أو التوفر.", @@ -298,7 +298,7 @@ export const dict = { "go.problem.item2": "حدود سخية ووصول موثوق", "go.problem.item3": "مصمم لأكبر عدد ممكن من المبرمجين", "go.problem.item4": - "يتضمن GLM-5.1 وGLM-5 وKimi K2.5 وKimi K2.6 وMiMo-V2.5-Pro وMiMo-V2.5 وQwen3.7 Max وQwen3.6 Plus وMiniMax M2.5 وMiniMax M2.7 وDeepSeek V4 Pro وDeepSeek V4 Flash", + "يتضمن GLM-5.1 وGLM-5 وKimi K2.5 وKimi K2.6 وMiMo-V2.5-Pro وMiMo-V2.5 وQwen3.7 Max وQwen3.6 Plus وMiniMax M2.5 وMiniMax M2.7 وMiniMax M3 وDeepSeek V4 Pro وDeepSeek V4 Flash", "go.how.title": "كيف يعمل Go", "go.how.body": "يبدأ Go من $5 للشهر الأول، ثم $10/شهر. يمكنك استخدامه مع OpenCode أو أي وكيل.", "go.how.step1.title": "أنشئ حسابًا", @@ -322,7 +322,7 @@ export const dict = { "go.faq.a2": "يتضمن Go النماذج المدرجة أدناه، مع حدود سخية وإتاحة موثوقة.", "go.faq.q3": "هل Go هو نفسه Zen؟", "go.faq.a3": - "لا. Zen هو الدفع حسب الاستخدام، بينما يبدأ Go من $5 للشهر الأول، ثم $10/شهر، مع حدود سخية ووصول موثوق إلى نماذج المصدر المفتوح GLM-5.1 وGLM-5 وKimi K2.5 وKimi K2.6 وMiMo-V2.5-Pro وMiMo-V2.5 وQwen3.7 Max وQwen3.6 Plus وMiniMax M2.5 وMiniMax M2.7 وDeepSeek V4 Pro وDeepSeek V4 Flash.", + "لا. Zen هو الدفع حسب الاستخدام، بينما يبدأ Go من $5 للشهر الأول، ثم $10/شهر، مع حدود سخية ووصول موثوق إلى نماذج المصدر المفتوح GLM-5.1 وGLM-5 وKimi K2.5 وKimi K2.6 وMiMo-V2.5-Pro وMiMo-V2.5 وQwen3.7 Max وQwen3.6 Plus وMiniMax M2.5 وMiniMax M2.7 وMiniMax M3 وDeepSeek V4 Pro وDeepSeek V4 Flash.", "go.faq.q4": "كم تكلفة Go؟", "go.faq.a4.p1.beforePricing": "تكلفة Go", "go.faq.a4.p1.pricingLink": "$5 للشهر الأول", @@ -345,7 +345,7 @@ export const dict = { "go.faq.q9": "ما الفرق بين النماذج المجانية وGo؟", "go.faq.a9": - "تشمل النماذج المجانية Big Pickle بالإضافة إلى النماذج الترويجية المتاحة في ذلك الوقت، مع حصة 200 طلب/يوم. يتضمن Go نماذج GLM-5.1 وGLM-5 وKimi K2.5 وKimi K2.6 وMiMo-V2.5-Pro وMiMo-V2.5 وQwen3.7 Max وQwen3.6 Plus وMiniMax M2.5 وMiniMax M2.7 وDeepSeek V4 Pro وDeepSeek V4 Flash مع حصص طلبات أعلى مطبقة عبر نوافذ متجددة (5 ساعات، أسبوعيًا، وشهريًا)، تعادل تقريبًا 12 دولارًا كل 5 ساعات، و30 دولارًا في الأسبوع، و60 دولارًا في الشهر (تختلف أعداد الطلبات الفعلية حسب النموذج والاستخدام).", + "تشمل النماذج المجانية Big Pickle بالإضافة إلى النماذج الترويجية المتاحة في ذلك الوقت، مع حصة 200 طلب/يوم. يتضمن Go نماذج GLM-5.1 وGLM-5 وKimi K2.5 وKimi K2.6 وMiMo-V2.5-Pro وMiMo-V2.5 وQwen3.7 Max وQwen3.6 Plus وMiniMax M2.5 وMiniMax M2.7 وMiniMax M3 وDeepSeek V4 Pro وDeepSeek V4 Flash مع حصص طلبات أعلى مطبقة عبر نوافذ متجددة (5 ساعات، أسبوعيًا، وشهريًا)، تعادل تقريبًا 12 دولارًا كل 5 ساعات، و30 دولارًا في الأسبوع، و60 دولارًا في الشهر (تختلف أعداد الطلبات الفعلية حسب النموذج والاستخدام).", "zen.api.error.rateLimitExceeded": "تم تجاوز حد الطلبات. يرجى المحاولة مرة أخرى لاحقًا.", "zen.api.error.modelNotSupported": "النموذج {{model}} غير مدعوم", diff --git a/packages/console/app/src/i18n/br.ts b/packages/console/app/src/i18n/br.ts index 7cb326a5a2..bd9f1960aa 100644 --- a/packages/console/app/src/i18n/br.ts +++ b/packages/console/app/src/i18n/br.ts @@ -253,7 +253,7 @@ export const dict = { "go.title": "OpenCode Go | Modelos de codificação de baixo custo para todos", "go.meta.description": - "O Go começa em $5 no primeiro mês, depois $10/mês, com limites generosos de solicitação de 5 horas para GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro e DeepSeek V4 Flash.", + "O Go começa em $5 no primeiro mês, depois $10/mês, com limites generosos de solicitação de 5 horas para GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro e DeepSeek V4 Flash.", "go.hero.title": "Modelos de codificação de baixo custo para todos", "go.hero.body": "O Go traz a codificação com agentes para programadores em todo o mundo. Oferecendo limites generosos e acesso confiável aos modelos de código aberto mais capazes, para que você possa construir com agentes poderosos sem se preocupar com custos ou disponibilidade.", @@ -303,7 +303,7 @@ export const dict = { "go.problem.item2": "Limites generosos e acesso confiável", "go.problem.item3": "Feito para o maior número possível de programadores", "go.problem.item4": - "Inclui GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro e DeepSeek V4 Flash", + "Inclui GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro e DeepSeek V4 Flash", "go.how.title": "Como o Go funciona", "go.how.body": "O Go começa em $5 no primeiro mês, depois $10/mês. Você pode usá-lo com o OpenCode ou qualquer agente.", @@ -329,7 +329,7 @@ export const dict = { "go.faq.a2": "O Go inclui os modelos listados abaixo, com limites generosos e acesso confiável.", "go.faq.q3": "O Go é o mesmo que o Zen?", "go.faq.a3": - "Não. Zen é pay-as-you-go, enquanto o Go começa em $5 no primeiro mês, depois $10/mês, com limites generosos e acesso confiável aos modelos open source GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro e DeepSeek V4 Flash.", + "Não. Zen é pay-as-you-go, enquanto o Go começa em $5 no primeiro mês, depois $10/mês, com limites generosos e acesso confiável aos modelos open source GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro e DeepSeek V4 Flash.", "go.faq.q4": "Quanto custa o Go?", "go.faq.a4.p1.beforePricing": "O Go custa", "go.faq.a4.p1.pricingLink": "$5 no primeiro mês", @@ -353,7 +353,7 @@ export const dict = { "go.faq.q9": "Qual a diferença entre os modelos gratuitos e o Go?", "go.faq.a9": - "Os modelos gratuitos incluem Big Pickle e modelos promocionais disponíveis no momento, com uma cota de 200 requisições/dia. O Go inclui GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro e DeepSeek V4 Flash com cotas de requisição mais altas aplicadas em janelas móveis (5 horas, semanal e mensal), aproximadamente equivalentes a $12 por 5 horas, $30 por semana e $60 por mês (as contagens reais de requisições variam de acordo com o modelo e o uso).", + "Os modelos gratuitos incluem Big Pickle e modelos promocionais disponíveis no momento, com uma cota de 200 requisições/dia. O Go inclui GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro e DeepSeek V4 Flash com cotas de requisição mais altas aplicadas em janelas móveis (5 horas, semanal e mensal), aproximadamente equivalentes a $12 por 5 horas, $30 por semana e $60 por mês (as contagens reais de requisições variam de acordo com o modelo e o uso).", "zen.api.error.rateLimitExceeded": "Limite de taxa excedido. Por favor, tente novamente mais tarde.", "zen.api.error.modelNotSupported": "Modelo {{model}} não suportado", diff --git a/packages/console/app/src/i18n/da.ts b/packages/console/app/src/i18n/da.ts index 7c6312e0a0..25a8f21624 100644 --- a/packages/console/app/src/i18n/da.ts +++ b/packages/console/app/src/i18n/da.ts @@ -251,7 +251,7 @@ export const dict = { "go.title": "OpenCode Go | Kodningsmodeller til lav pris for alle", "go.meta.description": - "Go starter ved $5 for den første måned, derefter $10/måned, med generøse 5-timers anmodningsgrænser for GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro og DeepSeek V4 Flash.", + "Go starter ved $5 for den første måned, derefter $10/måned, med generøse 5-timers anmodningsgrænser for GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro og DeepSeek V4 Flash.", "go.hero.title": "Kodningsmodeller til lav pris for alle", "go.hero.body": "Go bringer agentisk kodning til programmører over hele verden. Med generøse grænser og pålidelig adgang til de mest kapable open source-modeller, så du kan bygge med kraftfulde agenter uden at bekymre dig om omkostninger eller tilgængelighed.", @@ -300,7 +300,7 @@ export const dict = { "go.problem.item2": "Generøse grænser og pålidelig adgang", "go.problem.item3": "Bygget til så mange programmører som muligt", "go.problem.item4": - "Inkluderer GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro og DeepSeek V4 Flash", + "Inkluderer GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro og DeepSeek V4 Flash", "go.how.title": "Hvordan Go virker", "go.how.body": "Go starter ved $5 for den første måned, derefter $10/måned. Du kan bruge det med OpenCode eller enhver agent.", @@ -326,7 +326,7 @@ export const dict = { "go.faq.a2": "Go inkluderer modellerne nedenfor med generøse grænser og pålidelig adgang.", "go.faq.q3": "Er Go det samme som Zen?", "go.faq.a3": - "Nej. Zen er pay-as-you-go, mens Go starter ved $5 for den første måned, derefter $10/måned, med generøse grænser og pålidelig adgang til open source-modellerne GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro og DeepSeek V4 Flash.", + "Nej. Zen er pay-as-you-go, mens Go starter ved $5 for den første måned, derefter $10/måned, med generøse grænser og pålidelig adgang til open source-modellerne GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro og DeepSeek V4 Flash.", "go.faq.q4": "Hvad koster Go?", "go.faq.a4.p1.beforePricing": "Go koster", "go.faq.a4.p1.pricingLink": "$5 første måned", @@ -349,7 +349,7 @@ export const dict = { "go.faq.q9": "Hvad er forskellen på gratis modeller og Go?", "go.faq.a9": - "Gratis modeller inkluderer Big Pickle plus salgsfremmende modeller tilgængelige på det tidspunkt, med en kvote på 200 forespørgsler/dag. Go inkluderer GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro og DeepSeek V4 Flash med højere anmodningskvoter håndhævet over rullende vinduer (5-timers, ugentlig og månedlig), nogenlunde svarende til $12 pr. 5 timer, $30 pr. uge og $60 pr. måned (faktiske anmodningstal varierer efter model og brug).", + "Gratis modeller inkluderer Big Pickle plus salgsfremmende modeller tilgængelige på det tidspunkt, med en kvote på 200 forespørgsler/dag. Go inkluderer GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro og DeepSeek V4 Flash med højere anmodningskvoter håndhævet over rullende vinduer (5-timers, ugentlig og månedlig), nogenlunde svarende til $12 pr. 5 timer, $30 pr. uge og $60 pr. måned (faktiske anmodningstal varierer efter model og brug).", "zen.api.error.rateLimitExceeded": "Hastighedsgrænse overskredet. Prøv venligst igen senere.", "zen.api.error.modelNotSupported": "Model {{model}} understøttes ikke", diff --git a/packages/console/app/src/i18n/de.ts b/packages/console/app/src/i18n/de.ts index de5237cca0..3ceb778893 100644 --- a/packages/console/app/src/i18n/de.ts +++ b/packages/console/app/src/i18n/de.ts @@ -253,7 +253,7 @@ export const dict = { "go.title": "OpenCode Go | Kostengünstige Coding-Modelle für alle", "go.meta.description": - "Go beginnt bei $5 für den ersten Monat, danach $10/Monat, mit großzügigen 5-Stunden-Anfragelimits für GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro und DeepSeek V4 Flash.", + "Go beginnt bei $5 für den ersten Monat, danach $10/Monat, mit großzügigen 5-Stunden-Anfragelimits für GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro und DeepSeek V4 Flash.", "go.hero.title": "Kostengünstige Coding-Modelle für alle", "go.hero.body": "Go bringt Agentic Coding zu Programmierern auf der ganzen Welt. Mit großzügigen Limits und zuverlässigem Zugang zu den leistungsfähigsten Open-Source-Modellen, damit du mit leistungsstarken Agenten entwickeln kannst, ohne dir Gedanken über Kosten oder Verfügbarkeit zu machen.", @@ -302,7 +302,7 @@ export const dict = { "go.problem.item2": "Großzügige Limits und zuverlässiger Zugang", "go.problem.item3": "Für so viele Programmierer wie möglich gebaut", "go.problem.item4": - "Beinhaltet GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro und DeepSeek V4 Flash", + "Beinhaltet GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro und DeepSeek V4 Flash", "go.how.title": "Wie Go funktioniert", "go.how.body": "Go beginnt bei $5 für den ersten Monat, danach $10/Monat. Du kannst es mit OpenCode oder jedem Agenten nutzen.", @@ -328,7 +328,7 @@ export const dict = { "go.faq.a2": "Go umfasst die unten aufgeführten Modelle mit großzügigen Limits und zuverlässigem Zugriff.", "go.faq.q3": "Ist Go dasselbe wie Zen?", "go.faq.a3": - "Nein. Zen ist Pay-as-you-go, während Go bei $5 für den ersten Monat beginnt, danach $10/Monat, mit großzügigen Limits und zuverlässigem Zugang zu den Open-Source-Modellen GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro und DeepSeek V4 Flash.", + "Nein. Zen ist Pay-as-you-go, während Go bei $5 für den ersten Monat beginnt, danach $10/Monat, mit großzügigen Limits und zuverlässigem Zugang zu den Open-Source-Modellen GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro und DeepSeek V4 Flash.", "go.faq.q4": "Wie viel kostet Go?", "go.faq.a4.p1.beforePricing": "Go kostet", "go.faq.a4.p1.pricingLink": "$5 im ersten Monat", @@ -352,7 +352,7 @@ export const dict = { "go.faq.q9": "Was ist der Unterschied zwischen kostenlosen Modellen und Go?", "go.faq.a9": - "Kostenlose Modelle beinhalten Big Pickle sowie Werbemodelle, die zum jeweiligen Zeitpunkt verfügbar sind, mit einem Kontingent von 200 Anfragen/Tag. Go beinhaltet GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro und DeepSeek V4 Flash mit höheren Anfragekontingenten, die über rollierende Zeitfenster (5 Stunden, wöchentlich und monatlich) durchgesetzt werden, grob äquivalent zu $12 pro 5 Stunden, $30 pro Woche und $60 pro Monat (tatsächliche Anfragezahlen variieren je nach Modell und Nutzung).", + "Kostenlose Modelle beinhalten Big Pickle sowie Werbemodelle, die zum jeweiligen Zeitpunkt verfügbar sind, mit einem Kontingent von 200 Anfragen/Tag. Go beinhaltet GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro und DeepSeek V4 Flash mit höheren Anfragekontingenten, die über rollierende Zeitfenster (5 Stunden, wöchentlich und monatlich) durchgesetzt werden, grob äquivalent zu $12 pro 5 Stunden, $30 pro Woche und $60 pro Monat (tatsächliche Anfragezahlen variieren je nach Modell und Nutzung).", "zen.api.error.rateLimitExceeded": "Ratenlimit überschritten. Bitte versuche es später erneut.", "zen.api.error.modelNotSupported": "Modell {{model}} wird nicht unterstützt", diff --git a/packages/console/app/src/i18n/en.ts b/packages/console/app/src/i18n/en.ts index 950fa7543d..3f1cdda074 100644 --- a/packages/console/app/src/i18n/en.ts +++ b/packages/console/app/src/i18n/en.ts @@ -248,7 +248,7 @@ export const dict = { "go.title": "OpenCode Go | Low cost coding models for everyone", "go.meta.description": - "Go starts at $5 for your first month, then $10/month, with generous 5-hour request limits for GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, and DeepSeek V4 Flash.", + "Go starts at $5 for your first month, then $10/month, with generous 5-hour request limits for GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, and DeepSeek V4 Flash.", "go.hero.title": "Low cost coding models for everyone", "go.hero.body": "Go brings agentic coding to programmers around the world. Offering generous limits and reliable access to the most capable open-source models, so you can build with powerful agents without worrying about cost or availability.", @@ -296,7 +296,7 @@ export const dict = { "go.problem.item2": "Generous limits and reliable access", "go.problem.item3": "Built for as many programmers as possible", "go.problem.item4": - "Includes GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, and DeepSeek V4 Flash", + "Includes GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, and DeepSeek V4 Flash", "go.how.title": "How Go works", "go.how.body": "Go starts at $5 for your first month, then $10/month. You can use it with OpenCode or any agent.", "go.how.step1.title": "Create an account", @@ -321,7 +321,7 @@ export const dict = { "go.faq.a2": "Go includes the models listed below, with generous limits and reliable access.", "go.faq.q3": "Is Go the same as Zen?", "go.faq.a3": - "No. Zen is pay-as-you-go, while Go starts at $5 for your first month, then $10/month, with generous limits and reliable access to open-source models GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, and DeepSeek V4 Flash.", + "No. Zen is pay-as-you-go, while Go starts at $5 for your first month, then $10/month, with generous limits and reliable access to open-source models GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, and DeepSeek V4 Flash.", "go.faq.q4": "How much does Go cost?", "go.faq.a4.p1.beforePricing": "Go costs", "go.faq.a4.p1.pricingLink": "$5 first month", @@ -345,7 +345,7 @@ export const dict = { "go.faq.q9": "What is the difference between free models and Go?", "go.faq.a9": - "Free models include Big Pickle plus promotional models available at the time, with a quota of 200 requests/day. Go includes GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, and DeepSeek V4 Flash with higher request quotas enforced across rolling windows (5-hour, weekly, and monthly), roughly equivalent to $12 per 5 hours, $30 per week, and $60 per month (actual request counts vary by model and usage).", + "Free models include Big Pickle plus promotional models available at the time, with a quota of 200 requests/day. Go includes GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, and DeepSeek V4 Flash with higher request quotas enforced across rolling windows (5-hour, weekly, and monthly), roughly equivalent to $12 per 5 hours, $30 per week, and $60 per month (actual request counts vary by model and usage).", "zen.api.error.rateLimitExceeded": "Rate limit exceeded. Please try again later.", "zen.api.error.modelNotSupported": "Model {{model}} is not supported", diff --git a/packages/console/app/src/i18n/es.ts b/packages/console/app/src/i18n/es.ts index 0027736015..ac39e1751a 100644 --- a/packages/console/app/src/i18n/es.ts +++ b/packages/console/app/src/i18n/es.ts @@ -254,7 +254,7 @@ export const dict = { "go.title": "OpenCode Go | Modelos de programación de bajo coste para todos", "go.meta.description": - "Go comienza en $5 el primer mes, luego 10 $/mes, con generosos límites de solicitudes de 5 horas para GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro y DeepSeek V4 Flash.", + "Go comienza en $5 el primer mes, luego 10 $/mes, con generosos límites de solicitudes de 5 horas para GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro y DeepSeek V4 Flash.", "go.hero.title": "Modelos de programación de bajo coste para todos", "go.hero.body": "Go lleva la programación agéntica a programadores de todo el mundo. Ofrece límites generosos y acceso fiable a los modelos de código abierto más capaces, para que puedas crear con agentes potentes sin preocuparte por el coste o la disponibilidad.", @@ -304,7 +304,7 @@ export const dict = { "go.problem.item2": "Límites generosos y acceso fiable", "go.problem.item3": "Creado para tantos programadores como sea posible", "go.problem.item4": - "Incluye GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro y DeepSeek V4 Flash", + "Incluye GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro y DeepSeek V4 Flash", "go.how.title": "Cómo funciona Go", "go.how.body": "Go comienza en $5 el primer mes, luego 10 $/mes. Puedes usarlo con OpenCode o cualquier agente.", "go.how.step1.title": "Crear una cuenta", @@ -329,7 +329,7 @@ export const dict = { "go.faq.a2": "Go incluye los modelos que se indican abajo, con límites generosos y acceso confiable.", "go.faq.q3": "¿Es Go lo mismo que Zen?", "go.faq.a3": - "No. Zen es pago por uso, mientras que Go comienza en $5 el primer mes, luego 10 $/mes, con límites generosos y acceso fiable a los modelos de código abierto GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro y DeepSeek V4 Flash.", + "No. Zen es pago por uso, mientras que Go comienza en $5 el primer mes, luego 10 $/mes, con límites generosos y acceso fiable a los modelos de código abierto GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro y DeepSeek V4 Flash.", "go.faq.q4": "¿Cuánto cuesta Go?", "go.faq.a4.p1.beforePricing": "Go cuesta", "go.faq.a4.p1.pricingLink": "$5 el primer mes", @@ -353,7 +353,7 @@ export const dict = { "go.faq.q9": "¿Cuál es la diferencia entre los modelos gratuitos y Go?", "go.faq.a9": - "Los modelos gratuitos incluyen Big Pickle más modelos promocionales disponibles en el momento, con una cuota de 200 solicitudes/día. Go incluye GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro y DeepSeek V4 Flash con cuotas de solicitud más altas aplicadas a través de ventanas móviles (5 horas, semanal y mensual), aproximadamente equivalente a 12 $ por 5 horas, 30 $ por semana y 60 $ por mes (los recuentos reales de solicitudes varían según el modelo y el uso).", + "Los modelos gratuitos incluyen Big Pickle más modelos promocionales disponibles en el momento, con una cuota de 200 solicitudes/día. Go incluye GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro y DeepSeek V4 Flash con cuotas de solicitud más altas aplicadas a través de ventanas móviles (5 horas, semanal y mensual), aproximadamente equivalente a 12 $ por 5 horas, 30 $ por semana y 60 $ por mes (los recuentos reales de solicitudes varían según el modelo y el uso).", "zen.api.error.rateLimitExceeded": "Límite de tasa excedido. Por favor, inténtalo de nuevo más tarde.", "zen.api.error.modelNotSupported": "Modelo {{model}} no soportado", diff --git a/packages/console/app/src/i18n/fr.ts b/packages/console/app/src/i18n/fr.ts index 2918706fc9..6dbfd44ebe 100644 --- a/packages/console/app/src/i18n/fr.ts +++ b/packages/console/app/src/i18n/fr.ts @@ -255,7 +255,7 @@ export const dict = { "go.title": "OpenCode Go | Modèles de code à faible coût pour tous", "go.meta.description": - "Go commence à $5 pour le premier mois, puis 10 $/mois, avec des limites de requêtes généreuses sur 5 heures pour GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro et DeepSeek V4 Flash.", + "Go commence à $5 pour le premier mois, puis 10 $/mois, avec des limites de requêtes généreuses sur 5 heures pour GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro et DeepSeek V4 Flash.", "go.hero.title": "Modèles de code à faible coût pour tous", "go.hero.body": "Go apporte le codage agentique aux programmeurs du monde entier. Offrant des limites généreuses et un accès fiable aux modèles open source les plus capables, pour que vous puissiez construire avec des agents puissants sans vous soucier du coût ou de la disponibilité.", @@ -304,7 +304,7 @@ export const dict = { "go.problem.item2": "Limites généreuses et accès fiable", "go.problem.item3": "Conçu pour autant de programmeurs que possible", "go.problem.item4": - "Inclut GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro et DeepSeek V4 Flash", + "Inclut GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro et DeepSeek V4 Flash", "go.how.title": "Comment fonctionne Go", "go.how.body": "Go commence à $5 pour le premier mois, puis 10 $/mois. Vous pouvez l'utiliser avec OpenCode ou n'importe quel agent.", @@ -330,7 +330,7 @@ export const dict = { "go.faq.a2": "Go inclut les modèles ci-dessous, avec des limites généreuses et un accès fiable.", "go.faq.q3": "Est-ce que Go est la même chose que Zen ?", "go.faq.a3": - "Non. Zen est un paiement à l'utilisation, tandis que Go commence à $5 pour le premier mois, puis 10 $/mois, avec des limites généreuses et un accès fiable aux modèles open source GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro et DeepSeek V4 Flash.", + "Non. Zen est un paiement à l'utilisation, tandis que Go commence à $5 pour le premier mois, puis 10 $/mois, avec des limites généreuses et un accès fiable aux modèles open source GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro et DeepSeek V4 Flash.", "go.faq.q4": "Combien coûte Go ?", "go.faq.a4.p1.beforePricing": "Go coûte", "go.faq.a4.p1.pricingLink": "$5 le premier mois", @@ -353,7 +353,7 @@ export const dict = { "Oui, vous pouvez utiliser Go avec n'importe quel agent. Suivez les instructions de configuration dans votre agent de code préféré.", "go.faq.q9": "Quelle est la différence entre les modèles gratuits et Go ?", "go.faq.a9": - "Les modèles gratuits incluent Big Pickle ainsi que des modèles promotionnels disponibles à ce moment-là, avec un quota de 200 requêtes/jour. Go inclut GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro et DeepSeek V4 Flash avec des quotas de requêtes plus élevés appliqués sur des fenêtres glissantes (5 heures, hebdomadaire et mensuelle), à peu près équivalent à 12 $ par 5 heures, 30 $ par semaine et 60 $ par mois (le nombre réel de requêtes varie selon le modèle et l'utilisation).", + "Les modèles gratuits incluent Big Pickle ainsi que des modèles promotionnels disponibles à ce moment-là, avec un quota de 200 requêtes/jour. Go inclut GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro et DeepSeek V4 Flash avec des quotas de requêtes plus élevés appliqués sur des fenêtres glissantes (5 heures, hebdomadaire et mensuelle), à peu près équivalent à 12 $ par 5 heures, 30 $ par semaine et 60 $ par mois (le nombre réel de requêtes varie selon le modèle et l'utilisation).", "zen.api.error.rateLimitExceeded": "Limite de débit dépassée. Veuillez réessayer plus tard.", "zen.api.error.modelNotSupported": "Modèle {{model}} non pris en charge", diff --git a/packages/console/app/src/i18n/it.ts b/packages/console/app/src/i18n/it.ts index 5735df45fa..a1b6a0fd25 100644 --- a/packages/console/app/src/i18n/it.ts +++ b/packages/console/app/src/i18n/it.ts @@ -251,7 +251,7 @@ export const dict = { "go.title": "OpenCode Go | Modelli di coding a basso costo per tutti", "go.meta.description": - "Go inizia a $5 per il primo mese, poi $10/mese, con generosi limiti di richiesta di 5 ore per GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro e DeepSeek V4 Flash.", + "Go inizia a $5 per il primo mese, poi $10/mese, con generosi limiti di richiesta di 5 ore per GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro e DeepSeek V4 Flash.", "go.hero.title": "Modelli di coding a basso costo per tutti", "go.hero.body": "Go porta il coding agentico ai programmatori di tutto il mondo. Offrendo limiti generosi e un accesso affidabile ai modelli open source più capaci, in modo da poter costruire con agenti potenti senza preoccuparsi dei costi o della disponibilità.", @@ -300,7 +300,7 @@ export const dict = { "go.problem.item2": "Limiti generosi e accesso affidabile", "go.problem.item3": "Costruito per il maggior numero possibile di programmatori", "go.problem.item4": - "Include GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro e DeepSeek V4 Flash", + "Include GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro e DeepSeek V4 Flash", "go.how.title": "Come funziona Go", "go.how.body": "Go inizia a $5 per il primo mese, poi $10/mese. Puoi usarlo con OpenCode o qualsiasi agente.", "go.how.step1.title": "Crea un account", @@ -325,7 +325,7 @@ export const dict = { "go.faq.a2": "Go include i modelli elencati di seguito, con limiti generosi e accesso affidabile.", "go.faq.q3": "Go è lo stesso di Zen?", "go.faq.a3": - "No. Zen è a consumo, mentre Go inizia a $5 per il primo mese, poi $10/mese, con limiti generosi e accesso affidabile ai modelli open source GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro e DeepSeek V4 Flash.", + "No. Zen è a consumo, mentre Go inizia a $5 per il primo mese, poi $10/mese, con limiti generosi e accesso affidabile ai modelli open source GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro e DeepSeek V4 Flash.", "go.faq.q4": "Quanto costa Go?", "go.faq.a4.p1.beforePricing": "Go costa", "go.faq.a4.p1.pricingLink": "$5 il primo mese", @@ -349,7 +349,7 @@ export const dict = { "go.faq.q9": "Qual è la differenza tra i modelli gratuiti e Go?", "go.faq.a9": - "I modelli gratuiti includono Big Pickle più modelli promozionali disponibili al momento, con una quota di 200 richieste/giorno. Go include GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro e DeepSeek V4 Flash con quote di richiesta più elevate applicate su finestre mobili (5 ore, settimanale e mensile), approssimativamente equivalenti a $12 ogni 5 ore, $30 a settimana e $60 al mese (il conteggio effettivo delle richieste varia in base al modello e all'utilizzo).", + "I modelli gratuiti includono Big Pickle più modelli promozionali disponibili al momento, con una quota di 200 richieste/giorno. Go include GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro e DeepSeek V4 Flash con quote di richiesta più elevate applicate su finestre mobili (5 ore, settimanale e mensile), approssimativamente equivalenti a $12 ogni 5 ore, $30 a settimana e $60 al mese (il conteggio effettivo delle richieste varia in base al modello e all'utilizzo).", "zen.api.error.rateLimitExceeded": "Limite di richieste superato. Riprova più tardi.", "zen.api.error.modelNotSupported": "Modello {{model}} non supportato", diff --git a/packages/console/app/src/i18n/ja.ts b/packages/console/app/src/i18n/ja.ts index 29759e91cf..8a4feeb68e 100644 --- a/packages/console/app/src/i18n/ja.ts +++ b/packages/console/app/src/i18n/ja.ts @@ -250,7 +250,7 @@ export const dict = { "go.title": "OpenCode Go | すべての人のための低価格なコーディングモデル", "go.meta.description": - "Goは最初の月$5、その後$10/月で、GLM-5.1、GLM-5、Kimi K2.5、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.6 Plus、MiniMax M2.5、MiniMax M2.7、DeepSeek V4 Pro、DeepSeek V4 Flashに対して5時間のゆとりあるリクエスト上限があります。", + "Goは最初の月$5、その後$10/月で、GLM-5.1、GLM-5、Kimi K2.5、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.6 Plus、MiniMax M2.5、MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro、DeepSeek V4 Flashに対して5時間のゆとりあるリクエスト上限があります。", "go.hero.title": "すべての人のための低価格なコーディングモデル", "go.hero.body": "Goは、世界中のプログラマーにエージェント型コーディングをもたらします。最も高性能なオープンソースモデルへの十分な制限と安定したアクセスを提供し、コストや可用性を気にすることなく強力なエージェントで構築できます。", @@ -300,7 +300,7 @@ export const dict = { "go.problem.item2": "十分な制限と安定したアクセス", "go.problem.item3": "できるだけ多くのプログラマーのために構築", "go.problem.item4": - "GLM-5.1、GLM-5、Kimi K2.5、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.6 Plus、MiniMax M2.5、MiniMax M2.7、DeepSeek V4 Pro、DeepSeek V4 Flashを含む", + "GLM-5.1、GLM-5、Kimi K2.5、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.6 Plus、MiniMax M2.5、MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro、DeepSeek V4 Flashを含む", "go.how.title": "Goの仕組み", "go.how.body": "Goは最初の月$5、その後$10/月で始まります。OpenCodeまたは任意のエージェントで使えます。", "go.how.step1.title": "アカウントを作成", @@ -325,7 +325,7 @@ export const dict = { "go.faq.a2": "Go には、十分な利用上限と安定したアクセスを備えた、以下のモデルが含まれます。", "go.faq.q3": "GoはZenと同じですか?", "go.faq.a3": - "いいえ。Zenは従量課金制ですが、Goは最初の月$5、その後$10/月で始まり、GLM-5.1、GLM-5、Kimi K2.5、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.6 Plus、MiniMax M2.5、MiniMax M2.7、DeepSeek V4 Pro、DeepSeek V4 Flashのオープンソースモデルに対して、ゆとりある上限と信頼できるアクセスを提供します。", + "いいえ。Zenは従量課金制ですが、Goは最初の月$5、その後$10/月で始まり、GLM-5.1、GLM-5、Kimi K2.5、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.6 Plus、MiniMax M2.5、MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro、DeepSeek V4 Flashのオープンソースモデルに対して、ゆとりある上限と信頼できるアクセスを提供します。", "go.faq.q4": "Goの料金は?", "go.faq.a4.p1.beforePricing": "Goは", "go.faq.a4.p1.pricingLink": "最初の月$5", @@ -349,7 +349,7 @@ export const dict = { "go.faq.q9": "無料モデルとGoの違いは何ですか?", "go.faq.a9": - "無料モデルにはBig Pickleと、その時点で利用可能なプロモーションモデルが含まれ、1日200リクエストの制限があります。GoにはGLM-5.1、GLM-5、Kimi K2.5、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.6 Plus、MiniMax M2.5、MiniMax M2.7、DeepSeek V4 Pro、DeepSeek V4 Flashが含まれ、ローリングウィンドウ(5時間、週間、月間)全体でより高いリクエスト制限が適用されます。これは概算で5時間あたり$12、週間$30、月間$60相当です(実際のリクエスト数はモデルと使用状況により異なります)。", + "無料モデルにはBig Pickleと、その時点で利用可能なプロモーションモデルが含まれ、1日200リクエストの制限があります。GoにはGLM-5.1、GLM-5、Kimi K2.5、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.6 Plus、MiniMax M2.5、MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro、DeepSeek V4 Flashが含まれ、ローリングウィンドウ(5時間、週間、月間)全体でより高いリクエスト制限が適用されます。これは概算で5時間あたり$12、週間$30、月間$60相当です(実際のリクエスト数はモデルと使用状況により異なります)。", "zen.api.error.rateLimitExceeded": "レート制限を超えました。後でもう一度お試しください。", "zen.api.error.modelNotSupported": "モデル {{model}} はサポートされていません", diff --git a/packages/console/app/src/i18n/ko.ts b/packages/console/app/src/i18n/ko.ts index dfa6704b80..24ae5009ff 100644 --- a/packages/console/app/src/i18n/ko.ts +++ b/packages/console/app/src/i18n/ko.ts @@ -247,7 +247,7 @@ export const dict = { "go.title": "OpenCode Go | 모두를 위한 저비용 코딩 모델", "go.meta.description": - "Go는 첫 달 $5, 이후 $10/월로 시작하며, GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash에 대해 넉넉한 5시간 요청 한도를 제공합니다.", + "Go는 첫 달 $5, 이후 $10/월로 시작하며, GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash에 대해 넉넉한 5시간 요청 한도를 제공합니다.", "go.hero.title": "모두를 위한 저비용 코딩 모델", "go.hero.body": "Go는 전 세계 프로그래머들에게 에이전트 코딩을 제공합니다. 가장 유능한 오픈 소스 모델에 대한 넉넉한 한도와 안정적인 액세스를 제공하므로, 비용이나 가용성 걱정 없이 강력한 에이전트로 빌드할 수 있습니다.", @@ -297,7 +297,7 @@ export const dict = { "go.problem.item2": "넉넉한 한도와 안정적인 액세스", "go.problem.item3": "가능한 한 많은 프로그래머를 위해 제작됨", "go.problem.item4": - "GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash 포함", + "GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash 포함", "go.how.title": "Go 작동 방식", "go.how.body": "Go는 첫 달 $5, 이후 $10/월로 시작합니다. OpenCode 또는 어떤 에이전트와도 함께 사용할 수 있습니다.", "go.how.step1.title": "계정 생성", @@ -321,7 +321,7 @@ export const dict = { "go.faq.a2": "Go에는 넉넉한 한도와 안정적인 액세스를 제공하는 아래 모델이 포함됩니다.", "go.faq.q3": "Go는 Zen과 같은가요?", "go.faq.a3": - "아니요. Zen은 종량제인 반면, Go는 첫 달 $5, 이후 $10/월로 시작하며, GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash 오픈 소스 모델에 대한 넉넉한 한도와 안정적인 액세스를 제공합니다.", + "아니요. Zen은 종량제인 반면, Go는 첫 달 $5, 이후 $10/월로 시작하며, GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash 오픈 소스 모델에 대한 넉넉한 한도와 안정적인 액세스를 제공합니다.", "go.faq.q4": "Go 비용은 얼마인가요?", "go.faq.a4.p1.beforePricing": "Go 비용은", "go.faq.a4.p1.pricingLink": "첫 달 $5", @@ -344,7 +344,7 @@ export const dict = { "go.faq.q9": "무료 모델과 Go의 차이점은 무엇인가요?", "go.faq.a9": - "무료 모델에는 Big Pickle과 당시 사용 가능한 프로모션 모델이 포함되며, 하루 200회 요청 할당량이 적용됩니다. Go는 GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash를 포함하며, 롤링 윈도우(5시간, 주간, 월간)에 걸쳐 더 높은 요청 할당량을 적용합니다. 이는 대략 5시간당 $12, 주당 $30, 월 $60에 해당합니다(실제 요청 수는 모델 및 사용량에 따라 다름).", + "무료 모델에는 Big Pickle과 당시 사용 가능한 프로모션 모델이 포함되며, 하루 200회 요청 할당량이 적용됩니다. Go는 GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash를 포함하며, 롤링 윈도우(5시간, 주간, 월간)에 걸쳐 더 높은 요청 할당량을 적용합니다. 이는 대략 5시간당 $12, 주당 $30, 월 $60에 해당합니다(실제 요청 수는 모델 및 사용량에 따라 다름).", "zen.api.error.rateLimitExceeded": "속도 제한을 초과했습니다. 나중에 다시 시도해 주세요.", "zen.api.error.modelNotSupported": "{{model}} 모델은 지원되지 않습니다", diff --git a/packages/console/app/src/i18n/no.ts b/packages/console/app/src/i18n/no.ts index 1418a42c83..70c6e866bc 100644 --- a/packages/console/app/src/i18n/no.ts +++ b/packages/console/app/src/i18n/no.ts @@ -251,7 +251,7 @@ export const dict = { "go.title": "OpenCode Go | Rimelige kodemodeller for alle", "go.meta.description": - "Go starter på $5 for den første måneden, deretter $10/måned, med sjenerøse 5-timers forespørselsgrenser for GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro og DeepSeek V4 Flash.", + "Go starter på $5 for den første måneden, deretter $10/måned, med sjenerøse 5-timers forespørselsgrenser for GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro og DeepSeek V4 Flash.", "go.hero.title": "Rimelige kodemodeller for alle", "go.hero.body": "Go bringer agent-koding til programmerere over hele verden. Med rause grenser og pålitelig tilgang til de mest kapable åpen kildekode-modellene, kan du bygge med kraftige agenter uten å bekymre deg for kostnader eller tilgjengelighet.", @@ -300,7 +300,7 @@ export const dict = { "go.problem.item2": "Rause grenser og pålitelig tilgang", "go.problem.item3": "Bygget for så mange programmerere som mulig", "go.problem.item4": - "Inkluderer GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro og DeepSeek V4 Flash", + "Inkluderer GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro og DeepSeek V4 Flash", "go.how.title": "Hvordan Go fungerer", "go.how.body": "Go starter på $5 for den første måneden, deretter $10/måned. Du kan bruke det med OpenCode eller hvilken som helst agent.", @@ -326,7 +326,7 @@ export const dict = { "go.faq.a2": "Go inkluderer modellene nedenfor, med høye grenser og pålitelig tilgang.", "go.faq.q3": "Er Go det samme som Zen?", "go.faq.a3": - "Nei. Zen er betaling etter bruk, mens Go starter på $5 for den første måneden, deretter $10/måned, med sjenerøse grenser og pålitelig tilgang til åpen kildekode-modellene GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro og DeepSeek V4 Flash.", + "Nei. Zen er betaling etter bruk, mens Go starter på $5 for den første måneden, deretter $10/måned, med sjenerøse grenser og pålitelig tilgang til åpen kildekode-modellene GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro og DeepSeek V4 Flash.", "go.faq.q4": "Hva koster Go?", "go.faq.a4.p1.beforePricing": "Go koster", "go.faq.a4.p1.pricingLink": "$5 første måned", @@ -350,7 +350,7 @@ export const dict = { "go.faq.q9": "Hva er forskjellen mellom gratis modeller og Go?", "go.faq.a9": - "Gratis modeller inkluderer Big Pickle pluss kampanjemodeller tilgjengelig på det tidspunktet, med en kvote på 200 forespørsler/dag. Go inkluderer GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro og DeepSeek V4 Flash med høyere kvoter håndhevet over rullerende vinduer (5 timer, ukentlig og månedlig), omtrent tilsvarende $12 per 5 timer, $30 per uke og $60 per måned (faktiske forespørselsantall varierer etter modell og bruk).", + "Gratis modeller inkluderer Big Pickle pluss kampanjemodeller tilgjengelig på det tidspunktet, med en kvote på 200 forespørsler/dag. Go inkluderer GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro og DeepSeek V4 Flash med høyere kvoter håndhevet over rullerende vinduer (5 timer, ukentlig og månedlig), omtrent tilsvarende $12 per 5 timer, $30 per uke og $60 per måned (faktiske forespørselsantall varierer etter modell og bruk).", "zen.api.error.rateLimitExceeded": "Rate limit overskredet. Vennligst prøv igjen senere.", "zen.api.error.modelNotSupported": "Modell {{model}} støttes ikke", diff --git a/packages/console/app/src/i18n/pl.ts b/packages/console/app/src/i18n/pl.ts index d931106917..d337d0fb71 100644 --- a/packages/console/app/src/i18n/pl.ts +++ b/packages/console/app/src/i18n/pl.ts @@ -252,7 +252,7 @@ export const dict = { "go.title": "OpenCode Go | Niskokosztowe modele do kodowania dla każdego", "go.meta.description": - "Go zaczyna się od $5 za pierwszy miesiąc, potem $10/miesiąc, z hojnymi 5-godzinnymi limitami zapytań dla GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro i DeepSeek V4 Flash.", + "Go zaczyna się od $5 za pierwszy miesiąc, potem $10/miesiąc, z hojnymi 5-godzinnymi limitami zapytań dla GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro i DeepSeek V4 Flash.", "go.hero.title": "Niskokosztowe modele do kodowania dla każdego", "go.hero.body": "Go udostępnia programowanie z agentami programistom na całym świecie. Oferuje hojne limity i niezawodny dostęp do najzdolniejszych modeli open source, dzięki czemu możesz budować za pomocą potężnych agentów, nie martwiąc się o koszty czy dostępność.", @@ -301,7 +301,7 @@ export const dict = { "go.problem.item2": "Hojne limity i niezawodny dostęp", "go.problem.item3": "Stworzony dla jak największej liczby programistów", "go.problem.item4": - "Zawiera GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro i DeepSeek V4 Flash", + "Zawiera GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro i DeepSeek V4 Flash", "go.how.title": "Jak działa Go", "go.how.body": "Go zaczyna się od $5 za pierwszy miesiąc, potem $10/miesiąc. Możesz go używać z OpenCode lub dowolnym agentem.", @@ -327,7 +327,7 @@ export const dict = { "go.faq.a2": "Go obejmuje poniższe modele z wysokimi limitami i niezawodnym dostępem.", "go.faq.q3": "Czy Go to to samo co Zen?", "go.faq.a3": - "Nie. Zen to model płatności za użycie, podczas gdy Go zaczyna się od $5 za pierwszy miesiąc, potem $10/miesiąc, z hojnymi limitami i niezawodnym dostępem do modeli open source GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro i DeepSeek V4 Flash.", + "Nie. Zen to model płatności za użycie, podczas gdy Go zaczyna się od $5 za pierwszy miesiąc, potem $10/miesiąc, z hojnymi limitami i niezawodnym dostępem do modeli open source GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro i DeepSeek V4 Flash.", "go.faq.q4": "Ile kosztuje Go?", "go.faq.a4.p1.beforePricing": "Go kosztuje", "go.faq.a4.p1.pricingLink": "$5 za pierwszy miesiąc", @@ -351,7 +351,7 @@ export const dict = { "go.faq.q9": "Jaka jest różnica między darmowymi modelami a Go?", "go.faq.a9": - "Darmowe modele obejmują Big Pickle oraz modele promocyjne dostępne w danym momencie, z limitem 200 zapytań/dzień. Go zawiera GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro i DeepSeek V4 Flash z wyższymi limitami zapytań egzekwowanymi w oknach kroczących (5-godzinnych, tygodniowych i miesięcznych), w przybliżeniu równoważnymi $12 na 5 godzin, $30 tygodniowo i $60 miesięcznie (rzeczywista liczba zapytań zależy od modelu i użycia).", + "Darmowe modele obejmują Big Pickle oraz modele promocyjne dostępne w danym momencie, z limitem 200 zapytań/dzień. Go zawiera GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro i DeepSeek V4 Flash z wyższymi limitami zapytań egzekwowanymi w oknach kroczących (5-godzinnych, tygodniowych i miesięcznych), w przybliżeniu równoważnymi $12 na 5 godzin, $30 tygodniowo i $60 miesięcznie (rzeczywista liczba zapytań zależy od modelu i użycia).", "zen.api.error.rateLimitExceeded": "Przekroczono limit zapytań. Spróbuj ponownie później.", "zen.api.error.modelNotSupported": "Model {{model}} nie jest obsługiwany", diff --git a/packages/console/app/src/i18n/ru.ts b/packages/console/app/src/i18n/ru.ts index 1f0ce4f3a0..eeb9d618d3 100644 --- a/packages/console/app/src/i18n/ru.ts +++ b/packages/console/app/src/i18n/ru.ts @@ -255,7 +255,7 @@ export const dict = { "go.title": "OpenCode Go | Недорогие модели для кодинга для всех", "go.meta.description": - "Go начинается с $5 за первый месяц, затем $10/месяц, с щедрыми лимитами запросов за 5 часов для GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro и DeepSeek V4 Flash.", + "Go начинается с $5 за первый месяц, затем $10/месяц, с щедрыми лимитами запросов за 5 часов для GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro и DeepSeek V4 Flash.", "go.hero.title": "Недорогие модели для кодинга для всех", "go.hero.body": "Go открывает доступ к агентам-программистам разработчикам по всему миру. Предлагая щедрые лимиты и надежный доступ к наиболее способным моделям с открытым исходным кодом, вы можете создавать проекты с мощными агентами, не беспокоясь о затратах или доступности.", @@ -305,7 +305,7 @@ export const dict = { "go.problem.item2": "Щедрые лимиты и надежный доступ", "go.problem.item3": "Создан для максимального числа программистов", "go.problem.item4": - "Включает GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro и DeepSeek V4 Flash", + "Включает GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro и DeepSeek V4 Flash", "go.how.title": "Как работает Go", "go.how.body": "Go начинается с $5 за первый месяц, затем $10/месяц. Вы можете использовать его с OpenCode или любым агентом.", @@ -331,7 +331,7 @@ export const dict = { "go.faq.a2": "Go включает перечисленные ниже модели с щедрыми лимитами и надежным доступом.", "go.faq.q3": "Go — это то же самое, что и Zen?", "go.faq.a3": - "Нет. Zen - это оплата по мере использования, в то время как Go начинается с $5 за первый месяц, затем $10/месяц, с щедрыми лимитами и надежным доступом к моделям с открытым исходным кодом GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro и DeepSeek V4 Flash.", + "Нет. Zen - это оплата по мере использования, в то время как Go начинается с $5 за первый месяц, затем $10/месяц, с щедрыми лимитами и надежным доступом к моделям с открытым исходным кодом GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro и DeepSeek V4 Flash.", "go.faq.q4": "Сколько стоит Go?", "go.faq.a4.p1.beforePricing": "Go стоит", "go.faq.a4.p1.pricingLink": "$5 за первый месяц", @@ -355,7 +355,7 @@ export const dict = { "go.faq.q9": "В чем разница между бесплатными моделями и Go?", "go.faq.a9": - "Бесплатные модели включают Big Pickle плюс промо-модели, доступные на данный момент, с квотой 200 запросов/день. Go включает GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro и DeepSeek V4 Flash с более высокими квотами запросов, применяемыми в скользящих окнах (5 часов, неделя и месяц), что примерно эквивалентно $12 за 5 часов, $30 в неделю и $60 в месяц (фактическое количество запросов зависит от модели и использования).", + "Бесплатные модели включают Big Pickle плюс промо-модели, доступные на данный момент, с квотой 200 запросов/день. Go включает GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro и DeepSeek V4 Flash с более высокими квотами запросов, применяемыми в скользящих окнах (5 часов, неделя и месяц), что примерно эквивалентно $12 за 5 часов, $30 в неделю и $60 в месяц (фактическое количество запросов зависит от модели и использования).", "zen.api.error.rateLimitExceeded": "Превышен лимит запросов. Пожалуйста, попробуйте позже.", "zen.api.error.modelNotSupported": "Модель {{model}} не поддерживается", diff --git a/packages/console/app/src/i18n/th.ts b/packages/console/app/src/i18n/th.ts index 724bb5c3da..a842424829 100644 --- a/packages/console/app/src/i18n/th.ts +++ b/packages/console/app/src/i18n/th.ts @@ -250,7 +250,7 @@ export const dict = { "go.title": "OpenCode Go | โมเดลเขียนโค้ดราคาประหยัดสำหรับทุกคน", "go.meta.description": - "Go เริ่มต้นที่ $5 สำหรับเดือนแรก จากนั้น $10/เดือน พร้อมขีดจำกัดคำขอ 5 ชั่วโมงที่เอื้อเฟื้อสำหรับ GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro และ DeepSeek V4 Flash", + "Go เริ่มต้นที่ $5 สำหรับเดือนแรก จากนั้น $10/เดือน พร้อมขีดจำกัดคำขอ 5 ชั่วโมงที่เอื้อเฟื้อสำหรับ GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro และ DeepSeek V4 Flash", "go.hero.title": "โมเดลเขียนโค้ดราคาประหยัดสำหรับทุกคน", "go.hero.body": "Go นำการเขียนโค้ดแบบเอเจนต์มาสู่นักเขียนโปรแกรมทั่วโลก เสนอขีดจำกัดที่กว้างขวางและการเข้าถึงโมเดลโอเพนซอร์สที่มีความสามารถสูงสุดได้อย่างน่าเชื่อถือ เพื่อให้คุณสามารถสร้างสรรค์ด้วยเอเจนต์ที่ทรงพลังโดยไม่ต้องกังวลเรื่องค่าใช้จ่ายหรือความพร้อมใช้งาน", @@ -298,7 +298,7 @@ export const dict = { "go.problem.item2": "ขีดจำกัดที่กว้างขวางและการเข้าถึงที่เชื่อถือได้", "go.problem.item3": "สร้างขึ้นเพื่อโปรแกรมเมอร์จำนวนมากที่สุดเท่าที่จะเป็นไปได้", "go.problem.item4": - "รวมถึง GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro และ DeepSeek V4 Flash", + "รวมถึง GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro และ DeepSeek V4 Flash", "go.how.title": "Go ทำงานอย่างไร", "go.how.body": "Go เริ่มต้นที่ $5 สำหรับเดือนแรก จากนั้น $10/เดือน คุณสามารถใช้กับ OpenCode หรือเอเจนต์ใดก็ได้", "go.how.step1.title": "สร้างบัญชี", @@ -323,7 +323,7 @@ export const dict = { "go.faq.a2": "Go รวมโมเดลด้านล่างนี้ พร้อมขีดจำกัดที่มากและการเข้าถึงที่เชื่อถือได้", "go.faq.q3": "Go เหมือนกับ Zen หรือไม่?", "go.faq.a3": - "ไม่ Zen เป็นแบบจ่ายตามการใช้งาน ในขณะที่ Go เริ่มต้นที่ $5 สำหรับเดือนแรก จากนั้น $10/เดือน พร้อมขีดจำกัดที่เอื้อเฟื้อและการเข้าถึงโมเดลโอเพนซอร์ส GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro และ DeepSeek V4 Flash อย่างเชื่อถือได้", + "ไม่ Zen เป็นแบบจ่ายตามการใช้งาน ในขณะที่ Go เริ่มต้นที่ $5 สำหรับเดือนแรก จากนั้น $10/เดือน พร้อมขีดจำกัดที่เอื้อเฟื้อและการเข้าถึงโมเดลโอเพนซอร์ส GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro และ DeepSeek V4 Flash อย่างเชื่อถือได้", "go.faq.q4": "Go ราคาเท่าไหร่?", "go.faq.a4.p1.beforePricing": "Go ราคา", "go.faq.a4.p1.pricingLink": "$5 เดือนแรก", @@ -346,7 +346,7 @@ export const dict = { "go.faq.q9": "ความแตกต่างระหว่างโมเดลฟรีและ Go คืออะไร?", "go.faq.a9": - "โมเดลฟรีรวมถึง Big Pickle บวกกับโมเดลโปรโมชั่นที่มีให้ในขณะนั้น ด้วยโควต้า 200 คำขอ/วัน Go รวมถึง GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro และ DeepSeek V4 Flash ที่มีโควต้าคำขอสูงกว่า ซึ่งบังคับใช้ผ่านช่วงเวลาหมุนเวียน (5 ชั่วโมง, รายสัปดาห์ และรายเดือน) เทียบเท่าประมาณ $12 ต่อ 5 ชั่วโมง, $30 ต่อสัปดาห์ และ $60 ต่อเดือน (จำนวนคำขอจริงจะแตกต่างกันไปตามโมเดลและการใช้งาน)", + "โมเดลฟรีรวมถึง Big Pickle บวกกับโมเดลโปรโมชั่นที่มีให้ในขณะนั้น ด้วยโควต้า 200 คำขอ/วัน Go รวมถึง GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro และ DeepSeek V4 Flash ที่มีโควต้าคำขอสูงกว่า ซึ่งบังคับใช้ผ่านช่วงเวลาหมุนเวียน (5 ชั่วโมง, รายสัปดาห์ และรายเดือน) เทียบเท่าประมาณ $12 ต่อ 5 ชั่วโมง, $30 ต่อสัปดาห์ และ $60 ต่อเดือน (จำนวนคำขอจริงจะแตกต่างกันไปตามโมเดลและการใช้งาน)", "zen.api.error.rateLimitExceeded": "เกินขีดจำกัดอัตราการใช้งาน กรุณาลองใหม่ในภายหลัง", "zen.api.error.modelNotSupported": "ไม่รองรับโมเดล {{model}}", diff --git a/packages/console/app/src/i18n/tr.ts b/packages/console/app/src/i18n/tr.ts index 0805eb7e9b..b3f3de05bf 100644 --- a/packages/console/app/src/i18n/tr.ts +++ b/packages/console/app/src/i18n/tr.ts @@ -253,7 +253,7 @@ export const dict = { "go.title": "OpenCode Go | Herkes için düşük maliyetli kodlama modelleri", "go.meta.description": - "Go ilk ay $5, sonrasında ayda 10$ fiyatıyla başlar; GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro ve DeepSeek V4 Flash için cömert 5 saatlik istek limitleri sunar.", + "Go ilk ay $5, sonrasında ayda 10$ fiyatıyla başlar; GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro ve DeepSeek V4 Flash için cömert 5 saatlik istek limitleri sunar.", "go.hero.title": "Herkes için düşük maliyetli kodlama modelleri", "go.hero.body": "Go, dünya çapındaki programcılara ajan tabanlı kodlama getiriyor. En yetenekli açık kaynaklı modellere cömert limitler ve güvenilir erişim sunarak, maliyet veya erişilebilirlik konusunda endişelenmeden güçlü ajanlarla geliştirme yapmanızı sağlar.", @@ -303,7 +303,7 @@ export const dict = { "go.problem.item2": "Cömert limitler ve güvenilir erişim", "go.problem.item3": "Mümkün olduğunca çok programcı için geliştirildi", "go.problem.item4": - "GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro ve DeepSeek V4 Flash içerir", + "GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro ve DeepSeek V4 Flash içerir", "go.how.title": "Go nasıl çalışır?", "go.how.body": "Go ilk ay $5, sonrasında ayda 10$ fiyatıyla başlar. OpenCode veya herhangi bir ajanla kullanabilirsiniz.", @@ -329,7 +329,7 @@ export const dict = { "go.faq.a2": "Go, aşağıda listelenen modelleri cömert limitler ve güvenilir erişimle sunar.", "go.faq.q3": "Go, Zen ile aynı mı?", "go.faq.a3": - "Hayır. Zen kullandıkça öde modelidir, Go ise ilk ay $5, sonrasında ayda 10$ fiyatıyla başlar; GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro ve DeepSeek V4 Flash açık kaynak modellerine cömert limitler ve güvenilir erişim sunar.", + "Hayır. Zen kullandıkça öde modelidir, Go ise ilk ay $5, sonrasında ayda 10$ fiyatıyla başlar; GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro ve DeepSeek V4 Flash açık kaynak modellerine cömert limitler ve güvenilir erişim sunar.", "go.faq.q4": "Go ne kadar?", "go.faq.a4.p1.beforePricing": "Go'nun maliyeti", "go.faq.a4.p1.pricingLink": "İlk ay $5", @@ -353,7 +353,7 @@ export const dict = { "go.faq.q9": "Ücretsiz modeller ve Go arasındaki fark nedir?", "go.faq.a9": - "Ücretsiz modeller, günlük 200 istek kotası ile Big Pickle ve o sırada mevcut olan promosyonel modelleri içerir. Go ise GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro ve DeepSeek V4 Flash modellerini; yuvarlanan pencereler (5 saatlik, haftalık ve aylık) üzerinden uygulanan daha yüksek istek kotalarıyla içerir. Bu kotalar kabaca her 5 saatte 12$, haftada 30$ ve ayda 60$ değerine eşdeğerdir (gerçek istek sayıları modele ve kullanıma göre değişir).", + "Ücretsiz modeller, günlük 200 istek kotası ile Big Pickle ve o sırada mevcut olan promosyonel modelleri içerir. Go ise GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro ve DeepSeek V4 Flash modellerini; yuvarlanan pencereler (5 saatlik, haftalık ve aylık) üzerinden uygulanan daha yüksek istek kotalarıyla içerir. Bu kotalar kabaca her 5 saatte 12$, haftada 30$ ve ayda 60$ değerine eşdeğerdir (gerçek istek sayıları modele ve kullanıma göre değişir).", "zen.api.error.rateLimitExceeded": "İstek limiti aşıldı. Lütfen daha sonra tekrar deneyin.", "zen.api.error.modelNotSupported": "{{model}} modeli desteklenmiyor", diff --git a/packages/console/app/src/i18n/uk.ts b/packages/console/app/src/i18n/uk.ts index b82c367ddb..fc77960cd2 100644 --- a/packages/console/app/src/i18n/uk.ts +++ b/packages/console/app/src/i18n/uk.ts @@ -252,7 +252,7 @@ export const dict = { "go.title": "OpenCode Go | Недорогі моделі кодування для всіх", "go.meta.description": - "Go починається від $5 за перший місяць, потім $10/місяць, з generous 5-годинними лімітами запитів для GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro та DeepSeek V4 Flash.", + "Go починається від $5 за перший місяць, потім $10/місяць, з generous 5-годинними лімітами запитів для GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro та DeepSeek V4 Flash.", "go.hero.title": "Недорогі моделі кодування для всіх", "go.hero.body": "Go надає агентне програмування програмістам у всьому світі, пропонуючи щедрі ліміти та надійний доступ до найкращих моделей з відкритим кодом.", @@ -301,7 +301,7 @@ export const dict = { "go.problem.item2": "Щедрі ліміти та надійний доступ", "go.problem.item3": "Створено для якомога більшої кількості програмістів", "go.problem.item4": - "Включає GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro та DeepSeek V4 Flash", + "Включає GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro та DeepSeek V4 Flash", "go.how.title": "Як працює Go", "go.how.body": "Go починається від $5 за перший місяць, потім $10/місяць. Використовуйте з OpenCode або будь-яким агентом.", @@ -327,7 +327,7 @@ export const dict = { "go.faq.a2": "Go включає моделі, перелічені нижче, із щедрими лімітами та надійним доступом.", "go.faq.q3": "Чи Go те саме, що Zen?", "go.faq.a3": - "Ні. Zen — це плата за використання, тоді як Go починається від $5 за перший місяць, потім $10/місяць, із щедрими лімітами та надійним доступом до моделей з відкритим кодом GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro та DeepSeek V4 Flash.", + "Ні. Zen — це плата за використання, тоді як Go починається від $5 за перший місяць, потім $10/місяць, із щедрими лімітами та надійним доступом до моделей з відкритим кодом GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro та DeepSeek V4 Flash.", "go.faq.q4": "Скільки коштує Go?", "go.faq.a4.p1.beforePricing": "Go коштує", "go.faq.a4.p1.pricingLink": "$5 за перший місяць", @@ -350,7 +350,7 @@ export const dict = { "go.faq.q9": "Яка різниця між безкоштовними моделями та Go?", "go.faq.a9": - "Безкоштовні моделі включають Big Pickle та акційні моделі з лімітом 200 запитів/день. Go включає GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro та DeepSeek V4 Flash із вищими лімітами.", + "Безкоштовні моделі включають Big Pickle та акційні моделі з лімітом 200 запитів/день. Go включає GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro та DeepSeek V4 Flash із вищими лімітами.", "zen.api.error.rateLimitExceeded": "Перевищено ліміт запитів. Спробуйте пізніше.", "zen.api.error.modelNotSupported": "Модель {{model}} не підтримується", diff --git a/packages/console/app/src/i18n/zh.ts b/packages/console/app/src/i18n/zh.ts index 9fbd0b5b1e..3d527d15be 100644 --- a/packages/console/app/src/i18n/zh.ts +++ b/packages/console/app/src/i18n/zh.ts @@ -241,7 +241,7 @@ export const dict = { "go.title": "OpenCode Go | 人人可用的低成本编程模型", "go.meta.description": - "Go 首月 $5,之后 $10/月,提供对 GLM-5.1、GLM-5、Kimi K2.5、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.6 Plus、MiniMax M2.5、MiniMax M2.7、DeepSeek V4 Pro 和 DeepSeek V4 Flash 的 5 小时充裕请求额度。", + "Go 首月 $5,之后 $10/月,提供对 GLM-5.1、GLM-5、Kimi K2.5、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.6 Plus、MiniMax M2.5、MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro 和 DeepSeek V4 Flash 的 5 小时充裕请求额度。", "go.hero.title": "人人可用的低成本编程模型", "go.hero.body": "Go 将代理编程带给全世界的程序员。提供充裕的限额和对最强大的开源模型的可靠访问,让您可以利用强大的代理进行构建,而无需担心成本或可用性。", @@ -289,7 +289,7 @@ export const dict = { "go.problem.item2": "充裕的限额和可靠的访问", "go.problem.item3": "为尽可能多的程序员打造", "go.problem.item4": - "包含 GLM-5.1, GLM-5, Kimi K2.5、Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5、MiniMax M2.7、DeepSeek V4 Pro 和 DeepSeek V4 Flash", + "包含 GLM-5.1, GLM-5, Kimi K2.5、Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5、MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro 和 DeepSeek V4 Flash", "go.how.title": "Go 如何工作", "go.how.body": "Go 起价为首月 $5,之后 $10/月。您可以将其与 OpenCode 或任何代理搭配使用。", "go.how.step1.title": "创建账户", @@ -311,7 +311,7 @@ export const dict = { "go.faq.a2": "Go 包含下方列出的模型,提供充足的限额和可靠的访问。", "go.faq.q3": "Go 和 Zen 一样吗?", "go.faq.a3": - "不。Zen 是按量付费,而 Go 首月 $5,之后 $10/月,提供充裕的额度,并可可靠地访问 GLM-5.1、GLM-5、Kimi K2.5、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.6 Plus、MiniMax M2.5、MiniMax M2.7、DeepSeek V4 Pro 和 DeepSeek V4 Flash 等开源模型。", + "不。Zen 是按量付费,而 Go 首月 $5,之后 $10/月,提供充裕的额度,并可可靠地访问 GLM-5.1、GLM-5、Kimi K2.5、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.6 Plus、MiniMax M2.5、MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro 和 DeepSeek V4 Flash 等开源模型。", "go.faq.q4": "Go 多少钱?", "go.faq.a4.p1.beforePricing": "Go 费用为", "go.faq.a4.p1.pricingLink": "首月 $5", @@ -333,7 +333,7 @@ export const dict = { "go.faq.q9": "免费模型和 Go 之间的区别是什么?", "go.faq.a9": - "免费模型包含 Big Pickle 加上当时可用的促销模型,每天有 200 次请求的配额。Go 包含 GLM-5.1, GLM-5, Kimi K2.5、Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5、MiniMax M2.7、DeepSeek V4 Pro 和 DeepSeek V4 Flash,并在滚动窗口(5 小时、每周和每月)内执行更高的请求配额,大致相当于每 5 小时 $12、每周 $30 和每月 $60(实际请求计数因模型和使用情况而异)。", + "免费模型包含 Big Pickle 加上当时可用的促销模型,每天有 200 次请求的配额。Go 包含 GLM-5.1, GLM-5, Kimi K2.5、Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.6 Plus, MiniMax M2.5、MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro 和 DeepSeek V4 Flash,并在滚动窗口(5 小时、每周和每月)内执行更高的请求配额,大致相当于每 5 小时 $12、每周 $30 和每月 $60(实际请求计数因模型和使用情况而异)。", "zen.api.error.rateLimitExceeded": "超出速率限制。请稍后重试。", "zen.api.error.modelNotSupported": "不支持模型 {{model}}", diff --git a/packages/console/app/src/i18n/zht.ts b/packages/console/app/src/i18n/zht.ts index 4a782621bd..3b2f58b86a 100644 --- a/packages/console/app/src/i18n/zht.ts +++ b/packages/console/app/src/i18n/zht.ts @@ -241,7 +241,7 @@ export const dict = { "go.title": "OpenCode Go | 低成本全民編碼模型", "go.meta.description": - "Go 首月 $5,之後 $10/月,提供對 GLM-5.1、GLM-5、Kimi K2.5、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.6 Plus、MiniMax M2.5、MiniMax M2.7、DeepSeek V4 Pro 和 DeepSeek V4 Flash 的 5 小時充裕請求額度。", + "Go 首月 $5,之後 $10/月,提供對 GLM-5.1、GLM-5、Kimi K2.5、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.6 Plus、MiniMax M2.5、MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro 和 DeepSeek V4 Flash 的 5 小時充裕請求額度。", "go.hero.title": "低成本全民編碼模型", "go.hero.body": "Go 將代理編碼帶給全世界的程式設計師。提供寬裕的限額以及對最強大開源模型的穩定存取,讓你可以使用強大的代理進行構建,而無需擔心成本或可用性。", @@ -289,7 +289,7 @@ export const dict = { "go.problem.item2": "寬裕的限額與穩定存取", "go.problem.item3": "專為盡可能多的程式設計師打造", "go.problem.item4": - "包含 GLM-5.1、GLM-5、Kimi K2.5、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.6 Plus、MiniMax M2.5、MiniMax M2.7、DeepSeek V4 Pro 與 DeepSeek V4 Flash", + "包含 GLM-5.1、GLM-5、Kimi K2.5、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.6 Plus、MiniMax M2.5、MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro 與 DeepSeek V4 Flash", "go.how.title": "Go 如何運作", "go.how.body": "Go 起價為首月 $5,之後 $10/月。您可以將其與 OpenCode 或任何代理搭配使用。", "go.how.step1.title": "建立帳號", @@ -311,7 +311,7 @@ export const dict = { "go.faq.a2": "Go 包含下方列出的模型,提供充足的額度與穩定的存取。", "go.faq.q3": "Go 與 Zen 一樣嗎?", "go.faq.a3": - "不。Zen 是按量付費,而 Go 首月 $5,之後 $10/月,提供充裕的額度,並可可靠地存取 GLM-5.1、GLM-5、Kimi K2.5、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.6 Plus、MiniMax M2.5、MiniMax M2.7、DeepSeek V4 Pro 和 DeepSeek V4 Flash 等開源模型。", + "不。Zen 是按量付費,而 Go 首月 $5,之後 $10/月,提供充裕的額度,並可可靠地存取 GLM-5.1、GLM-5、Kimi K2.5、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.6 Plus、MiniMax M2.5、MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro 和 DeepSeek V4 Flash 等開源模型。", "go.faq.q4": "Go 費用是多少?", "go.faq.a4.p1.beforePricing": "Go 費用為", "go.faq.a4.p1.pricingLink": "首月 $5", @@ -333,7 +333,7 @@ export const dict = { "go.faq.q9": "免費模型與 Go 有什麼區別?", "go.faq.a9": - "免費模型包括 Big Pickle 以及當時可用的促銷模型,配額為 200 次請求/天。Go 包括 GLM-5.1、GLM-5、Kimi K2.5、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.6 Plus、MiniMax M2.5、MiniMax M2.7、DeepSeek V4 Pro 與 DeepSeek V4 Flash,並在滾動視窗(5 小時、每週和每月)內執行更高的請求配額,大約相當於每 5 小時 $12、每週 $30 和每月 $60(實際請求數因模型和使用情況而異)。", + "免費模型包括 Big Pickle 以及當時可用的促銷模型,配額為 200 次請求/天。Go 包括 GLM-5.1、GLM-5、Kimi K2.5、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.6 Plus、MiniMax M2.5、MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro 與 DeepSeek V4 Flash,並在滾動視窗(5 小時、每週和每月)內執行更高的請求配額,大約相當於每 5 小時 $12、每週 $30 和每月 $60(實際請求數因模型和使用情況而異)。", "zen.api.error.rateLimitExceeded": "超出頻率限制。請稍後再試。", "zen.api.error.modelNotSupported": "不支援模型 {{model}}", diff --git a/packages/console/app/src/routes/go/index.tsx b/packages/console/app/src/routes/go/index.tsx index bccdc9e97a..1a9a9d43bc 100644 --- a/packages/console/app/src/routes/go/index.tsx +++ b/packages/console/app/src/routes/go/index.tsx @@ -31,6 +31,7 @@ const models = [ { name: "MiMo-V2.5", provider: "Xiaomi MiMo" }, { name: "Qwen3.7 Max", provider: "Alibaba Cloud Model Studio" }, { name: "Qwen3.6 Plus", provider: "Alibaba Cloud Model Studio" }, + { name: "MiniMax M3", provider: "MiniMax" }, { name: "MiniMax M2.7", provider: "MiniMax" }, { name: "MiniMax M2.5", provider: "MiniMax" }, { name: "DeepSeek V4 Pro", provider: "DeepSeek" }, @@ -61,12 +62,13 @@ function LimitsGraph(props: { href: string }) { const free = 200 const graph = [ { id: "glm-5.1", name: "GLM-5.1", req: 880, d: "100ms" }, - { id: "qwen3.7-max", name: "Qwen3.7 Max", req: 950, d: "280ms" }, + { id: "qwen3.7-max", name: "Qwen3.7 Max", req: 950, d: "110ms" }, { id: "kimi-k2.6", name: "Kimi K2.6", req: 1150, d: "150ms" }, - { id: "mimo-v2.5-pro", name: "MiMo-V2.5-Pro", req: 3250, d: "150ms" }, - { id: "qwen3.6-plus", name: "Qwen3.6 Plus", req: 3300, d: "280ms" }, - { id: "minimax-m2.7", name: "MiniMax M2.7", req: 3400, d: "300ms" }, - { id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", req: 3450, d: "200ms" }, + { id: "minimax-m3", name: "MiniMax M3", req: 1400, d: "200ms" }, + { id: "mimo-v2.5-pro", name: "MiMo-V2.5-Pro", req: 3250, d: "210ms" }, + { id: "qwen3.6-plus", name: "Qwen3.6 Plus", req: 3300, d: "220ms" }, + { id: "minimax-m2.7", name: "MiniMax M2.7", req: 3400, d: "230ms" }, + { id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", req: 3450, d: "240ms" }, { id: "mimo-v2.5", name: "MiMo-V2.5", req: 30100, d: "340ms" }, { id: "deepseek-v4-flash", name: "DeepSeek V4 Flash", req: 31650, d: "340ms" }, ] diff --git a/packages/console/app/src/routes/workspace/[id]/go/lite-section.tsx b/packages/console/app/src/routes/workspace/[id]/go/lite-section.tsx index da7bdc2208..e7e44c6c5c 100644 --- a/packages/console/app/src/routes/workspace/[id]/go/lite-section.tsx +++ b/packages/console/app/src/routes/workspace/[id]/go/lite-section.tsx @@ -265,6 +265,7 @@ export function LiteSection(props: { lite: LiteSubscription | undefined }) {
  • MiMo-V2.5
  • MiniMax M2.5
  • MiniMax M2.7
  • +
  • MiniMax M3
  • Qwen3.6 Plus
  • Qwen3.7 Max
  • DeepSeek V4 Pro
  • diff --git a/packages/effect-sqlite-node/sst-env.d.ts b/packages/effect-sqlite-node/sst-env.d.ts new file mode 100644 index 0000000000..64441936d7 --- /dev/null +++ b/packages/effect-sqlite-node/sst-env.d.ts @@ -0,0 +1,10 @@ +/* This file is auto-generated by SST. Do not edit. */ +/* tslint:disable */ +/* eslint-disable */ +/* deno-fmt-ignore-file */ +/* biome-ignore-all lint: auto-generated */ + +/// + +import "sst" +export {} \ No newline at end of file diff --git a/packages/web/src/content/docs/ar/go.mdx b/packages/web/src/content/docs/ar/go.mdx index faa354ba23..61320735b0 100644 --- a/packages/web/src/content/docs/ar/go.mdx +++ b/packages/web/src/content/docs/ar/go.mdx @@ -57,6 +57,7 @@ OpenCode Go هو اشتراك منخفض التكلفة — **$5 للشهر ال - **MiMo-V2.5-Pro** - **MiniMax M2.5** - **MiniMax M2.7** +- **MiniMax M3** - **Qwen3.6 Plus** - **Qwen3.7 Max** - **DeepSeek V4 Pro** @@ -86,6 +87,7 @@ OpenCode Go هو اشتراك منخفض التكلفة — **$5 للشهر ال | Kimi K2.6 | 1,150 | 2,880 | 5,750 | | MiMo-V2.5 | 30,100 | 75,200 | 150,400 | | MiMo-V2.5-Pro | 3,250 | 8,150 | 16,300 | +| MiniMax M3 | 1,400 | 3,500 | 7,000 | | MiniMax M2.7 | 3,400 | 8,500 | 17,000 | | MiniMax M2.5 | 6,300 | 15,900 | 31,800 | | Qwen3.7 Max | 950 | 2,390 | 4,770 | @@ -99,6 +101,7 @@ OpenCode Go هو اشتراك منخفض التكلفة — **$5 للشهر ال - Kimi K2.5/K2.6 — ‏870 input، و55,000 cached، و200 output tokens لكل طلب - DeepSeek V4 Pro — ‏750 input، و82,000 cached، و290 output tokens لكل طلب - DeepSeek V4 Flash — ‏790 input، و68,000 cached، و280 output tokens لكل طلب +- MiniMax M3 — ‏810 input، و62,000 cached، و225 output tokens لكل طلب - MiniMax M2.7/M2.5 — ‏300 input، و55,000 cached، و125 output tokens لكل طلب - Qwen3.7 Max — ‏420 input، و66,000 cached، و200 output tokens لكل طلب - Qwen3.6 Plus — ‏500 input، و57,000 cached، و190 output tokens لكل طلب @@ -115,6 +118,7 @@ OpenCode Go هو اشتراك منخفض التكلفة — **$5 للشهر ال | Kimi K2.5 | $0.60 | $3.00 | $0.10 | - | | MiMo V2.5 | $0.14 | $0.28 | $0.0028 | - | | MiMo V2.5 Pro | $1.74 | $3.48 | $0.0145 | - | +| MiniMax M3 | $0.60 | $2.40 | $0.12 | $0.75 | | MiniMax M2.7 | $0.30 | $1.20 | $0.06 | $0.375 | | MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 | | Qwen3.7 Max | $2.50 | $7.50 | $0.50 | $3.125 | @@ -152,6 +156,7 @@ OpenCode Go هو اشتراك منخفض التكلفة — **$5 للشهر ال | DeepSeek V4 Flash | deepseek-v4-flash | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiMo-V2.5 | mimo-v2.5 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiMo-V2.5-Pro | mimo-v2.5-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| MiniMax M3 | minimax-m3 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.7 Max | qwen3.7-max | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | diff --git a/packages/web/src/content/docs/ar/zen.mdx b/packages/web/src/content/docs/ar/zen.mdx index cc69e7ce06..326177b397 100644 --- a/packages/web/src/content/docs/ar/zen.mdx +++ b/packages/web/src/content/docs/ar/zen.mdx @@ -97,9 +97,9 @@ OpenCode Zen هي بوابة AI تتيح لك الوصول إلى هذه الن | Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Grok Build 0.1 | grok-build-0.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| DeepSeek V4 Flash Free | deepseek-v4-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Nemotron 3 Super Free | nemotron-3-super-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| DeepSeek V4 Flash Free | deepseek-v4-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | يستخدم [معرّف النموذج](/docs/config/#models) في إعدادات OpenCode الصيغة `opencode/`. على سبيل المثال، بالنسبة إلى GPT 5.5، ستستخدم `opencode/gpt-5.5` في إعداداتك. diff --git a/packages/web/src/content/docs/bs/go.mdx b/packages/web/src/content/docs/bs/go.mdx index 8cd0491d6f..f3a02342ae 100644 --- a/packages/web/src/content/docs/bs/go.mdx +++ b/packages/web/src/content/docs/bs/go.mdx @@ -67,6 +67,7 @@ Trenutna lista modela uključuje: - **MiMo-V2.5-Pro** - **MiniMax M2.5** - **MiniMax M2.7** +- **MiniMax M3** - **Qwen3.6 Plus** - **Qwen3.7 Max** - **DeepSeek V4 Pro** @@ -96,6 +97,7 @@ Tabela ispod pruža procijenjeni broj zahtjeva na osnovu tipičnih obrazaca kori | Kimi K2.6 | 1,150 | 2,880 | 5,750 | | MiMo-V2.5 | 30,100 | 75,200 | 150,400 | | MiMo-V2.5-Pro | 3,250 | 8,150 | 16,300 | +| MiniMax M3 | 1,400 | 3,500 | 7,000 | | MiniMax M2.7 | 3,400 | 8,500 | 17,000 | | MiniMax M2.5 | 6,300 | 15,900 | 31,800 | | Qwen3.7 Max | 950 | 2,390 | 4,770 | @@ -109,6 +111,7 @@ Procjene se zasnivaju na zapaženim prosječnim obrascima zahtjeva: - Kimi K2.5/K2.6 — 870 ulaznih, 55,000 keširanih, 200 izlaznih tokena po zahtjevu - DeepSeek V4 Pro — 750 ulaznih, 82,000 keširanih, 290 izlaznih tokena po zahtjevu - DeepSeek V4 Flash — 790 ulaznih, 68,000 keširanih, 280 izlaznih tokena po zahtjevu +- MiniMax M3 — 810 ulaznih, 62,000 keširanih, 225 izlaznih tokena po zahtjevu - MiniMax M2.7/M2.5 — 300 ulaznih, 55,000 keširanih, 125 izlaznih tokena po zahtjevu - Qwen3.7 Max — 420 ulaznih, 66,000 keširanih, 200 izlaznih tokena po zahtjevu - Qwen3.6 Plus — 500 ulaznih, 57,000 keširanih, 190 izlaznih tokena po zahtjevu @@ -125,6 +128,7 @@ Procjene se također zasnivaju na sljedećim cijenama po 1M tokena: | Kimi K2.5 | $0.60 | $3.00 | $0.10 | - | | MiMo V2.5 | $0.14 | $0.28 | $0.0028 | - | | MiMo V2.5 Pro | $1.74 | $3.48 | $0.0145 | - | +| MiniMax M3 | $0.60 | $2.40 | $0.12 | $0.75 | | MiniMax M2.7 | $0.30 | $1.20 | $0.06 | $0.375 | | MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 | | Qwen3.7 Max | $2.50 | $7.50 | $0.50 | $3.125 | @@ -164,6 +168,7 @@ Također možete pristupiti Go modelima putem sljedećih API endpointa. | DeepSeek V4 Flash | deepseek-v4-flash | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiMo-V2.5 | mimo-v2.5 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiMo-V2.5-Pro | mimo-v2.5-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| MiniMax M3 | minimax-m3 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.7 Max | qwen3.7-max | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | diff --git a/packages/web/src/content/docs/bs/zen.mdx b/packages/web/src/content/docs/bs/zen.mdx index 1659748fc3..0136425a74 100644 --- a/packages/web/src/content/docs/bs/zen.mdx +++ b/packages/web/src/content/docs/bs/zen.mdx @@ -102,9 +102,9 @@ Našim modelima možete pristupiti i preko sljedećih API endpointa. | Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Grok Build 0.1 | grok-build-0.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| DeepSeek V4 Flash Free | deepseek-v4-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Nemotron 3 Super Free | nemotron-3-super-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| DeepSeek V4 Flash Free | deepseek-v4-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | [model id](/docs/config/#models) u vašoj OpenCode konfiguraciji koristi format `opencode/`. Na primjer, za GPT 5.5 u konfiguraciji biste diff --git a/packages/web/src/content/docs/da/go.mdx b/packages/web/src/content/docs/da/go.mdx index a64de349d4..e2bbb0d0b1 100644 --- a/packages/web/src/content/docs/da/go.mdx +++ b/packages/web/src/content/docs/da/go.mdx @@ -67,6 +67,7 @@ Den nuværende liste over modeller inkluderer: - **MiMo-V2.5-Pro** - **MiniMax M2.5** - **MiniMax M2.7** +- **MiniMax M3** - **Qwen3.6 Plus** - **Qwen3.7 Max** - **DeepSeek V4 Pro** @@ -96,6 +97,7 @@ Tabellen nedenfor giver et estimeret antal anmodninger baseret på typiske Go-fo | Kimi K2.6 | 1,150 | 2,880 | 5,750 | | MiMo-V2.5 | 30,100 | 75,200 | 150,400 | | MiMo-V2.5-Pro | 3,250 | 8,150 | 16,300 | +| MiniMax M3 | 1,400 | 3,500 | 7,000 | | MiniMax M2.7 | 3,400 | 8,500 | 17,000 | | MiniMax M2.5 | 6,300 | 15,900 | 31,800 | | Qwen3.7 Max | 950 | 2,390 | 4,770 | @@ -109,6 +111,7 @@ Estimaterne er baseret på observerede gennemsnitlige anmodningsmønstre: - Kimi K2.5/K2.6 — 870 input, 55.000 cachelagrede, 200 output-tokens pr. anmodning - DeepSeek V4 Pro — 750 input, 82.000 cachelagrede, 290 output-tokens pr. anmodning - DeepSeek V4 Flash — 790 input, 68.000 cachelagrede, 280 output-tokens pr. anmodning +- MiniMax M3 — 810 input, 62.000 cachelagrede, 225 output-tokens pr. anmodning - MiniMax M2.7/M2.5 — 300 input, 55.000 cachelagrede, 125 output-tokens pr. anmodning - Qwen3.7 Max — 420 input, 66.000 cachelagrede, 200 output-tokens pr. anmodning - Qwen3.6 Plus — 500 input, 57.000 cachelagrede, 190 output-tokens pr. anmodning @@ -125,6 +128,7 @@ Estimaterne er også baseret på følgende priser pr. 1M tokens: | Kimi K2.5 | $0.60 | $3.00 | $0.10 | - | | MiMo V2.5 | $0.14 | $0.28 | $0.0028 | - | | MiMo V2.5 Pro | $1.74 | $3.48 | $0.0145 | - | +| MiniMax M3 | $0.60 | $2.40 | $0.12 | $0.75 | | MiniMax M2.7 | $0.30 | $1.20 | $0.06 | $0.375 | | MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 | | Qwen3.7 Max | $2.50 | $7.50 | $0.50 | $3.125 | @@ -164,6 +168,7 @@ Du kan også få adgang til Go-modeller gennem følgende API-endpoints. | DeepSeek V4 Flash | deepseek-v4-flash | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiMo-V2.5 | mimo-v2.5 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiMo-V2.5-Pro | mimo-v2.5-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| MiniMax M3 | minimax-m3 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.7 Max | qwen3.7-max | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | diff --git a/packages/web/src/content/docs/da/zen.mdx b/packages/web/src/content/docs/da/zen.mdx index c61d97d881..1abc0a8942 100644 --- a/packages/web/src/content/docs/da/zen.mdx +++ b/packages/web/src/content/docs/da/zen.mdx @@ -102,9 +102,9 @@ Du kan også få adgang til vores modeller gennem følgende API-endpoints. | Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Grok Build 0.1 | grok-build-0.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| DeepSeek V4 Flash Free | deepseek-v4-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Nemotron 3 Super Free | nemotron-3-super-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| DeepSeek V4 Flash Free | deepseek-v4-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | [model id](/docs/config/#models) i din OpenCode-konfiguration bruger formatet `opencode/`. For eksempel ville du for GPT 5.5 diff --git a/packages/web/src/content/docs/de/go.mdx b/packages/web/src/content/docs/de/go.mdx index c5346cc3c2..7bbffaec0d 100644 --- a/packages/web/src/content/docs/de/go.mdx +++ b/packages/web/src/content/docs/de/go.mdx @@ -59,6 +59,7 @@ Die aktuelle Liste der Modelle umfasst: - **MiMo-V2.5-Pro** - **MiniMax M2.5** - **MiniMax M2.7** +- **MiniMax M3** - **Qwen3.6 Plus** - **Qwen3.7 Max** - **DeepSeek V4 Pro** @@ -88,6 +89,7 @@ Die folgende Tabelle zeigt eine geschätzte Anzahl von Anfragen basierend auf ty | Kimi K2.6 | 1,150 | 2,880 | 5,750 | | MiMo-V2.5 | 30,100 | 75,200 | 150,400 | | MiMo-V2.5-Pro | 3,250 | 8,150 | 16,300 | +| MiniMax M3 | 1,400 | 3,500 | 7,000 | | MiniMax M2.7 | 3,400 | 8,500 | 17,000 | | MiniMax M2.5 | 6,300 | 15,900 | 31,800 | | Qwen3.7 Max | 950 | 2,390 | 4,770 | @@ -101,6 +103,7 @@ Die Schätzungen basieren auf beobachteten durchschnittlichen Anfragemustern: - Kimi K2.5/K2.6 — 870 Input-, 55.000 Cached-, 200 Output-Tokens pro Anfrage - DeepSeek V4 Pro — 750 Input-, 82.000 Cached-, 290 Output-Tokens pro Anfrage - DeepSeek V4 Flash — 790 Input-, 68.000 Cached-, 280 Output-Tokens pro Anfrage +- MiniMax M3 — 810 Input-, 62.000 Cached-, 225 Output-Tokens pro Anfrage - MiniMax M2.7/M2.5 — 300 Input-, 55.000 Cached-, 125 Output-Tokens pro Anfrage - Qwen3.7 Max — 420 Input-, 66.000 Cached-, 200 Output-Tokens pro Anfrage - Qwen3.6 Plus — 500 Input-, 57.000 Cached-, 190 Output-Tokens pro Anfrage @@ -117,6 +120,7 @@ Die Schätzungen basieren außerdem auf den folgenden Preisen pro 1M Tokens: | Kimi K2.5 | $0.60 | $3.00 | $0.10 | - | | MiMo V2.5 | $0.14 | $0.28 | $0.0028 | - | | MiMo V2.5 Pro | $1.74 | $3.48 | $0.0145 | - | +| MiniMax M3 | $0.60 | $2.40 | $0.12 | $0.75 | | MiniMax M2.7 | $0.30 | $1.20 | $0.06 | $0.375 | | MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 | | Qwen3.7 Max | $2.50 | $7.50 | $0.50 | $3.125 | @@ -154,6 +158,7 @@ Du kannst auf die Go-Modelle auch über die folgenden API-Endpunkte zugreifen. | DeepSeek V4 Flash | deepseek-v4-flash | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiMo-V2.5 | mimo-v2.5 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiMo-V2.5-Pro | mimo-v2.5-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| MiniMax M3 | minimax-m3 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.7 Max | qwen3.7-max | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | diff --git a/packages/web/src/content/docs/de/zen.mdx b/packages/web/src/content/docs/de/zen.mdx index 1fa139d05d..966af4f9ad 100644 --- a/packages/web/src/content/docs/de/zen.mdx +++ b/packages/web/src/content/docs/de/zen.mdx @@ -93,9 +93,9 @@ Du kannst auch über die folgenden API-Endpunkte auf unsere Modelle zugreifen. | Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Grok Build 0.1 | grok-build-0.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| DeepSeek V4 Flash Free | deepseek-v4-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Nemotron 3 Super Free | nemotron-3-super-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| DeepSeek V4 Flash Free | deepseek-v4-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | Die [Model-ID](/docs/config/#models) in deiner OpenCode-Konfiguration verwendet das Format `opencode/`. Für GPT 5.5 würdest du zum Beispiel `opencode/gpt-5.5` in deiner Konfiguration verwenden. diff --git a/packages/web/src/content/docs/es/go.mdx b/packages/web/src/content/docs/es/go.mdx index 34bb6602eb..b7dba05c8f 100644 --- a/packages/web/src/content/docs/es/go.mdx +++ b/packages/web/src/content/docs/es/go.mdx @@ -67,6 +67,7 @@ La lista actual de modelos incluye: - **MiMo-V2.5-Pro** - **MiniMax M2.5** - **MiniMax M2.7** +- **MiniMax M3** - **Qwen3.6 Plus** - **Qwen3.7 Max** - **DeepSeek V4 Pro** @@ -96,6 +97,7 @@ La siguiente tabla proporciona una cantidad estimada de peticiones basada en los | Kimi K2.6 | 1,150 | 2,880 | 5,750 | | MiMo-V2.5 | 30,100 | 75,200 | 150,400 | | MiMo-V2.5-Pro | 3,250 | 8,150 | 16,300 | +| MiniMax M3 | 1,400 | 3,500 | 7,000 | | MiniMax M2.7 | 3,400 | 8,500 | 17,000 | | MiniMax M2.5 | 6,300 | 15,900 | 31,800 | | Qwen3.7 Max | 950 | 2,390 | 4,770 | @@ -109,6 +111,7 @@ Las estimaciones se basan en los patrones de peticiones promedio observados: - Kimi K2.5/K2.6 — 870 tokens de entrada, 55,000 en caché, 200 tokens de salida por petición - DeepSeek V4 Pro — 750 tokens de entrada, 82,000 en caché, 290 tokens de salida por petición - DeepSeek V4 Flash — 790 tokens de entrada, 68,000 en caché, 280 tokens de salida por petición +- MiniMax M3 — 810 tokens de entrada, 62,000 en caché, 225 tokens de salida por petición - MiniMax M2.7/M2.5 — 300 tokens de entrada, 55,000 en caché, 125 tokens de salida por petición - Qwen3.7 Max — 420 tokens de entrada, 66,000 en caché, 200 tokens de salida por petición - Qwen3.6 Plus — 500 tokens de entrada, 57,000 en caché, 190 tokens de salida por petición @@ -125,6 +128,7 @@ Las estimaciones también se basan en los siguientes precios por 1M tokens: | Kimi K2.5 | $0.60 | $3.00 | $0.10 | - | | MiMo V2.5 | $0.14 | $0.28 | $0.0028 | - | | MiMo V2.5 Pro | $1.74 | $3.48 | $0.0145 | - | +| MiniMax M3 | $0.60 | $2.40 | $0.12 | $0.75 | | MiniMax M2.7 | $0.30 | $1.20 | $0.06 | $0.375 | | MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 | | Qwen3.7 Max | $2.50 | $7.50 | $0.50 | $3.125 | @@ -164,6 +168,7 @@ También puedes acceder a los modelos de Go a través de los siguientes endpoint | DeepSeek V4 Flash | deepseek-v4-flash | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiMo-V2.5 | mimo-v2.5 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiMo-V2.5-Pro | mimo-v2.5-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| MiniMax M3 | minimax-m3 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.7 Max | qwen3.7-max | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | diff --git a/packages/web/src/content/docs/es/zen.mdx b/packages/web/src/content/docs/es/zen.mdx index 359aed418e..f7bc492780 100644 --- a/packages/web/src/content/docs/es/zen.mdx +++ b/packages/web/src/content/docs/es/zen.mdx @@ -102,9 +102,9 @@ También puedes acceder a nuestros modelos a través de los siguientes endpoints | Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Grok Build 0.1 | grok-build-0.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| DeepSeek V4 Flash Free | deepseek-v4-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Nemotron 3 Super Free | nemotron-3-super-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| DeepSeek V4 Flash Free | deepseek-v4-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | El [identificador del modelo](/docs/config/#models) en tu configuración de OpenCode usa el formato `opencode/`. Por ejemplo, para GPT 5.5, usarías diff --git a/packages/web/src/content/docs/fr/go.mdx b/packages/web/src/content/docs/fr/go.mdx index 994c17afd6..85923a5701 100644 --- a/packages/web/src/content/docs/fr/go.mdx +++ b/packages/web/src/content/docs/fr/go.mdx @@ -57,6 +57,7 @@ La liste actuelle des modèles comprend : - **MiMo-V2.5-Pro** - **MiniMax M2.5** - **MiniMax M2.7** +- **MiniMax M3** - **Qwen3.6 Plus** - **Qwen3.7 Max** - **DeepSeek V4 Pro** @@ -86,6 +87,7 @@ Le tableau ci-dessous fournit une estimation du nombre de requêtes basée sur d | Kimi K2.6 | 1,150 | 2,880 | 5,750 | | MiMo-V2.5 | 30,100 | 75,200 | 150,400 | | MiMo-V2.5-Pro | 3,250 | 8,150 | 16,300 | +| MiniMax M3 | 1,400 | 3,500 | 7,000 | | MiniMax M2.7 | 3,400 | 8,500 | 17,000 | | MiniMax M2.5 | 6,300 | 15,900 | 31,800 | | Qwen3.7 Max | 950 | 2,390 | 4,770 | @@ -99,6 +101,7 @@ Les estimations sont basées sur les schémas de requêtes moyens observés : - Kimi K2.5/K2.6 — 870 tokens en entrée, 55,000 en cache, 200 tokens en sortie par requête - DeepSeek V4 Pro — 750 tokens en entrée, 82,000 en cache, 290 tokens en sortie par requête - DeepSeek V4 Flash — 790 tokens en entrée, 68,000 en cache, 280 tokens en sortie par requête +- MiniMax M3 — 810 tokens en entrée, 62,000 en cache, 225 tokens en sortie par requête - MiniMax M2.7/M2.5 — 300 tokens en entrée, 55,000 en cache, 125 tokens en sortie par requête - Qwen3.7 Max — 420 tokens en entrée, 66,000 en cache, 200 tokens en sortie par requête - Qwen3.6 Plus — 500 tokens en entrée, 57,000 en cache, 190 tokens en sortie par requête @@ -115,6 +118,7 @@ Les estimations sont également basées sur les prix suivants par 1M tokens : | Kimi K2.5 | $0.60 | $3.00 | $0.10 | - | | MiMo V2.5 | $0.14 | $0.28 | $0.0028 | - | | MiMo V2.5 Pro | $1.74 | $3.48 | $0.0145 | - | +| MiniMax M3 | $0.60 | $2.40 | $0.12 | $0.75 | | MiniMax M2.7 | $0.30 | $1.20 | $0.06 | $0.375 | | MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 | | Qwen3.7 Max | $2.50 | $7.50 | $0.50 | $3.125 | @@ -152,6 +156,7 @@ Vous pouvez également accéder aux modèles Go via les points de terminaison d' | DeepSeek V4 Flash | deepseek-v4-flash | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiMo-V2.5 | mimo-v2.5 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiMo-V2.5-Pro | mimo-v2.5-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| MiniMax M3 | minimax-m3 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.7 Max | qwen3.7-max | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | diff --git a/packages/web/src/content/docs/fr/zen.mdx b/packages/web/src/content/docs/fr/zen.mdx index 2e508a2e18..14a2109c9a 100644 --- a/packages/web/src/content/docs/fr/zen.mdx +++ b/packages/web/src/content/docs/fr/zen.mdx @@ -93,9 +93,9 @@ Vous pouvez également accéder à nos modèles via les points de terminaison AP | Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Grok Build 0.1 | grok-build-0.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| DeepSeek V4 Flash Free | deepseek-v4-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Nemotron 3 Super Free | nemotron-3-super-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| DeepSeek V4 Flash Free | deepseek-v4-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | Le [model id](/docs/config/#models) dans votre configuration OpenCode utilise le format `opencode/`. Par exemple, pour GPT 5.5, vous utiliseriez `opencode/gpt-5.5` dans votre configuration. diff --git a/packages/web/src/content/docs/go.mdx b/packages/web/src/content/docs/go.mdx index a960b51ae9..fe3c576cfe 100644 --- a/packages/web/src/content/docs/go.mdx +++ b/packages/web/src/content/docs/go.mdx @@ -67,6 +67,7 @@ The current list of models includes: - **MiMo-V2.5-Pro** - **MiniMax M2.5** - **MiniMax M2.7** +- **MiniMax M3** - **Qwen3.6 Plus** - **Qwen3.7 Max** - **DeepSeek V4 Pro** @@ -96,6 +97,7 @@ The table below provides an estimated request count based on typical Go usage pa | Kimi K2.6 | 1,150 | 2,880 | 5,750 | | MiMo-V2.5 | 30,100 | 75,200 | 150,400 | | MiMo-V2.5-Pro | 3,250 | 8,150 | 16,300 | +| MiniMax M3 | 1,400 | 3,500 | 7,000 | | MiniMax M2.7 | 3,400 | 8,500 | 17,000 | | MiniMax M2.5 | 6,300 | 15,900 | 31,800 | | Qwen3.7 Max | 950 | 2,390 | 4,770 | @@ -109,6 +111,7 @@ The estimates are based on observed average request patterns: - Kimi K2.5/K2.6 — 870 input, 55,000 cached, 200 output tokens per request - DeepSeek V4 Pro — 750 input, 82,000 cached, 290 output tokens per request - DeepSeek V4 Flash — 790 input, 68,000 cached, 280 output tokens per request +- MiniMax M3 — 810 input, 62,000 cached, 225 output tokens per request - MiniMax M2.7/M2.5 — 300 input, 55,000 cached, 125 output tokens per request - MiMo-V2.5 — 830 input, 71,500 cached, 295 output tokens per request - MiMo-V2.5-Pro — 790 input, 86,000 cached, 305 output tokens per request @@ -125,6 +128,7 @@ The estimates are also based on the following prices per 1M tokens: | Kimi K2.5 | $0.60 | $3.00 | $0.10 | - | | MiMo V2.5 | $0.14 | $0.28 | $0.0028 | - | | MiMo V2.5 Pro | $1.74 | $3.48 | $0.0145 | - | +| MiniMax M3 | $0.60 | $2.40 | $0.12 | $0.75 | | MiniMax M2.7 | $0.30 | $1.20 | $0.06 | $0.375 | | MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 | | Qwen3.7 Max | $2.50 | $7.50 | $0.50 | $3.125 | @@ -164,6 +168,7 @@ You can also access Go models through the following API endpoints. | DeepSeek V4 Flash | deepseek-v4-flash | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiMo-V2.5 | mimo-v2.5 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiMo-V2.5-Pro | mimo-v2.5-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| MiniMax M3 | minimax-m3 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.7 Max | qwen3.7-max | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | diff --git a/packages/web/src/content/docs/it/go.mdx b/packages/web/src/content/docs/it/go.mdx index 33b9a29c21..dbe208a8a9 100644 --- a/packages/web/src/content/docs/it/go.mdx +++ b/packages/web/src/content/docs/it/go.mdx @@ -65,6 +65,7 @@ L'elenco attuale dei modelli include: - **MiMo-V2.5-Pro** - **MiniMax M2.5** - **MiniMax M2.7** +- **MiniMax M3** - **Qwen3.6 Plus** - **Qwen3.7 Max** - **DeepSeek V4 Pro** @@ -94,6 +95,7 @@ La tabella seguente fornisce una stima del conteggio delle richieste in base a p | Kimi K2.6 | 1,150 | 2,880 | 5,750 | | MiMo-V2.5 | 30,100 | 75,200 | 150,400 | | MiMo-V2.5-Pro | 3,250 | 8,150 | 16,300 | +| MiniMax M3 | 1,400 | 3,500 | 7,000 | | MiniMax M2.7 | 3,400 | 8,500 | 17,000 | | MiniMax M2.5 | 6,300 | 15,900 | 31,800 | | Qwen3.7 Max | 950 | 2,390 | 4,770 | @@ -107,6 +109,7 @@ Le stime si basano sui pattern medi di richieste osservati: - Kimi K2.5/K2.6 — 870 di input, 55.000 in cache, 200 token di output per richiesta - DeepSeek V4 Pro — 750 di input, 82.000 in cache, 290 token di output per richiesta - DeepSeek V4 Flash — 790 di input, 68.000 in cache, 280 token di output per richiesta +- MiniMax M3 — 810 di input, 62.000 in cache, 225 token di output per richiesta - MiniMax M2.7/M2.5 — 300 di input, 55.000 in cache, 125 token di output per richiesta - Qwen3.7 Max — 420 di input, 66.000 in cache, 200 token di output per richiesta - Qwen3.6 Plus — 500 di input, 57.000 in cache, 190 token di output per richiesta @@ -123,6 +126,7 @@ Le stime si basano anche sui seguenti prezzi per 1M token: | Kimi K2.5 | $0.60 | $3.00 | $0.10 | - | | MiMo V2.5 | $0.14 | $0.28 | $0.0028 | - | | MiMo V2.5 Pro | $1.74 | $3.48 | $0.0145 | - | +| MiniMax M3 | $0.60 | $2.40 | $0.12 | $0.75 | | MiniMax M2.7 | $0.30 | $1.20 | $0.06 | $0.375 | | MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 | | Qwen3.7 Max | $2.50 | $7.50 | $0.50 | $3.125 | @@ -162,6 +166,7 @@ Puoi anche accedere ai modelli Go tramite i seguenti endpoint API. | DeepSeek V4 Flash | deepseek-v4-flash | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiMo-V2.5 | mimo-v2.5 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiMo-V2.5-Pro | mimo-v2.5-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| MiniMax M3 | minimax-m3 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.7 Max | qwen3.7-max | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | diff --git a/packages/web/src/content/docs/it/zen.mdx b/packages/web/src/content/docs/it/zen.mdx index 82138d0b25..b81eecae3f 100644 --- a/packages/web/src/content/docs/it/zen.mdx +++ b/packages/web/src/content/docs/it/zen.mdx @@ -102,9 +102,9 @@ Puoi anche accedere ai nostri modelli tramite i seguenti endpoint API. | Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Grok Build 0.1 | grok-build-0.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| DeepSeek V4 Flash Free | deepseek-v4-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Nemotron 3 Super Free | nemotron-3-super-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| DeepSeek V4 Flash Free | deepseek-v4-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | Il [model id](/docs/config/#models) nella config di OpenCode usa il formato `opencode/`. Per esempio, per GPT 5.5, useresti diff --git a/packages/web/src/content/docs/ja/go.mdx b/packages/web/src/content/docs/ja/go.mdx index 67eecc8196..4a9a0eb63b 100644 --- a/packages/web/src/content/docs/ja/go.mdx +++ b/packages/web/src/content/docs/ja/go.mdx @@ -57,6 +57,7 @@ OpenCode Goをサブスクライブできるのは、1つのワークスペー - **MiMo-V2.5-Pro** - **MiniMax M2.5** - **MiniMax M2.7** +- **MiniMax M3** - **Qwen3.6 Plus** - **Qwen3.7 Max** - **DeepSeek V4 Pro** @@ -86,6 +87,7 @@ OpenCode Goには以下の制限が含まれています: | Kimi K2.6 | 1,150 | 2,880 | 5,750 | | MiMo-V2.5 | 30,100 | 75,200 | 150,400 | | MiMo-V2.5-Pro | 3,250 | 8,150 | 16,300 | +| MiniMax M3 | 1,400 | 3,500 | 7,000 | | MiniMax M2.7 | 3,400 | 8,500 | 17,000 | | MiniMax M2.5 | 6,300 | 15,900 | 31,800 | | Qwen3.7 Max | 950 | 2,390 | 4,770 | @@ -99,6 +101,7 @@ OpenCode Goには以下の制限が含まれています: - Kimi K2.5/K2.6 — リクエストあたり 入力 870トークン、キャッシュ 55,000トークン、出力 200トークン - DeepSeek V4 Pro — リクエストあたり 入力 750トークン、キャッシュ 82,000トークン、出力 290トークン - DeepSeek V4 Flash — リクエストあたり 入力 790トークン、キャッシュ 68,000トークン、出力 280トークン +- MiniMax M3 — リクエストあたり 入力 810トークン、キャッシュ 62,000トークン、出力 225トークン - MiniMax M2.7/M2.5 — リクエストあたり 入力 300トークン、キャッシュ 55,000トークン、出力 125トークン - Qwen3.7 Max — リクエストあたり 入力 420トークン、キャッシュ 66,000トークン、出力 200トークン - Qwen3.6 Plus — リクエストあたり 入力 500トークン、キャッシュ 57,000トークン、出力 190トークン @@ -115,6 +118,7 @@ OpenCode Goには以下の制限が含まれています: | Kimi K2.5 | $0.60 | $3.00 | $0.10 | - | | MiMo V2.5 | $0.14 | $0.28 | $0.0028 | - | | MiMo V2.5 Pro | $1.74 | $3.48 | $0.0145 | - | +| MiniMax M3 | $0.60 | $2.40 | $0.12 | $0.75 | | MiniMax M2.7 | $0.30 | $1.20 | $0.06 | $0.375 | | MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 | | Qwen3.7 Max | $2.50 | $7.50 | $0.50 | $3.125 | @@ -152,6 +156,7 @@ Zen残高にクレジットがある場合は、コンソールで**Use balance* | DeepSeek V4 Flash | deepseek-v4-flash | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiMo-V2.5 | mimo-v2.5 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiMo-V2.5-Pro | mimo-v2.5-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| MiniMax M3 | minimax-m3 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.7 Max | qwen3.7-max | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | diff --git a/packages/web/src/content/docs/ja/zen.mdx b/packages/web/src/content/docs/ja/zen.mdx index f66c72ebe6..155a6951b2 100644 --- a/packages/web/src/content/docs/ja/zen.mdx +++ b/packages/web/src/content/docs/ja/zen.mdx @@ -93,9 +93,9 @@ OpenCode Zen は、OpenCode のほかのプロバイダーと同じように動 | Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Grok Build 0.1 | grok-build-0.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| DeepSeek V4 Flash Free | deepseek-v4-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Nemotron 3 Super Free | nemotron-3-super-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| DeepSeek V4 Flash Free | deepseek-v4-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | OpenCode 設定で使う [model id](/docs/config/#models) は `opencode/` 形式です。たとえば、GPT 5.5 では設定に `opencode/gpt-5.5` を使用します。 diff --git a/packages/web/src/content/docs/ko/go.mdx b/packages/web/src/content/docs/ko/go.mdx index aabb1d03e1..7cbbb9a0dc 100644 --- a/packages/web/src/content/docs/ko/go.mdx +++ b/packages/web/src/content/docs/ko/go.mdx @@ -57,6 +57,7 @@ workspace당 한 명의 멤버만 OpenCode Go를 구독할 수 있습니다. - **MiMo-V2.5-Pro** - **MiniMax M2.5** - **MiniMax M2.7** +- **MiniMax M3** - **Qwen3.6 Plus** - **Qwen3.7 Max** - **DeepSeek V4 Pro** @@ -86,6 +87,7 @@ OpenCode Go에는 다음과 같은 한도가 포함됩니다. | Kimi K2.6 | 1,150 | 2,880 | 5,750 | | MiMo-V2.5 | 30,100 | 75,200 | 150,400 | | MiMo-V2.5-Pro | 3,250 | 8,150 | 16,300 | +| MiniMax M3 | 1,400 | 3,500 | 7,000 | | MiniMax M2.7 | 3,400 | 8,500 | 17,000 | | MiniMax M2.5 | 6,300 | 15,900 | 31,800 | | Qwen3.7 Max | 950 | 2,390 | 4,770 | @@ -99,6 +101,7 @@ OpenCode Go에는 다음과 같은 한도가 포함됩니다. - Kimi K2.5/K2.6 — 요청당 입력 870, 캐시 55,000, 출력 토큰 200 - DeepSeek V4 Pro — 요청당 입력 750, 캐시 82,000, 출력 토큰 290 - DeepSeek V4 Flash — 요청당 입력 790, 캐시 68,000, 출력 토큰 280 +- MiniMax M3 — 요청당 입력 810, 캐시 62,000, 출력 토큰 225 - MiniMax M2.7/M2.5 — 요청당 입력 300, 캐시 55,000, 출력 토큰 125 - Qwen3.7 Max — 요청당 입력 420, 캐시 66,000, 출력 토큰 200 - Qwen3.6 Plus — 요청당 입력 500, 캐시 57,000, 출력 토큰 190 @@ -115,6 +118,7 @@ OpenCode Go에는 다음과 같은 한도가 포함됩니다. | Kimi K2.5 | $0.60 | $3.00 | $0.10 | - | | MiMo V2.5 | $0.14 | $0.28 | $0.0028 | - | | MiMo V2.5 Pro | $1.74 | $3.48 | $0.0145 | - | +| MiniMax M3 | $0.60 | $2.40 | $0.12 | $0.75 | | MiniMax M2.7 | $0.30 | $1.20 | $0.06 | $0.375 | | MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 | | Qwen3.7 Max | $2.50 | $7.50 | $0.50 | $3.125 | @@ -152,6 +156,7 @@ Zen 잔액에 크레딧도 있다면, console에서 **Use balance** 옵션을 | DeepSeek V4 Flash | deepseek-v4-flash | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiMo-V2.5 | mimo-v2.5 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiMo-V2.5-Pro | mimo-v2.5-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| MiniMax M3 | minimax-m3 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.7 Max | qwen3.7-max | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | diff --git a/packages/web/src/content/docs/ko/zen.mdx b/packages/web/src/content/docs/ko/zen.mdx index 5a574c46c6..e368dbf6ec 100644 --- a/packages/web/src/content/docs/ko/zen.mdx +++ b/packages/web/src/content/docs/ko/zen.mdx @@ -93,9 +93,9 @@ OpenCode Zen은 OpenCode의 다른 provider와 똑같이 작동합니다. | Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Grok Build 0.1 | grok-build-0.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| DeepSeek V4 Flash Free | deepseek-v4-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Nemotron 3 Super Free | nemotron-3-super-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| DeepSeek V4 Flash Free | deepseek-v4-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | OpenCode config에서 사용하는 [모델 ID](/docs/config/#models)는 `opencode/` 형식입니다. 예를 들어 GPT 5.5를 사용하려면 config에서 `opencode/gpt-5.5`를 사용하면 됩니다. diff --git a/packages/web/src/content/docs/nb/go.mdx b/packages/web/src/content/docs/nb/go.mdx index af1636877b..bee983fb8b 100644 --- a/packages/web/src/content/docs/nb/go.mdx +++ b/packages/web/src/content/docs/nb/go.mdx @@ -67,6 +67,7 @@ Den nåværende listen over modeller inkluderer: - **MiMo-V2.5-Pro** - **MiniMax M2.5** - **MiniMax M2.7** +- **MiniMax M3** - **Qwen3.6 Plus** - **Qwen3.7 Max** - **DeepSeek V4 Pro** @@ -96,6 +97,7 @@ Tabellen nedenfor gir et estimert antall forespørsler basert på typiske bruksm | Kimi K2.6 | 1,150 | 2,880 | 5,750 | | MiMo-V2.5 | 30,100 | 75,200 | 150,400 | | MiMo-V2.5-Pro | 3,250 | 8,150 | 16,300 | +| MiniMax M3 | 1,400 | 3,500 | 7,000 | | MiniMax M2.7 | 3,400 | 8,500 | 17,000 | | MiniMax M2.5 | 6,300 | 15,900 | 31,800 | | Qwen3.7 Max | 950 | 2,390 | 4,770 | @@ -109,6 +111,7 @@ Estimatene er basert på observerte gjennomsnittlige forespørselsmønstre: - Kimi K2.5/K2.6 — 870 input, 55 000 bufret, 200 output-tokens per forespørsel - DeepSeek V4 Pro — 750 input, 82 000 bufret, 290 output-tokens per forespørsel - DeepSeek V4 Flash — 790 input, 68 000 bufret, 280 output-tokens per forespørsel +- MiniMax M3 — 810 input, 62 000 bufret, 225 output-tokens per forespørsel - MiniMax M2.7/M2.5 — 300 input, 55 000 bufret, 125 output-tokens per forespørsel - Qwen3.7 Max — 420 input, 66 000 bufret, 200 output-tokens per forespørsel - Qwen3.6 Plus — 500 input, 57 000 bufret, 190 output-tokens per forespørsel @@ -125,6 +128,7 @@ Estimatene er også basert på følgende priser per 1M tokens: | Kimi K2.5 | $0.60 | $3.00 | $0.10 | - | | MiMo V2.5 | $0.14 | $0.28 | $0.0028 | - | | MiMo V2.5 Pro | $1.74 | $3.48 | $0.0145 | - | +| MiniMax M3 | $0.60 | $2.40 | $0.12 | $0.75 | | MiniMax M2.7 | $0.30 | $1.20 | $0.06 | $0.375 | | MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 | | Qwen3.7 Max | $2.50 | $7.50 | $0.50 | $3.125 | @@ -164,6 +168,7 @@ Du kan også få tilgang til Go-modeller gjennom følgende API-endepunkter. | DeepSeek V4 Flash | deepseek-v4-flash | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiMo-V2.5 | mimo-v2.5 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiMo-V2.5-Pro | mimo-v2.5-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| MiniMax M3 | minimax-m3 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.7 Max | qwen3.7-max | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | diff --git a/packages/web/src/content/docs/nb/zen.mdx b/packages/web/src/content/docs/nb/zen.mdx index e16abe43d3..20ea676f24 100644 --- a/packages/web/src/content/docs/nb/zen.mdx +++ b/packages/web/src/content/docs/nb/zen.mdx @@ -102,9 +102,9 @@ Du kan også få tilgang til modellene våre gjennom følgende API-endepunkter. | Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Grok Build 0.1 | grok-build-0.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| DeepSeek V4 Flash Free | deepseek-v4-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Nemotron 3 Super Free | nemotron-3-super-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| DeepSeek V4 Flash Free | deepseek-v4-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | [modell-id](/docs/config/#models) i OpenCode-konfigurasjonen din bruker formatet `opencode/`. For eksempel, for GPT 5.5, ville du diff --git a/packages/web/src/content/docs/pl/go.mdx b/packages/web/src/content/docs/pl/go.mdx index e13df2706d..dcd8c1a4c8 100644 --- a/packages/web/src/content/docs/pl/go.mdx +++ b/packages/web/src/content/docs/pl/go.mdx @@ -61,6 +61,7 @@ Obecna lista modeli obejmuje: - **MiMo-V2.5-Pro** - **MiniMax M2.5** - **MiniMax M2.7** +- **MiniMax M3** - **Qwen3.6 Plus** - **Qwen3.7 Max** - **DeepSeek V4 Pro** @@ -90,6 +91,7 @@ Poniższa tabela przedstawia szacunkową liczbę żądań na podstawie typowych | Kimi K2.6 | 1,150 | 2,880 | 5,750 | | MiMo-V2.5 | 30,100 | 75,200 | 150,400 | | MiMo-V2.5-Pro | 3,250 | 8,150 | 16,300 | +| MiniMax M3 | 1,400 | 3,500 | 7,000 | | MiniMax M2.7 | 3,400 | 8,500 | 17,000 | | MiniMax M2.5 | 6,300 | 15,900 | 31,800 | | Qwen3.7 Max | 950 | 2,390 | 4,770 | @@ -103,6 +105,7 @@ Szacunki te opierają się na zaobserwowanych średnich wzorcach żądań: - Kimi K2.5/K2.6 — 870 tokenów wejściowych, 55 000 w pamięci podręcznej, 200 tokenów wyjściowych na żądanie - DeepSeek V4 Pro — 750 tokenów wejściowych, 82 000 w pamięci podręcznej, 290 tokenów wyjściowych na żądanie - DeepSeek V4 Flash — 790 tokenów wejściowych, 68 000 w pamięci podręcznej, 280 tokenów wyjściowych na żądanie +- MiniMax M3 — 810 tokenów wejściowych, 62 000 w pamięci podręcznej, 225 tokenów wyjściowych na żądanie - MiniMax M2.7/M2.5 — 300 tokenów wejściowych, 55 000 w pamięci podręcznej, 125 tokenów wyjściowych na żądanie - Qwen3.7 Max — 420 tokenów wejściowych, 66 000 w pamięci podręcznej, 200 tokenów wyjściowych na żądanie - Qwen3.6 Plus — 500 tokenów wejściowych, 57 000 w pamięci podręcznej, 190 tokenów wyjściowych na żądanie @@ -119,6 +122,7 @@ Szacunki opierają się również na następujących cenach za 1M tokenów: | Kimi K2.5 | $0.60 | $3.00 | $0.10 | - | | MiMo V2.5 | $0.14 | $0.28 | $0.0028 | - | | MiMo V2.5 Pro | $1.74 | $3.48 | $0.0145 | - | +| MiniMax M3 | $0.60 | $2.40 | $0.12 | $0.75 | | MiniMax M2.7 | $0.30 | $1.20 | $0.06 | $0.375 | | MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 | | Qwen3.7 Max | $2.50 | $7.50 | $0.50 | $3.125 | @@ -156,6 +160,7 @@ Możesz również uzyskać dostęp do modeli Go za pośrednictwem następującyc | DeepSeek V4 Flash | deepseek-v4-flash | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiMo-V2.5 | mimo-v2.5 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiMo-V2.5-Pro | mimo-v2.5-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| MiniMax M3 | minimax-m3 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.7 Max | qwen3.7-max | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | diff --git a/packages/web/src/content/docs/pl/zen.mdx b/packages/web/src/content/docs/pl/zen.mdx index e1fcd4610e..b933e39bbc 100644 --- a/packages/web/src/content/docs/pl/zen.mdx +++ b/packages/web/src/content/docs/pl/zen.mdx @@ -102,9 +102,9 @@ Możesz też uzyskać dostęp do naszych modeli przez poniższe endpointy API. | Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Grok Build 0.1 | grok-build-0.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| DeepSeek V4 Flash Free | deepseek-v4-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Nemotron 3 Super Free | nemotron-3-super-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| DeepSeek V4 Flash Free | deepseek-v4-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | [ID modelu](/docs/config/#models) w Twojej konfiguracji OpenCode używa formatu `opencode/`. Na przykład dla GPT 5.5 użyjesz w konfiguracji diff --git a/packages/web/src/content/docs/pt-br/go.mdx b/packages/web/src/content/docs/pt-br/go.mdx index a156ba2870..e9dbb9275f 100644 --- a/packages/web/src/content/docs/pt-br/go.mdx +++ b/packages/web/src/content/docs/pt-br/go.mdx @@ -67,6 +67,7 @@ A lista atual de modelos inclui: - **MiMo-V2.5-Pro** - **MiniMax M2.5** - **MiniMax M2.7** +- **MiniMax M3** - **Qwen3.6 Plus** - **Qwen3.7 Max** - **DeepSeek V4 Pro** @@ -96,6 +97,7 @@ A tabela abaixo fornece uma contagem estimada de requisições com base nos padr | Kimi K2.6 | 1,150 | 2,880 | 5,750 | | MiMo-V2.5 | 30,100 | 75,200 | 150,400 | | MiMo-V2.5-Pro | 3,250 | 8,150 | 16,300 | +| MiniMax M3 | 1,400 | 3,500 | 7,000 | | MiniMax M2.7 | 3,400 | 8,500 | 17,000 | | MiniMax M2.5 | 6,300 | 15,900 | 31,800 | | Qwen3.7 Max | 950 | 2,390 | 4,770 | @@ -109,6 +111,7 @@ As estimativas se baseiam nos padrões médios de requisições observados: - Kimi K2.5/K2.6 — 870 tokens de entrada, 55.000 em cache, 200 tokens de saída por requisição - DeepSeek V4 Pro — 750 tokens de entrada, 82.000 em cache, 290 tokens de saída por requisição - DeepSeek V4 Flash — 790 tokens de entrada, 68.000 em cache, 280 tokens de saída por requisição +- MiniMax M3 — 810 tokens de entrada, 62.000 em cache, 225 tokens de saída por requisição - MiniMax M2.7/M2.5 — 300 tokens de entrada, 55.000 em cache, 125 tokens de saída por requisição - Qwen3.7 Max — 420 tokens de entrada, 66.000 em cache, 200 tokens de saída por requisição - Qwen3.6 Plus — 500 tokens de entrada, 57.000 em cache, 190 tokens de saída por requisição @@ -125,6 +128,7 @@ As estimativas também se baseiam nos seguintes preços por 1M tokens: | Kimi K2.5 | $0.60 | $3.00 | $0.10 | - | | MiMo V2.5 | $0.14 | $0.28 | $0.0028 | - | | MiMo V2.5 Pro | $1.74 | $3.48 | $0.0145 | - | +| MiniMax M3 | $0.60 | $2.40 | $0.12 | $0.75 | | MiniMax M2.7 | $0.30 | $1.20 | $0.06 | $0.375 | | MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 | | Qwen3.7 Max | $2.50 | $7.50 | $0.50 | $3.125 | @@ -164,6 +168,7 @@ Você também pode acessar os modelos do Go através dos seguintes endpoints de | DeepSeek V4 Flash | deepseek-v4-flash | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiMo-V2.5 | mimo-v2.5 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiMo-V2.5-Pro | mimo-v2.5-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| MiniMax M3 | minimax-m3 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.7 Max | qwen3.7-max | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | diff --git a/packages/web/src/content/docs/pt-br/zen.mdx b/packages/web/src/content/docs/pt-br/zen.mdx index 4331cd6548..464add0764 100644 --- a/packages/web/src/content/docs/pt-br/zen.mdx +++ b/packages/web/src/content/docs/pt-br/zen.mdx @@ -93,9 +93,9 @@ Você também pode acessar nossos modelos pelos seguintes endpoints de API. | Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Grok Build 0.1 | grok-build-0.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| DeepSeek V4 Flash Free | deepseek-v4-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Nemotron 3 Super Free | nemotron-3-super-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| DeepSeek V4 Flash Free | deepseek-v4-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | O [model id](/docs/config/#models) na sua configuração do OpenCode usa o formato `opencode/`. Por exemplo, para GPT 5.5, você usaria `opencode/gpt-5.5` na sua configuração. diff --git a/packages/web/src/content/docs/ru/go.mdx b/packages/web/src/content/docs/ru/go.mdx index b799960b40..c5fb4402a6 100644 --- a/packages/web/src/content/docs/ru/go.mdx +++ b/packages/web/src/content/docs/ru/go.mdx @@ -67,6 +67,7 @@ OpenCode Go работает так же, как и любой другой пр - **MiMo-V2.5-Pro** - **MiniMax M2.5** - **MiniMax M2.7** +- **MiniMax M3** - **Qwen3.6 Plus** - **Qwen3.7 Max** - **DeepSeek V4 Pro** @@ -96,6 +97,7 @@ OpenCode Go включает следующие лимиты: | Kimi K2.6 | 1,150 | 2,880 | 5,750 | | MiMo-V2.5 | 30,100 | 75,200 | 150,400 | | MiMo-V2.5-Pro | 3,250 | 8,150 | 16,300 | +| MiniMax M3 | 1,400 | 3,500 | 7,000 | | MiniMax M2.7 | 3,400 | 8,500 | 17,000 | | MiniMax M2.5 | 6,300 | 15,900 | 31,800 | | Qwen3.7 Max | 950 | 2,390 | 4,770 | @@ -109,6 +111,7 @@ OpenCode Go включает следующие лимиты: - Kimi K2.5/K2.6 — 870 входных, 55,000 кешированных, 200 выходных токенов на запрос - DeepSeek V4 Pro — 750 входных, 82,000 кешированных, 290 выходных токенов на запрос - DeepSeek V4 Flash — 790 входных, 68,000 кешированных, 280 выходных токенов на запрос +- MiniMax M3 — 810 входных, 62,000 кешированных, 225 выходных токенов на запрос - MiniMax M2.7/M2.5 — 300 входных, 55,000 кешированных, 125 выходных токенов на запрос - Qwen3.7 Max — 420 входных, 66,000 кешированных, 200 выходных токенов на запрос - Qwen3.6 Plus — 500 входных, 57,000 кешированных, 190 выходных токенов на запрос @@ -125,6 +128,7 @@ OpenCode Go включает следующие лимиты: | Kimi K2.5 | $0.60 | $3.00 | $0.10 | - | | MiMo V2.5 | $0.14 | $0.28 | $0.0028 | - | | MiMo V2.5 Pro | $1.74 | $3.48 | $0.0145 | - | +| MiniMax M3 | $0.60 | $2.40 | $0.12 | $0.75 | | MiniMax M2.7 | $0.30 | $1.20 | $0.06 | $0.375 | | MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 | | Qwen3.7 Max | $2.50 | $7.50 | $0.50 | $3.125 | @@ -164,6 +168,7 @@ OpenCode Go включает следующие лимиты: | DeepSeek V4 Flash | deepseek-v4-flash | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiMo-V2.5 | mimo-v2.5 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiMo-V2.5-Pro | mimo-v2.5-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| MiniMax M3 | minimax-m3 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.7 Max | qwen3.7-max | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | diff --git a/packages/web/src/content/docs/ru/zen.mdx b/packages/web/src/content/docs/ru/zen.mdx index 3f22dbdc7e..f15845d764 100644 --- a/packages/web/src/content/docs/ru/zen.mdx +++ b/packages/web/src/content/docs/ru/zen.mdx @@ -102,9 +102,9 @@ OpenCode Zen работает как любой другой провайдер | Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Grok Build 0.1 | grok-build-0.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| DeepSeek V4 Flash Free | deepseek-v4-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Nemotron 3 Super Free | nemotron-3-super-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| DeepSeek V4 Flash Free | deepseek-v4-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | [идентификатор модели](/docs/config/#models) в вашей конфигурации OpenCode использует формат `opencode/`. Например, для GPT 5.5 вам нужно diff --git a/packages/web/src/content/docs/th/go.mdx b/packages/web/src/content/docs/th/go.mdx index bafc0bfa0d..a6a0d736de 100644 --- a/packages/web/src/content/docs/th/go.mdx +++ b/packages/web/src/content/docs/th/go.mdx @@ -57,6 +57,7 @@ OpenCode Go ทำงานเหมือนกับผู้ให้บร - **MiMo-V2.5-Pro** - **MiniMax M2.5** - **MiniMax M2.7** +- **MiniMax M3** - **Qwen3.6 Plus** - **Qwen3.7 Max** - **DeepSeek V4 Pro** @@ -86,6 +87,7 @@ OpenCode Go มีขีดจำกัดดังต่อไปนี้: | Kimi K2.6 | 1,150 | 2,880 | 5,750 | | MiMo-V2.5 | 30,100 | 75,200 | 150,400 | | MiMo-V2.5-Pro | 3,250 | 8,150 | 16,300 | +| MiniMax M3 | 1,400 | 3,500 | 7,000 | | MiniMax M2.7 | 3,400 | 8,500 | 17,000 | | MiniMax M2.5 | 6,300 | 15,900 | 31,800 | | Qwen3.7 Max | 950 | 2,390 | 4,770 | @@ -99,6 +101,7 @@ OpenCode Go มีขีดจำกัดดังต่อไปนี้: - Kimi K2.5/K2.6 — 870 input, 55,000 cached, 200 output tokens ต่อ request - DeepSeek V4 Pro — 750 input, 82,000 cached, 290 output tokens ต่อ request - DeepSeek V4 Flash — 790 input, 68,000 cached, 280 output tokens ต่อ request +- MiniMax M3 — 810 input, 62,000 cached, 225 output tokens ต่อ request - MiniMax M2.7/M2.5 — 300 input, 55,000 cached, 125 output tokens ต่อ request - Qwen3.7 Max — 420 input, 66,000 cached, 200 output tokens ต่อ request - Qwen3.6 Plus — 500 input, 57,000 cached, 190 output tokens ต่อ request @@ -115,6 +118,7 @@ OpenCode Go มีขีดจำกัดดังต่อไปนี้: | Kimi K2.5 | $0.60 | $3.00 | $0.10 | - | | MiMo V2.5 | $0.14 | $0.28 | $0.0028 | - | | MiMo V2.5 Pro | $1.74 | $3.48 | $0.0145 | - | +| MiniMax M3 | $0.60 | $2.40 | $0.12 | $0.75 | | MiniMax M2.7 | $0.30 | $1.20 | $0.06 | $0.375 | | MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 | | Qwen3.7 Max | $2.50 | $7.50 | $0.50 | $3.125 | @@ -152,6 +156,7 @@ OpenCode Go มีขีดจำกัดดังต่อไปนี้: | DeepSeek V4 Flash | deepseek-v4-flash | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiMo-V2.5 | mimo-v2.5 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiMo-V2.5-Pro | mimo-v2.5-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| MiniMax M3 | minimax-m3 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.7 Max | qwen3.7-max | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | diff --git a/packages/web/src/content/docs/th/zen.mdx b/packages/web/src/content/docs/th/zen.mdx index 38082a7482..ecfc59b3fb 100644 --- a/packages/web/src/content/docs/th/zen.mdx +++ b/packages/web/src/content/docs/th/zen.mdx @@ -95,9 +95,9 @@ OpenCode Zen ทำงานเหมือน provider อื่น ๆ ใน | Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Grok Build 0.1 | grok-build-0.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| DeepSeek V4 Flash Free | deepseek-v4-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Nemotron 3 Super Free | nemotron-3-super-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| DeepSeek V4 Flash Free | deepseek-v4-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | [model id](/docs/config/#models) ใน OpenCode config ของคุณใช้รูปแบบ `opencode/` ตัวอย่างเช่น สำหรับ GPT 5.5 คุณจะใช้ `opencode/gpt-5.5` ใน config ของคุณ diff --git a/packages/web/src/content/docs/tr/go.mdx b/packages/web/src/content/docs/tr/go.mdx index 1fe63c84fd..58b438c039 100644 --- a/packages/web/src/content/docs/tr/go.mdx +++ b/packages/web/src/content/docs/tr/go.mdx @@ -57,6 +57,7 @@ Mevcut model listesi şunları içerir: - **MiMo-V2.5-Pro** - **MiniMax M2.5** - **MiniMax M2.7** +- **MiniMax M3** - **Qwen3.6 Plus** - **Qwen3.7 Max** - **DeepSeek V4 Pro** @@ -86,6 +87,7 @@ Aşağıdaki tablo, tipik Go kullanım modellerine dayalı tahmini bir istek say | Kimi K2.6 | 1,150 | 2,880 | 5,750 | | MiMo-V2.5 | 30,100 | 75,200 | 150,400 | | MiMo-V2.5-Pro | 3,250 | 8,150 | 16,300 | +| MiniMax M3 | 1,400 | 3,500 | 7,000 | | MiniMax M2.7 | 3,400 | 8,500 | 17,000 | | MiniMax M2.5 | 6,300 | 15,900 | 31,800 | | Qwen3.7 Max | 950 | 2,390 | 4,770 | @@ -99,6 +101,7 @@ Tahminler, gözlemlenen ortalama istek modellerine dayanır: - Kimi K2.5/K2.6 — İstek başına 870 girdi, 55.000 önbelleğe alınmış, 200 çıktı token'ı - DeepSeek V4 Pro — İstek başına 750 girdi, 82.000 önbelleğe alınmış, 290 çıktı token'ı - DeepSeek V4 Flash — İstek başına 790 girdi, 68.000 önbelleğe alınmış, 280 çıktı token'ı +- MiniMax M3 — İstek başına 810 girdi, 62.000 önbelleğe alınmış, 225 çıktı token'ı - MiniMax M2.7/M2.5 — İstek başına 300 girdi, 55.000 önbelleğe alınmış, 125 çıktı token'ı - Qwen3.7 Max — İstek başına 420 girdi, 66.000 önbelleğe alınmış, 200 çıktı token'ı - Qwen3.6 Plus — İstek başına 500 girdi, 57.000 önbelleğe alınmış, 190 çıktı token'ı @@ -115,6 +118,7 @@ Tahminler ayrıca 1M token başına aşağıdaki fiyatlara da dayanır: | Kimi K2.5 | $0.60 | $3.00 | $0.10 | - | | MiMo V2.5 | $0.14 | $0.28 | $0.0028 | - | | MiMo V2.5 Pro | $1.74 | $3.48 | $0.0145 | - | +| MiniMax M3 | $0.60 | $2.40 | $0.12 | $0.75 | | MiniMax M2.7 | $0.30 | $1.20 | $0.06 | $0.375 | | MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 | | Qwen3.7 Max | $2.50 | $7.50 | $0.50 | $3.125 | @@ -152,6 +156,7 @@ Go modellerine aşağıdaki API uç noktaları aracılığıyla da erişebilirsi | DeepSeek V4 Flash | deepseek-v4-flash | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiMo-V2.5 | mimo-v2.5 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiMo-V2.5-Pro | mimo-v2.5-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| MiniMax M3 | minimax-m3 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.7 Max | qwen3.7-max | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | diff --git a/packages/web/src/content/docs/tr/zen.mdx b/packages/web/src/content/docs/tr/zen.mdx index 6118ff2ee3..419172318c 100644 --- a/packages/web/src/content/docs/tr/zen.mdx +++ b/packages/web/src/content/docs/tr/zen.mdx @@ -93,9 +93,9 @@ Modellerimize aşağıdaki API uç noktaları aracılığıyla da erişebilirsin | Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Grok Build 0.1 | grok-build-0.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| DeepSeek V4 Flash Free | deepseek-v4-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Nemotron 3 Super Free | nemotron-3-super-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| DeepSeek V4 Flash Free | deepseek-v4-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | OpenCode yapılandırmanızdaki [model id](/docs/config/#models) `opencode/` biçimini kullanır. Örneğin, GPT 5.5 için yapılandırmanızda `opencode/gpt-5.5` kullanırsınız. diff --git a/packages/web/src/content/docs/zen.mdx b/packages/web/src/content/docs/zen.mdx index e16b36d366..e300d70758 100644 --- a/packages/web/src/content/docs/zen.mdx +++ b/packages/web/src/content/docs/zen.mdx @@ -102,9 +102,9 @@ You can also access our models through the following API endpoints. | Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Grok Build 0.1 | grok-build-0.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| DeepSeek V4 Flash Free | deepseek-v4-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Nemotron 3 Super Free | nemotron-3-super-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| DeepSeek V4 Flash Free | deepseek-v4-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | The [model id](/docs/config/#models) in your OpenCode config uses the format `opencode/`. For example, for GPT 5.5, you would diff --git a/packages/web/src/content/docs/zh-cn/go.mdx b/packages/web/src/content/docs/zh-cn/go.mdx index 9d57a765bd..cf8fa6506c 100644 --- a/packages/web/src/content/docs/zh-cn/go.mdx +++ b/packages/web/src/content/docs/zh-cn/go.mdx @@ -57,6 +57,7 @@ OpenCode Go 的工作方式与 OpenCode 中的其他提供商一样。 - **MiMo-V2.5-Pro** - **MiniMax M2.5** - **MiniMax M2.7** +- **MiniMax M3** - **Qwen3.6 Plus** - **Qwen3.7 Max** - **DeepSeek V4 Pro** @@ -86,6 +87,7 @@ OpenCode Go 包含以下限制: | Kimi K2.5 | 1,850 | 4,630 | 9,250 | | MiMo-V2.5 | 30,100 | 75,200 | 150,400 | | MiMo-V2.5-Pro | 3,250 | 8,150 | 16,300 | +| MiniMax M3 | 1,400 | 3,500 | 7,000 | | MiniMax M2.7 | 3,400 | 8,500 | 17,000 | | MiniMax M2.5 | 6,300 | 15,900 | 31,800 | | Qwen3.7 Max | 950 | 2,390 | 4,770 | @@ -101,6 +103,7 @@ OpenCode Go 包含以下限制: - DeepSeek V4 Flash — 每次请求 790 个输入 token,68,000 个缓存 token,280 个输出 token - MiMo-V2.5 — 每次请求 830 个输入 token,71,500 个缓存 token,295 个输出 token - MiMo-V2.5-Pro — 每次请求 790 个输入 token,86,000 个缓存 token,305 个输出 token +- MiniMax M3 — 每次请求 810 个输入 token,62,000 个缓存 token,225 个输出 token - MiniMax M2.7/M2.5 — 每次请求 300 个输入 token,55,000 个缓存 token,125 个输出 token - Qwen3.7 Max — 每次请求 420 个输入 token,66,000 个缓存 token,200 个输出 token - Qwen3.6 Plus — 每次请求 500 个输入 token,57,000 个缓存 token,190 个输出 token @@ -115,6 +118,7 @@ OpenCode Go 包含以下限制: | Kimi K2.5 | $0.60 | $3.00 | $0.10 | - | | MiMo V2.5 | $0.14 | $0.28 | $0.0028 | - | | MiMo V2.5 Pro | $1.74 | $3.48 | $0.0145 | - | +| MiniMax M3 | $0.60 | $2.40 | $0.12 | $0.75 | | MiniMax M2.7 | $0.30 | $1.20 | $0.06 | $0.375 | | MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 | | Qwen3.7 Max | $2.50 | $7.50 | $0.50 | $3.125 | @@ -152,6 +156,7 @@ OpenCode Go 包含以下限制: | DeepSeek V4 Flash | deepseek-v4-flash | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiMo-V2.5 | mimo-v2.5 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiMo-V2.5-Pro | mimo-v2.5-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| MiniMax M3 | minimax-m3 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.7 Max | qwen3.7-max | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | diff --git a/packages/web/src/content/docs/zh-cn/zen.mdx b/packages/web/src/content/docs/zh-cn/zen.mdx index 6b1c7d766e..368f2234e2 100644 --- a/packages/web/src/content/docs/zh-cn/zen.mdx +++ b/packages/web/src/content/docs/zh-cn/zen.mdx @@ -93,9 +93,9 @@ OpenCode Zen 的工作方式与 OpenCode 中的任何其他提供商相同。 | Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Grok Build 0.1 | grok-build-0.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| DeepSeek V4 Flash Free | deepseek-v4-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Nemotron 3 Super Free | nemotron-3-super-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| DeepSeek V4 Flash Free | deepseek-v4-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | 在你的 OpenCode 配置中,[模型 ID](/docs/config/#models) 使用 `opencode/` 格式。例如,对于 GPT 5.5,你需要在配置中使用 `opencode/gpt-5.5`。 diff --git a/packages/web/src/content/docs/zh-tw/go.mdx b/packages/web/src/content/docs/zh-tw/go.mdx index efe050dbaf..140830a4bb 100644 --- a/packages/web/src/content/docs/zh-tw/go.mdx +++ b/packages/web/src/content/docs/zh-tw/go.mdx @@ -57,6 +57,7 @@ OpenCode Go 的運作方式與 OpenCode 中的任何其他供應商相同。 - **MiMo-V2.5-Pro** - **MiniMax M2.5** - **MiniMax M2.7** +- **MiniMax M3** - **Qwen3.6 Plus** - **Qwen3.7 Max** - **DeepSeek V4 Pro** @@ -86,6 +87,7 @@ OpenCode Go 包含以下限制: | Kimi K2.6 | 1,150 | 2,880 | 5,750 | | MiMo-V2.5 | 30,100 | 75,200 | 150,400 | | MiMo-V2.5-Pro | 3,250 | 8,150 | 16,300 | +| MiniMax M3 | 1,400 | 3,500 | 7,000 | | MiniMax M2.7 | 3,400 | 8,500 | 17,000 | | MiniMax M2.5 | 6,300 | 15,900 | 31,800 | | Qwen3.7 Max | 950 | 2,390 | 4,770 | @@ -99,6 +101,7 @@ OpenCode Go 包含以下限制: - Kimi K2.5/K2.6 — 每次請求 870 個輸入 token、55,000 個快取 token、200 個輸出 token - DeepSeek V4 Pro — 每次請求 750 個輸入 token、82,000 個快取 token、290 個輸出 token - DeepSeek V4 Flash — 每次請求 790 個輸入 token、68,000 個快取 token、280 個輸出 token +- MiniMax M3 — 每次請求 810 個輸入 token、62,000 個快取 token、225 個輸出 token - MiniMax M2.7/M2.5 — 每次請求 300 個輸入 token、55,000 個快取 token、125 個輸出 token - Qwen3.7 Max — 每次請求 420 個輸入 token、66,000 個快取 token、200 個輸出 token - Qwen3.6 Plus — 每次請求 500 個輸入 token、57,000 個快取 token、190 個輸出 token @@ -115,6 +118,7 @@ OpenCode Go 包含以下限制: | Kimi K2.5 | $0.60 | $3.00 | $0.10 | - | | MiMo V2.5 | $0.14 | $0.28 | $0.0028 | - | | MiMo V2.5 Pro | $1.74 | $3.48 | $0.0145 | - | +| MiniMax M3 | $0.60 | $2.40 | $0.12 | $0.75 | | MiniMax M2.7 | $0.30 | $1.20 | $0.06 | $0.375 | | MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 | | Qwen3.7 Max | $2.50 | $7.50 | $0.50 | $3.125 | @@ -152,6 +156,7 @@ OpenCode Go 包含以下限制: | DeepSeek V4 Flash | deepseek-v4-flash | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiMo-V2.5 | mimo-v2.5 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiMo-V2.5-Pro | mimo-v2.5-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| MiniMax M3 | minimax-m3 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.7 Max | qwen3.7-max | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | diff --git a/packages/web/src/content/docs/zh-tw/zen.mdx b/packages/web/src/content/docs/zh-tw/zen.mdx index 00c65b0783..c806555eab 100644 --- a/packages/web/src/content/docs/zh-tw/zen.mdx +++ b/packages/web/src/content/docs/zh-tw/zen.mdx @@ -97,9 +97,9 @@ OpenCode Zen 的運作方式和 OpenCode 中的其他供應商一樣。 | Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Grok Build 0.1 | grok-build-0.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| DeepSeek V4 Flash Free | deepseek-v4-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Nemotron 3 Super Free | nemotron-3-super-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| DeepSeek V4 Flash Free | deepseek-v4-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | OpenCode 設定中的 [模型 ID](/docs/config/#models) 會使用 `opencode/` 格式。例如,如果是 GPT 5.5,你會在設定中使用 `opencode/gpt-5.5`。 From 917a36abc312613d39f9c0c5a023d0ca29e6b588 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Sun, 31 May 2026 22:59:44 -0500 Subject: [PATCH 044/840] refactor(opencode): inline local provider helpers (#30169) --- packages/opencode/src/provider/provider.ts | 34 ++++++++-------------- 1 file changed, 12 insertions(+), 22 deletions(-) diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 1806fb6a7f..915ed75fc2 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -32,11 +32,6 @@ import { ProviderError } from "./error" const log = Log.create({ service: "provider" }) const OPENAI_HEADER_TIMEOUT_DEFAULT = 10_000 -function shouldUseCopilotResponsesApi(modelID: string): boolean { - const match = /^gpt-(\d+)/.exec(modelID) - if (!match) return false - return Number(match[1]) >= 5 && !modelID.startsWith("gpt-5-mini") -} function wrapSSE(res: Response, ms: number, ctl: AbortController) { if (typeof ms !== "number" || ms <= 0) return res @@ -152,10 +147,6 @@ type CustomDep = { get: (key: string) => Effect.Effect } -function useLanguageModel(sdk: any) { - return sdk.responses === undefined && sdk.chat === undefined -} - function selectAzureLanguageModel(sdk: any, modelID: string, useChat: boolean) { if (useChat && sdk.chat) return sdk.chat(modelID) if (sdk.responses) return sdk.responses(modelID) @@ -218,8 +209,10 @@ function custom(dep: CustomDep): Record { Effect.succeed({ autoload: false, async getModel(sdk: any, modelID: string, _options?: Record) { - if (useLanguageModel(sdk)) return sdk.languageModel(modelID) - return shouldUseCopilotResponsesApi(modelID) ? sdk.responses(modelID) : sdk.chat(modelID) + if (sdk.responses === undefined && sdk.chat === undefined) return sdk.languageModel(modelID) + const match = /^gpt-(\d+)/.exec(modelID) + if (match && Number(match[1]) >= 5 && !modelID.startsWith("gpt-5-mini")) return sdk.responses(modelID) + return sdk.chat(modelID) }, options: {}, }), @@ -1171,18 +1164,15 @@ export function fromModelsDevProvider(provider: ModelsDev.Provider): Info { } } -function suggestionModelIDs(provider: Info | undefined, enableExperimentalModels: boolean) { - if (!provider) return [] - return Object.keys(provider.models).filter((id) => { - const model = provider.models[id] - if (model.status === "deprecated") return false - if (model.status === "alpha" && !enableExperimentalModels) return false - return true - }) -} - function modelSuggestions(provider: Info | undefined, modelID: ProviderV2.ModelID, enableExperimentalModels: boolean) { - const available = suggestionModelIDs(provider, enableExperimentalModels) + const available = provider + ? Object.keys(provider.models).filter((id) => { + const model = provider.models[id] + if (model.status === "deprecated") return false + if (model.status === "alpha" && !enableExperimentalModels) return false + return true + }) + : [] const fuzzy = fuzzysort.go(modelID, available, { limit: 3, threshold: -10000 }).map((m) => m.target) if (fuzzy.length) return fuzzy const query = modelID From a9c115c220b50b7237941b3c2b72ba29bbc7fee6 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Sun, 31 May 2026 23:17:57 -0500 Subject: [PATCH 045/840] refactor(opencode): simplify provider setup flow (#30173) --- packages/opencode/src/provider/provider.ts | 54 ++++++++-------------- 1 file changed, 20 insertions(+), 34 deletions(-) diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 915ed75fc2..ad860be92a 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -3,13 +3,13 @@ import fuzzysort from "fuzzysort" import { Config } from "@/config/config" import { mapValues, mergeDeep, omit, pickBy, sortBy } from "remeda" import { NoSuchModelError, type Provider as SDK } from "ai" -import * as Log from "@opencode-ai/core/util/log" +import { Log } from "@opencode-ai/core/util/log" import { Npm } from "@opencode-ai/core/npm" import { Hash } from "@opencode-ai/core/util/hash" import { Plugin } from "../plugin" import { serviceUse } from "@opencode-ai/core/effect/service-use" import { type LanguageModelV3 } from "@ai-sdk/provider" -import * as ModelsDev from "@opencode-ai/core/models-dev" +import { ModelsDev } from "@opencode-ai/core/models-dev" import { Auth } from "../auth" import { Env } from "../env" import { InstallationVersion } from "@opencode-ai/core/installation/version" @@ -24,7 +24,7 @@ import { EffectPromise } from "@/effect/promise" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { isRecord } from "@/util/record" import { optionalOmitUndefined } from "@opencode-ai/core/schema" -import * as ProviderTransform from "./transform" +import { ProviderTransform } from "./transform" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelStatus } from "./model-status" import { RuntimeFlags } from "@/effect/runtime-flags" @@ -575,11 +575,7 @@ function custom(dep: CustomDep): Record { const instanceUrl = (yield* dep.get("GITLAB_INSTANCE_URL")) || "https://gitlab.com" const auth = yield* dep.auth(input.id) - const apiKey = yield* Effect.sync(() => { - if (auth?.type === "oauth") return auth.access - if (auth?.type === "api") return auth.key - return undefined - }) + const apiKey = auth?.type === "oauth" ? auth.access : auth?.type === "api" ? auth.key : undefined const token = apiKey ?? (yield* dep.get("GITLAB_TOKEN")) const providerConfig = (yield* dep.config()).provider?.["gitlab"] @@ -727,12 +723,7 @@ function custom(dep: CustomDep): Record { }, } - const apiKey = yield* Effect.gen(function* () { - const envToken = env["CLOUDFLARE_API_KEY"] - if (envToken) return envToken - if (auth?.type === "api") return auth.key - return undefined - }) + const apiKey = env["CLOUDFLARE_API_KEY"] || (auth?.type === "api" ? auth.key : undefined) return { autoload: !!apiKey, @@ -778,12 +769,8 @@ function custom(dep: CustomDep): Record { } // Get API token from env or auth - required for authenticated gateways - const apiToken = yield* Effect.gen(function* () { - const envToken = env["CLOUDFLARE_API_TOKEN"] || env["CF_AIG_TOKEN"] - if (envToken) return envToken - if (auth?.type === "api") return auth.key - return undefined - }) + const apiToken = + env["CLOUDFLARE_API_TOKEN"] || env["CF_AIG_TOKEN"] || (auth?.type === "api" ? auth.key : undefined) if (!apiToken) { throw new Error( @@ -1671,15 +1658,15 @@ export const layer = Layer.effect( return loaded as SDK } - let installedPath: string - if (!model.api.npm.startsWith("file://")) { + const installedPath = await (async () => { + if (model.api.npm.startsWith("file://")) { + log.info("loading local provider", { pkg: model.api.npm }) + return model.api.npm + } const item = await Npm.add(model.api.npm) if (!item.entrypoint) throw new Error(`Package ${model.api.npm} has no import entrypoint`) - installedPath = item.entrypoint - } else { - log.info("loading local provider", { pkg: model.api.npm }) - installedPath = model.api.npm - } + return item.entrypoint + })() // `installedPath` is a local entry path or an existing `file://` URL. Normalize // only path inputs so Node on Windows accepts the dynamic import. @@ -1778,7 +1765,7 @@ export const layer = Layer.effect( const provider = s.providers[providerID] if (!provider) return undefined - let priority = [ + const defaultPriority = [ "claude-haiku-4-5", "claude-haiku-4.5", "3-5-haiku", @@ -1787,12 +1774,11 @@ export const layer = Layer.effect( "gemini-2.5-flash", "gpt-5-nano", ] - if (providerID.startsWith("opencode")) { - priority = ["gpt-5-nano"] - } - if (providerID.startsWith("github-copilot")) { - priority = ["gpt-5-mini", "claude-haiku-4.5", ...priority] - } + const priority = providerID.startsWith("opencode") + ? ["gpt-5-nano"] + : providerID.startsWith("github-copilot") + ? ["gpt-5-mini", "claude-haiku-4.5", ...defaultPriority] + : defaultPriority for (const item of priority) { if (providerID === ProviderV2.ID.amazonBedrock) { const crossRegionPrefixes = ["global.", "us.", "eu."] From 2725eed9922ff4d9f3458ace12035d09875b5dc4 Mon Sep 17 00:00:00 2001 From: Michael Hart Date: Mon, 1 Jun 2026 14:27:03 +1000 Subject: [PATCH 046/840] fix(app): show project sessions before path sync resolves (#30167) Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com> --- .../session-list-path-loading.spec.ts | 40 +++++++++++++++++++ .../context/global-sync/child-store.test.ts | 31 +++++++++++++- .../src/context/global-sync/child-store.ts | 5 +-- 3 files changed, 72 insertions(+), 4 deletions(-) create mode 100644 packages/app/e2e/regression/session-list-path-loading.spec.ts diff --git a/packages/app/e2e/regression/session-list-path-loading.spec.ts b/packages/app/e2e/regression/session-list-path-loading.spec.ts new file mode 100644 index 0000000000..1dbc0575f1 --- /dev/null +++ b/packages/app/e2e/regression/session-list-path-loading.spec.ts @@ -0,0 +1,40 @@ +import { expect, test } from "@playwright/test" +import { fixture, pageMessages } from "../smoke/session-timeline.fixture" +import { mockOpenCodeServer } from "../utils/mock-server" + +test("shows loaded sessions before the directory path request resolves", async ({ page }) => { + await mockOpenCodeServer(page, { + sessions: fixture.sessions, + provider: fixture.provider, + directory: fixture.directory, + project: fixture.project, + pageMessages, + }) + + let releasePath!: () => void + const pathBlocked = new Promise((resolve) => { + releasePath = resolve + }) + await page.route("**/path?*", async (route) => { + if (!new URL(route.request().url()).searchParams.has("directory")) return route.fallback() + await pathBlocked + return route.fallback() + }) + + await page.addInitScript((directory) => { + localStorage.setItem( + "opencode.global.dat:server", + JSON.stringify({ + projects: { local: [{ worktree: directory, expanded: true }] }, + lastProject: { local: directory }, + }), + ) + }, fixture.directory) + + await page.goto("/") + try { + await expect(page.getByText(fixture.expected.sourceTitle).first()).toBeVisible({ timeout: 5_000 }) + } finally { + releasePath() + } +}) diff --git a/packages/app/src/context/global-sync/child-store.test.ts b/packages/app/src/context/global-sync/child-store.test.ts index 7c4adb5216..19d43746b1 100644 --- a/packages/app/src/context/global-sync/child-store.test.ts +++ b/packages/app/src/context/global-sync/child-store.test.ts @@ -52,7 +52,7 @@ beforeAll(async () => { useQueries: (options: () => { queries: Array<{ enabled?: boolean }> }) => { queryGroups.push(options) return [ - { isLoading: false, data: { state: "", config: "", worktree: "", directory: "", home: "" } }, + { isLoading: true, data: undefined }, { isLoading: false, data: {} }, { isLoading: false, data: [] }, { isLoading: false, data: provider }, @@ -128,6 +128,35 @@ describe("createChildStoreManager", () => { } }) + test("provides the requested directory while the path query is pending", () => { + let manager: ReturnType | undefined + + const dispose = createOwner((owner) => { + manager = createChildStoreManager({ + owner, + isBooting: () => false, + isLoadingSessions: () => false, + onBootstrap() {}, + onMcp() {}, + onDispose() {}, + translate: (key) => key, + queryOptions: queryOptionsApi, + global: { provider }, + }) + }) + + try { + if (!manager) throw new Error("manager required") + + const [store] = manager.child("/project", { bootstrap: false }) + + expect(store.path.directory).toBe("/project") + expect(store.path.worktree).toBe("") + } finally { + dispose() + } + }) + test("enables MCP only when requested for the directory", () => { let manager: ReturnType | undefined const offset = queryGroups.length diff --git a/packages/app/src/context/global-sync/child-store.ts b/packages/app/src/context/global-sync/child-store.ts index 99da39ebb0..b7b1d72ac0 100644 --- a/packages/app/src/context/global-sync/child-store.ts +++ b/packages/app/src/context/global-sync/child-store.ts @@ -204,9 +204,8 @@ export function createChildStoreManager(input: { }, config: {}, get path() { - if (pathQuery.isLoading || !pathQuery.data) - return { state: "", config: "", worktree: "", directory: "", home: "" } - return pathQuery.data + if (pathQuery.data) return pathQuery.data + return { state: "", config: "", worktree: "", directory, home: "" } }, status: "loading" as const, agent: [], From b258a55a6c61ac5d5158a83f9acd11b15700b4d8 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Sun, 31 May 2026 23:50:43 -0500 Subject: [PATCH 047/840] fix(core): preserve session metadata migration identity (#30176) --- .../migration.sql | 1 + .../snapshot.json | 0 .../migration.sql | 1 - packages/core/src/database/migration.gen.ts | 2 +- .../20260511173437_session-metadata.ts | 16 ++++++++ .../20260530232709_lovely_romulus.ts | 11 ------ packages/core/test/database-migration.test.ts | 39 +++++++++++++++++++ 7 files changed, 57 insertions(+), 13 deletions(-) create mode 100644 packages/core/migration/20260511173437_session-metadata/migration.sql rename packages/core/migration/{20260530232709_lovely_romulus => 20260511173437_session-metadata}/snapshot.json (100%) delete mode 100644 packages/core/migration/20260530232709_lovely_romulus/migration.sql create mode 100644 packages/core/src/database/migration/20260511173437_session-metadata.ts delete mode 100644 packages/core/src/database/migration/20260530232709_lovely_romulus.ts diff --git a/packages/core/migration/20260511173437_session-metadata/migration.sql b/packages/core/migration/20260511173437_session-metadata/migration.sql new file mode 100644 index 0000000000..1f8fcaf64a --- /dev/null +++ b/packages/core/migration/20260511173437_session-metadata/migration.sql @@ -0,0 +1 @@ +ALTER TABLE `session` ADD `metadata` text; diff --git a/packages/core/migration/20260530232709_lovely_romulus/snapshot.json b/packages/core/migration/20260511173437_session-metadata/snapshot.json similarity index 100% rename from packages/core/migration/20260530232709_lovely_romulus/snapshot.json rename to packages/core/migration/20260511173437_session-metadata/snapshot.json diff --git a/packages/core/migration/20260530232709_lovely_romulus/migration.sql b/packages/core/migration/20260530232709_lovely_romulus/migration.sql deleted file mode 100644 index 0ce73631f0..0000000000 --- a/packages/core/migration/20260530232709_lovely_romulus/migration.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE `session` ADD `metadata` text; \ No newline at end of file diff --git a/packages/core/src/database/migration.gen.ts b/packages/core/src/database/migration.gen.ts index 1a6918b33b..ee76318488 100644 --- a/packages/core/src/database/migration.gen.ts +++ b/packages/core/src/database/migration.gen.ts @@ -22,6 +22,6 @@ export const migrations = ( import("./migration/20260507164347_add_workspace_time"), import("./migration/20260510033149_session_usage"), import("./migration/20260511000411_data_migration_state"), - import("./migration/20260530232709_lovely_romulus"), + import("./migration/20260511173437_session-metadata"), ]) ).map((module) => module.default) satisfies DatabaseMigration.Migration[] diff --git a/packages/core/src/database/migration/20260511173437_session-metadata.ts b/packages/core/src/database/migration/20260511173437_session-metadata.ts new file mode 100644 index 0000000000..413f086671 --- /dev/null +++ b/packages/core/src/database/migration/20260511173437_session-metadata.ts @@ -0,0 +1,16 @@ +import { Effect } from "effect" +import type { DatabaseMigration } from "../migration" + +export default { + id: "20260511173437_session-metadata", + up(tx) { + return Effect.gen(function* () { + // This column briefly shipped again under 20260530232709_lovely_romulus. + if ( + (yield* tx.all<{ name: string }>(`PRAGMA table_info(\`session\`)`)).some((column) => column.name === "metadata") + ) + return + yield* tx.run(`ALTER TABLE \`session\` ADD \`metadata\` text;`) + }) + }, +} satisfies DatabaseMigration.Migration diff --git a/packages/core/src/database/migration/20260530232709_lovely_romulus.ts b/packages/core/src/database/migration/20260530232709_lovely_romulus.ts deleted file mode 100644 index 2fe023543d..0000000000 --- a/packages/core/src/database/migration/20260530232709_lovely_romulus.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Effect } from "effect" -import type { DatabaseMigration } from "../migration" - -export default { - id: "20260530232709_lovely_romulus", - up(tx) { - return Effect.gen(function* () { - yield* tx.run(`ALTER TABLE \`session\` ADD \`metadata\` text;`) - }) - }, -} satisfies DatabaseMigration.Migration diff --git a/packages/core/test/database-migration.test.ts b/packages/core/test/database-migration.test.ts index 5b0b08c962..316974de8c 100644 --- a/packages/core/test/database-migration.test.ts +++ b/packages/core/test/database-migration.test.ts @@ -7,6 +7,7 @@ import { Effect } from "effect" import { sql } from "drizzle-orm" import { DatabaseMigration } from "@opencode-ai/core/database/migration" import sessionUsageMigration from "@opencode-ai/core/database/migration/20260510033149_session_usage" +import sessionMetadataMigration from "@opencode-ai/core/database/migration/20260511173437_session-metadata" import type { SqlClient as SqlClientService } from "effect/unstable/sql/SqlClient" const run = (effect: Effect.Effect) => @@ -89,6 +90,44 @@ describe("DatabaseMigration", () => { ) }) + test("does not replay a migrated session metadata column", async () => { + await run( + Effect.gen(function* () { + const db = yield* makeDb + yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY, metadata text)`) + yield* db.run( + sql`CREATE TABLE __drizzle_migrations (id INTEGER PRIMARY KEY, hash text NOT NULL, created_at numeric, name text, applied_at TEXT)`, + ) + yield* db.run(sql` + INSERT INTO __drizzle_migrations (hash, created_at, name, applied_at) + VALUES ('hash', 1, '20260511173437_session-metadata', ${new Date().toISOString()}) + `) + + yield* DatabaseMigration.applyOnly(db, [sessionMetadataMigration]) + + expect(yield* db.all(sql`SELECT id FROM migration`)).toEqual([{ id: "20260511173437_session-metadata" }]) + }), + ) + }) + + test("accepts the temporary replacement session metadata migration id", async () => { + await run( + Effect.gen(function* () { + const db = yield* makeDb + yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY, metadata text)`) + yield* db.run(sql`CREATE TABLE migration (id TEXT PRIMARY KEY, time_completed INTEGER NOT NULL)`) + yield* db.run(sql`INSERT INTO migration (id, time_completed) VALUES ('20260530232709_lovely_romulus', 1)`) + + yield* DatabaseMigration.applyOnly(db, [sessionMetadataMigration]) + + expect(yield* db.all(sql`SELECT id FROM migration ORDER BY id`)).toEqual([ + { id: "20260511173437_session-metadata" }, + { id: "20260530232709_lovely_romulus" }, + ]) + }), + ) + }) + test("skips drizzle import when migration table already has state", async () => { await run( Effect.gen(function* () { From f9ba23ab62eabea5031495247e5eb5e2d823fea3 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Mon, 1 Jun 2026 00:00:40 -0500 Subject: [PATCH 048/840] refactor(session): align namespace imports and inline trivial helpers (#30180) --- packages/opencode/src/session/compaction.ts | 4 ++-- packages/opencode/src/session/instruction.ts | 12 +++++------- packages/opencode/src/session/llm.ts | 2 +- packages/opencode/src/session/message-v2.ts | 2 +- packages/opencode/src/session/processor.ts | 8 +++----- packages/opencode/src/session/prompt.ts | 11 ++++------- packages/opencode/src/session/reminders.ts | 2 +- packages/opencode/src/session/revert.ts | 4 ++-- packages/opencode/src/session/run-state.ts | 2 +- packages/opencode/src/session/session.ts | 8 ++------ packages/opencode/src/session/summary.ts | 2 +- packages/opencode/src/session/tools.ts | 4 ++-- 12 files changed, 25 insertions(+), 36 deletions(-) diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index ceb78e6321..c687df59bb 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -1,10 +1,10 @@ import { SessionLegacy } from "@opencode-ai/core/session/legacy" -import * as Session from "./session" +import { Session } from "./session" import { SessionID, MessageID, PartID } from "./schema" import { Provider } from "@/provider/provider" import { MessageV2 } from "./message-v2" import { Token } from "@/util/token" -import * as Log from "@opencode-ai/core/util/log" +import { Log } from "@opencode-ai/core/util/log" import { SessionProcessor } from "./processor" import { Agent } from "@/agent/agent" import { Plugin } from "@/plugin" diff --git a/packages/opencode/src/session/instruction.ts b/packages/opencode/src/session/instruction.ts index 855c58ba5c..cae261e72b 100644 --- a/packages/opencode/src/session/instruction.ts +++ b/packages/opencode/src/session/instruction.ts @@ -12,12 +12,6 @@ import { Global } from "@opencode-ai/core/global" import type { MessageV2 } from "./message-v2" import type { MessageID } from "./schema" -const files = (disableClaudeCodePrompt: boolean) => [ - "AGENTS.md", - ...(disableClaudeCodePrompt ? [] : ["CLAUDE.md"]), - "CONTEXT.md", // deprecated -] - function extract(messages: SessionLegacy.WithParts[]) { const paths = new Set() for (const msg of messages) { @@ -65,7 +59,11 @@ export const layer: Layer.Layer< path.join(global.config, "AGENTS.md"), ...(!flags.disableClaudeCodePrompt ? [path.join(global.home, ".claude", "CLAUDE.md")] : []), ] - const instructionFiles = files(flags.disableClaudeCodePrompt) + const instructionFiles = [ + "AGENTS.md", + ...(!flags.disableClaudeCodePrompt ? ["CLAUDE.md"] : []), + "CONTEXT.md", // deprecated + ] const state = yield* InstanceState.make( Effect.fn("Instruction.state")(() => diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index eb63ee534e..ebaad3e930 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -1,7 +1,7 @@ import { Provider } from "@/provider/provider" import { SessionLegacy } from "@opencode-ai/core/session/legacy" import { serviceUse } from "@opencode-ai/core/effect/service-use" -import * as Log from "@opencode-ai/core/util/log" +import { Log } from "@opencode-ai/core/util/log" import { Context, Effect, Layer } from "effect" import * as Stream from "effect/Stream" import { streamText, wrapLanguageModel, type ModelMessage, type Tool } from "ai" diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 862e06fc21..2884c4cf86 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -30,7 +30,7 @@ import { inArray } from "drizzle-orm" import { lt } from "drizzle-orm" import { or } from "drizzle-orm" import { MessageTable, PartTable, SessionTable } from "@opencode-ai/core/session/sql" -import * as ProviderError from "@/provider/error" +import { ProviderError } from "@/provider/error" import { iife } from "@/util/iife" import { errorMessage } from "@/util/error" import { isMedia } from "@/util/media" diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index f124f7eea4..8f9b83a79c 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -7,7 +7,7 @@ import { Config } from "@/config/config" import { Permission } from "@/permission" import { Plugin } from "@/plugin" import { Snapshot } from "@/snapshot" -import * as Session from "./session" +import { Session } from "./session" import { LLM } from "./llm" import { MessageV2 } from "./message-v2" import { isOverflow } from "./overflow" @@ -19,7 +19,7 @@ import { SessionSummary } from "./summary" import type { Provider } from "@/provider/provider" import { Question } from "@/question" import { errorMessage } from "@/util/error" -import * as Log from "@opencode-ai/core/util/log" +import { Log } from "@opencode-ai/core/util/log" import { isRecord } from "@/util/record" import { EventV2Bridge } from "@/event-v2-bridge" import { Database } from "@opencode-ai/core/database/database" @@ -301,8 +301,6 @@ export const layer = Layer.effect( } } - const toolInput = (value: unknown): Record => (isRecord(value) ? value : { value }) - const handleEvent = Effect.fnUntraced(function* (value: StreamEvent) { switch (value.type) { case "reasoning-start": @@ -380,7 +378,7 @@ export const layer = Layer.effect( throw new Error(`Tool call not allowed while generating summary: ${value.name}`) } const toolCall = yield* ensureToolCall(value) - const input = toolInput(value.input) + const input = isRecord(value.input) ? value.input : { value: value.input } if (!toolCall.call.inputEnded) { // TODO(v2): Temporary dual-write while migrating session messages to v2 events. if (flags.experimentalEventSystem) { diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 36e394f0aa..e7c6a62363 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -3,9 +3,9 @@ import { SessionLegacy } from "@opencode-ai/core/session/legacy" import os from "os" import { SessionID, MessageID, PartID } from "./schema" import { MessageV2 } from "./message-v2" -import * as Log from "@opencode-ai/core/util/log" +import { Log } from "@opencode-ai/core/util/log" import { SessionRevert } from "./revert" -import * as Session from "./session" +import { Session } from "./session" import { Agent } from "../agent/agent" import { Provider } from "@/provider/provider" @@ -148,10 +148,6 @@ export const layer = Layer.effect( const parts: Types.DeepMutable = [{ type: "text", text: template }] const files = ConfigMarkdown.files(template) const seen = new Set() - const mentionSource = (match: RegExpMatchArray) => { - const start = match.index ?? 0 - return { value: match[0], start, end: start + match[0].length } - } yield* Effect.forEach( files, Effect.fnUntraced(function* (match) { @@ -164,7 +160,8 @@ export const layer = Layer.effect( const alias = slash === -1 ? name : name.slice(0, slash) const reference = yield* references.get(alias) if (reference) { - const source = mentionSource(match) + const start = match.index ?? 0 + const source = { value: match[0], start, end: start + match[0].length } if (reference.kind === "invalid") { parts.push( referenceTextPart({ reference, source, target: slash === -1 ? undefined : name.slice(slash + 1) }), diff --git a/packages/opencode/src/session/reminders.ts b/packages/opencode/src/session/reminders.ts index 206304b393..a868a59dd3 100644 --- a/packages/opencode/src/session/reminders.ts +++ b/packages/opencode/src/session/reminders.ts @@ -7,7 +7,7 @@ import { InstanceState } from "@/effect/instance-state" import { RuntimeFlags } from "@/effect/runtime-flags" import { PartID } from "./schema" import { MessageV2 } from "./message-v2" -import * as Session from "./session" +import { Session } from "./session" import PROMPT_PLAN from "./prompt/plan.txt" import BUILD_SWITCH from "./prompt/build-switch.txt" import PLAN_MODE from "./prompt/plan-mode.txt" diff --git a/packages/opencode/src/session/revert.ts b/packages/opencode/src/session/revert.ts index f33330704d..19a3725583 100644 --- a/packages/opencode/src/session/revert.ts +++ b/packages/opencode/src/session/revert.ts @@ -3,8 +3,8 @@ import { SessionLegacy } from "@opencode-ai/core/session/legacy" import { EventV2Bridge } from "@/event-v2-bridge" import { Snapshot } from "../snapshot" import { Storage } from "@/storage/storage" -import * as Log from "@opencode-ai/core/util/log" -import * as Session from "./session" +import { Log } from "@opencode-ai/core/util/log" +import { Session } from "./session" import { MessageV2 } from "./message-v2" import { SessionID, MessageID, PartID } from "./schema" import { SessionRunState } from "./run-state" diff --git a/packages/opencode/src/session/run-state.ts b/packages/opencode/src/session/run-state.ts index 1b92dce682..399a5b604e 100644 --- a/packages/opencode/src/session/run-state.ts +++ b/packages/opencode/src/session/run-state.ts @@ -3,7 +3,7 @@ import { SessionLegacy } from "@opencode-ai/core/session/legacy" import { Runner } from "@/effect/runner" import { BackgroundJob } from "@/background/job" import { Effect, Latch, Layer, Scope, Context } from "effect" -import * as Session from "./session" +import { Session } from "./session" import { MessageV2 } from "./message-v2" import { SessionID } from "./schema" import { SessionStatus } from "./status" diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index bb6f1d6a1d..a8a867a425 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -25,7 +25,7 @@ import { or } from "drizzle-orm" import type { SQL } from "drizzle-orm" import { PartTable, SessionTable } from "@opencode-ai/core/session/sql" import { ProjectTable } from "@opencode-ai/core/project/sql" -import * as Log from "@opencode-ai/core/util/log" +import { Log } from "@opencode-ai/core/util/log" import { MessageV2 } from "./message-v2" import type { InstanceContext } from "../project/instance-context" import { InstanceState } from "@/effect/instance-state" @@ -48,10 +48,6 @@ const runtime = makeRuntime(Database.Service, Database.defaultLayer) const parentTitlePrefix = "New session - " const childTitlePrefix = "Child session - " -function createDefaultTitle(isChild = false) { - return (isChild ? childTitlePrefix : parentTitlePrefix) + new Date().toISOString() -} - export function isDefaultTitle(title: string) { return new RegExp( `^(${parentTitlePrefix}|${childTitlePrefix})\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$`, @@ -581,7 +577,7 @@ export const layer: Layer.Layer< path: input.path, workspaceID: input.workspaceID, parentID: input.parentID, - title: input.title ?? createDefaultTitle(!!input.parentID), + title: input.title ?? (input.parentID ? childTitlePrefix : parentTitlePrefix) + new Date().toISOString(), agent: input.agent, model: input.model, metadata: input.metadata, diff --git a/packages/opencode/src/session/summary.ts b/packages/opencode/src/session/summary.ts index 7dccec1f2e..89652d9a39 100644 --- a/packages/opencode/src/session/summary.ts +++ b/packages/opencode/src/session/summary.ts @@ -2,7 +2,7 @@ import { Effect, Layer, Context, Schema } from "effect" import { SessionLegacy } from "@opencode-ai/core/session/legacy" import { EventV2Bridge } from "@/event-v2-bridge" import { Snapshot } from "@/snapshot" -import * as Session from "./session" +import { Session } from "./session" import { SessionID, MessageID } from "./schema" import { Config } from "@/config/config" diff --git a/packages/opencode/src/session/tools.ts b/packages/opencode/src/session/tools.ts index 20ffb60e13..b91e138eda 100644 --- a/packages/opencode/src/session/tools.ts +++ b/packages/opencode/src/session/tools.ts @@ -14,10 +14,10 @@ import type { TaskPromptOps } from "@/tool/task" import { type Tool as AITool, tool, jsonSchema, type ToolExecutionOptions, asSchema } from "ai" import { Effect } from "effect" import { MessageV2 } from "./message-v2" -import * as Session from "./session" +import { Session } from "./session" import { SessionProcessor } from "./processor" import { PartID } from "./schema" -import * as Log from "@opencode-ai/core/util/log" +import { Log } from "@opencode-ai/core/util/log" import { EffectBridge } from "@/effect/bridge" import { ProviderV2 } from "@opencode-ai/core/provider" From 7ccb7889afb24690c045563e65f8fa9a717878e3 Mon Sep 17 00:00:00 2001 From: Simon Klee Date: Mon, 1 Jun 2026 09:26:09 +0200 Subject: [PATCH 049/840] opencode(run): add queued prompt management (#30103) Direct run mode previously made submitted follow-up prompts irrevocable while a response was still running. Let users edit or remove queued prompts before dispatch without interrupting the active turn. --- .../src/cli/cmd/run/footer.command.tsx | 121 +++++++++++++++- .../src/cli/cmd/run/footer.prompt.tsx | 33 +++-- packages/opencode/src/cli/cmd/run/footer.ts | 41 +++++- .../opencode/src/cli/cmd/run/footer.view.tsx | 109 ++++++++++++++- .../opencode/src/cli/cmd/run/runtime.queue.ts | 92 +++++++++---- .../src/cli/cmd/run/stream.transport.ts | 4 +- packages/opencode/src/cli/cmd/run/types.ts | 14 ++ .../src/cli/cmd/tui/config/keybind.ts | 2 + .../test/cli/run/footer.view.test.tsx | 130 ++++++++++++++++-- .../test/cli/run/runtime.queue.test.ts | 86 ++++++++++++ packages/opencode/test/cli/run/stream.test.ts | 1 + .../test/cli/run/stream.transport.test.ts | 1 + 12 files changed, 570 insertions(+), 64 deletions(-) diff --git a/packages/opencode/src/cli/cmd/run/footer.command.tsx b/packages/opencode/src/cli/cmd/run/footer.command.tsx index bc2434f3e4..d007707e8b 100644 --- a/packages/opencode/src/cli/cmd/run/footer.command.tsx +++ b/packages/opencode/src/cli/cmd/run/footer.command.tsx @@ -5,7 +5,7 @@ import fuzzysort from "fuzzysort" import { createEffect, createMemo, createSignal, type Accessor } from "solid-js" import { RunFooterMenu, createFooterMenuState, type RunFooterMenuItem } from "./footer.menu" import type { RunFooterTheme } from "./theme" -import type { FooterSubagentTab, RunCommand, RunInput, RunProvider } from "./types" +import type { FooterQueuedPrompt, FooterSubagentTab, RunCommand, RunInput, RunProvider } from "./types" type PanelEntry = RunFooterMenuItem & { category: string @@ -14,6 +14,7 @@ type PanelEntry = RunFooterMenuItem & { type CommandEntry = | (PanelEntry & { action: "model" }) + | (PanelEntry & { action: "queued" }) | (PanelEntry & { action: "subagent" }) | (PanelEntry & { action: "variant.cycle" }) | (PanelEntry & { action: "variant.list" }) @@ -37,6 +38,10 @@ type SubagentEntry = PanelEntry & { current: boolean } +type QueuedEntry = PanelEntry & { + prompt: FooterQueuedPrompt +} + type MenuState = ReturnType const PANEL_PAD = 2 @@ -294,11 +299,13 @@ export function RunCommandMenuBody(props: { theme: Accessor commands: Accessor subagents: Accessor + queued: Accessor variants: Accessor variantCycle: string onClose: () => void onModel: () => void onSubagent: () => void + onQueued: () => void onVariant: () => void onVariantCycle: () => void onCommand: (name: string) => void @@ -315,6 +322,17 @@ export function RunCommandMenuBody(props: { category: "Suggested", display: "Switch model", }, + ...(props.queued().length > 0 + ? [ + { + action: "queued" as const, + category: "Suggested", + display: "Manage queued prompts", + footer: `${props.queued().length} queued`, + keywords: props.queued().map((item) => item.prompt.text).join(" "), + }, + ] + : []), ...(props.subagents().length > 0 ? [ { @@ -387,6 +405,11 @@ export function RunCommandMenuBody(props: { return } + if (item.action === "queued") { + props.onQueued() + return + } + if (item.action === "variant.cycle") { props.onVariantCycle() return @@ -559,6 +582,102 @@ export function RunSubagentSelectBody(props: { ) } +export function RunQueuedPromptSelectBody(props: { + theme: Accessor + prompts: Accessor + onClose: () => void + onEdit: (prompt: FooterQueuedPrompt) => void | Promise + onDelete: (prompt: FooterQueuedPrompt) => void | Promise + onRows?: (rows: number) => void +}) { + let field: InputRenderable | undefined + const [query, setQuery] = createSignal("") + const entries = createMemo(() => + props.prompts().map((prompt) => ({ + category: "", + display: prompt.prompt.text.replaceAll("\n", " "), + footer: "queued · ctrl+e edit · ctrl+d remove", + keywords: prompt.prompt.text, + prompt, + })), + ) + const items = createMemo(() => match(query(), entries())) + const menu = createFooterMenuState({ count: () => items().length, limit: SUBAGENT_LIST_ROWS }) + const selected = () => items()[menu.selected()] + + createEffect(() => { + query() + menu.reset() + }) + + createEffect(() => { + props.onRows?.(menu.rows() + PANEL_FRAME_ROWS) + }) + + useKeyboard((event) => { + if (event.defaultPrevented) { + return + } + + const item = selected() + const ctrl = event.ctrl && !event.meta && !event.shift && !event.super + if (item && (event.name === "delete" || (ctrl && event.name === "d"))) { + event.preventDefault() + props.onDelete(item.prompt) + return + } + + if (item && ctrl && event.name === "e") { + event.preventDefault() + props.onEdit(item.prompt) + return + } + + handleKey({ + event, + menu, + field: () => field, + setQuery, + select: () => { + const item = selected() + if (item) props.onEdit(item.prompt) + }, + close: props.onClose, + }) + }) + + return ( + { + field = input + }} + onQuery={setQuery} + > + + + ) +} + export function RunVariantSelectBody(props: { theme: Accessor variants: Accessor diff --git a/packages/opencode/src/cli/cmd/run/footer.prompt.tsx b/packages/opencode/src/cli/cmd/run/footer.prompt.tsx index fd4e9072f0..7f4a9ca5b2 100644 --- a/packages/opencode/src/cli/cmd/run/footer.prompt.tsx +++ b/packages/opencode/src/cli/cmd/run/footer.prompt.tsx @@ -63,7 +63,6 @@ type PromptInput = { directory: string findFiles: (query: string) => Promise agents: Accessor - subagents: Accessor resources: Accessor commands: Accessor tuiConfig: RunTuiConfig @@ -79,7 +78,6 @@ type PromptInput = { onInputClear: () => void onExitRequest?: () => boolean onExit: () => void - onSubagentMenu?: () => void onRows: (rows: number) => void onStatus: (text: string) => void } @@ -98,6 +96,7 @@ export type PromptState = { onKeyDown: (event: KeyEvent) => void onContentChange: () => void replaceDraft: (text: string) => void + replacePrompt: (prompt: RunPrompt) => void bind: (area?: TextareaRenderable) => void } @@ -791,12 +790,20 @@ export function createPromptState(input: PromptInput): PromptState { } if (next.kind === "slash") { - const text = `/${next.name} ` const cursor = area.cursorOffset + const head = slashHead(area.plainText) + const local = !shell() && (next.name === "new" || next.name === "exit") + const separator = !shell() && !local && head && /\s/.test(area.plainText[head.end] ?? "") ? "" : " " + const text = `/${next.name}${separator}` area.cursorOffset = 0 const start = area.logicalCursor - area.cursorOffset = cursor + area.cursorOffset = + shell() || !head + ? cursor + : local + ? Bun.stringWidth(area.plainText) + : Bun.stringWidth(area.plainText.slice(0, head.end)) const end = area.logicalCursor area.deleteRange(start.row, start.col, end.row, end.col) @@ -804,6 +811,11 @@ export function createPromptState(input: PromptInput): PromptState { area.cursorOffset = Bun.stringWidth(text) hide() syncDraft() + if (!shell()) { + submitPrompt(clonePrompt(draft)) + return + } + scheduleRows() area.focus() return @@ -888,6 +900,7 @@ export function createPromptState(input: PromptInput): PromptState { if (current === "command") return false if (current === "model") return false if (current === "variant") return false + if (current === "queued-menu") return false if (current === "subagent-menu") return false return true } @@ -957,17 +970,6 @@ export function createPromptState(input: PromptInput): PromptState { mode: OPENCODE_BASE_MODE, enabled: input.prompt() && !visible(), bindings: [ - { - key: "down", - desc: "View subagents", - group: "Prompt", - cmd() { - if (!area || area.isDestroyed) return false - if (area.plainText.length !== 0) return false - if (input.subagents() === 0) return false - input.onSubagentMenu?.() - }, - }, { key: "!", desc: "Shell mode", @@ -1199,6 +1201,7 @@ export function createPromptState(input: PromptInput): PromptState { scheduleRows() }, replaceDraft, + replacePrompt: restore, bind, } } diff --git a/packages/opencode/src/cli/cmd/run/footer.ts b/packages/opencode/src/cli/cmd/run/footer.ts index 4ac8b40d88..16c6b24207 100644 --- a/packages/opencode/src/cli/cmd/run/footer.ts +++ b/packages/opencode/src/cli/cmd/run/footer.ts @@ -42,6 +42,7 @@ import type { FooterEvent, FooterPatch, FooterPromptRoute, + FooterQueuedPrompt, FooterState, FooterSubagentState, FooterView, @@ -164,6 +165,7 @@ export class RunFooter implements FooterApi { private closed = false private destroyed = false private prompts = new Set<(input: RunPrompt) => void>() + private queuedRemoves = new Set<(messageID: string) => boolean | Promise>() private closes = new Set<() => void>() // Microtask-coalesced commit queue. Flushed on next microtask or on close/destroy. private queue: StreamCommit[] = [] @@ -192,6 +194,8 @@ export class RunFooter implements FooterApi { private setView: Setter private subagent: Accessor private setSubagent: (next: FooterSubagentState) => void + private queuedPrompts: Accessor + private setQueuedPrompts: Setter private promptRoute: FooterPromptRoute = { type: "composer" } private subagentMenuRows = SUBAGENT_ROWS private autocomplete = false @@ -249,6 +253,9 @@ export class RunFooter implements FooterApi { setSubagent("permissions", reconcile(next.permissions, { key: "id" })) setSubagent("questions", reconcile(next.questions, { key: "id" })) } + const [queuedPrompts, setQueuedPrompts] = createSignal([]) + this.queuedPrompts = queuedPrompts + this.setQueuedPrompts = setQueuedPrompts this.base = Math.max(1, renderer.footerHeight - TEXTAREA_MIN_ROWS) this.scrollback = new RunScrollbackStream(renderer, options.theme, { diffStyle: options.diffStyle, @@ -270,6 +277,7 @@ export class RunFooter implements FooterApi { state: footer.state, view: footer.view, subagent: footer.subagent, + queuedPrompts: footer.queuedPrompts, findFiles: options.findFiles, agents: footer.agents, resources: footer.resources, @@ -299,6 +307,7 @@ export class RunFooter implements FooterApi { onLayout: footer.syncLayout, onStatus: footer.setStatus, onSubagentSelect: options.onSubagentSelect, + onQueuedRemove: footer.handleQueuedRemove, }) }, }), @@ -325,6 +334,13 @@ export class RunFooter implements FooterApi { } } + public onQueuedRemove(fn: (messageID: string) => boolean | Promise): () => void { + this.queuedRemoves.add(fn) + return () => { + this.queuedRemoves.delete(fn) + } + } + public onClose(fn: () => void): () => void { if (this.isClosed) { fn() @@ -370,6 +386,15 @@ export class RunFooter implements FooterApi { return } + if (next.type === "queued.prompts") { + if (this.isGone) { + return + } + + this.setQueuedPrompts(next.prompts) + return + } + const patch = eventPatch(next) if (patch) { this.patch(patch) @@ -546,6 +571,11 @@ export class RunFooter implements FooterApi { this.requestExitHandler = fn } + private handleQueuedRemove = async (messageID: string): Promise => { + const fn = [...this.queuedRemoves][0] + return fn ? await fn(messageID) : false + } + private handleInputClear = (): void => { this.clearInterruptTimer() this.clearExitTimer() @@ -573,11 +603,13 @@ export class RunFooter implements FooterApi { ? 1 + MODEL_ROWS : this.promptRoute.type === "variant" ? 1 + VARIANT_ROWS - : this.promptRoute.type === "subagent-menu" + : this.promptRoute.type === "queued-menu" ? 1 + this.subagentMenuRows - : this.promptRoute.type === "subagent" - ? this.base + SUBAGENT_INSPECTOR_ROWS - : Math.max(base + TEXTAREA_MIN_ROWS, Math.min(base + PROMPT_MAX_ROWS, base + this.rows)) + : this.promptRoute.type === "subagent-menu" + ? 1 + this.subagentMenuRows + : this.promptRoute.type === "subagent" + ? this.base + SUBAGENT_INSPECTOR_ROWS + : Math.max(base + TEXTAREA_MIN_ROWS, Math.min(base + PROMPT_MAX_ROWS, base + this.rows)) if (height !== this.renderer.footerHeight) { this.renderer.footerHeight = height @@ -872,6 +904,7 @@ export class RunFooter implements FooterApi { this.clearExitTimer() this.renderer.off(CliRenderEvents.DESTROY, this.handleDestroy) this.prompts.clear() + this.queuedRemoves.clear() this.closes.clear() this.scrollback.destroy() } diff --git a/packages/opencode/src/cli/cmd/run/footer.view.tsx b/packages/opencode/src/cli/cmd/run/footer.view.tsx index bd37c1b211..839765d0af 100644 --- a/packages/opencode/src/cli/cmd/run/footer.view.tsx +++ b/packages/opencode/src/cli/cmd/run/footer.view.tsx @@ -18,6 +18,7 @@ import { RUN_SUBAGENT_PANEL_ROWS, RunCommandMenuBody, RunModelSelectBody, + RunQueuedPromptSelectBody, RunSubagentSelectBody, RunVariantSelectBody, } from "./footer.command" @@ -35,6 +36,7 @@ import { } from "@/cli/cmd/tui/keymap" import type { FooterPromptRoute, + FooterQueuedPrompt, FooterState, FooterSubagentState, FooterView, @@ -79,6 +81,7 @@ type RunFooterViewProps = { state: () => FooterState view?: () => FooterView subagent?: () => FooterSubagentState + queuedPrompts?: () => FooterQueuedPrompt[] theme?: RunTheme diffStyle?: RunDiffStyle tuiConfig: RunTuiConfig @@ -100,6 +103,7 @@ type RunFooterViewProps = { onLayout: (input: { route: FooterPromptRoute; autocomplete: boolean; subagentRows: number }) => void onStatus: (text: string) => void onSubagentSelect?: (sessionID: string | undefined) => void + onQueuedRemove: (messageID: string) => Promise } export { TEXTAREA_MIN_ROWS, TEXTAREA_MAX_ROWS } from "./footer.prompt" @@ -119,13 +123,15 @@ export function RunFooterView(props: RunFooterViewProps) { }) const [route, setRoute] = createSignal({ type: "composer" }) const [subagentMenuRows, setSubagentMenuRows] = createSignal(RUN_SUBAGENT_PANEL_ROWS) + const queuedPrompts = createMemo(() => props.queuedPrompts?.() ?? []) const prompt = createMemo(() => active().type === "prompt" && route().type === "composer") const selectingSubagent = createMemo(() => active().type === "prompt" && route().type === "subagent-menu") + const selectingQueued = createMemo(() => active().type === "prompt" && route().type === "queued-menu") const inspecting = createMemo(() => active().type === "prompt" && route().type === "subagent") const commanding = createMemo(() => active().type === "prompt" && route().type === "command") const modeling = createMemo(() => active().type === "prompt" && route().type === "model") const varianting = createMemo(() => active().type === "prompt" && route().type === "variant") - const panel = createMemo(() => selectingSubagent() || commanding() || modeling() || varianting()) + const panel = createMemo(() => selectingQueued() || selectingSubagent() || commanding() || modeling() || varianting()) const selected = createMemo(() => { const current = route() return current.type === "subagent" ? current.sessionID : undefined @@ -151,6 +157,11 @@ export function RunFooterView(props: RunFooterViewProps) { label: count === 1 ? "agent" : "agents", } }) + const queuedIndicator = createMemo(() => { + const count = queuedPrompts().length + if (count === 0) return + return { count, label: count === 1 ? "prompt" : "prompts" } + }) const detail = createMemo(() => { const current = route() return current.type === "subagent" ? subagent().details[current.sessionID] : undefined @@ -180,11 +191,28 @@ export function RunFooterView(props: RunFooterViewProps) { props.tuiConfig, ) ?? "", ) + const queuedShortcut = useKeymapSelector( + (keymap: OpenTuiKeymap) => + formatKeyBindings( + keymap + .getCommandBindings({ visibility: "registered", commands: ["session.queued_prompts"] }) + .get("session.queued_prompts"), + props.tuiConfig, + ) ?? "", + ) + const subagentShortcut = useKeymapSelector( + (keymap: OpenTuiKeymap) => + formatKeyBindings( + keymap.getCommandBindings({ visibility: "registered", commands: ["session.child.first"] }).get("session.child.first"), + props.tuiConfig, + ) ?? "", + ) const hints = createMemo(() => hintFlags(term().width)) const busy = createMemo(() => props.state().phase === "running") const armed = createMemo(() => props.state().interrupt > 0) const exiting = createMemo(() => props.state().exit > 0) const queue = createMemo(() => props.state().queue) + const additionalQueue = createMemo(() => Math.max(0, queue() - queuedPrompts().length)) const duration = createMemo(() => props.state().duration) const usage = createMemo(() => props.state().usage) const interruptKey = createMemo(() => interrupt() || "/exit") @@ -248,6 +276,12 @@ export function RunFooterView(props: RunFooterViewProps) { props.onSubagentSelect?.(undefined) } + const openQueuedMenu = () => { + if (queuedPrompts().length === 0) return + setRoute({ type: "queued-menu" }) + props.onSubagentSelect?.(undefined) + } + const closePanel = () => { setRoute({ type: "composer" }) } @@ -282,7 +316,6 @@ export function RunFooterView(props: RunFooterViewProps) { directory: props.directory, findFiles: props.findFiles, agents: props.agents, - subagents: () => tabs().length, resources: props.resources, commands: props.commands, tuiConfig: props.tuiConfig, @@ -298,7 +331,6 @@ export function RunFooterView(props: RunFooterViewProps) { onInputClear: props.onInputClear, onExitRequest: props.onExitRequest, onExit: props.onExit, - onSubagentMenu: openSubagentMenu, onRows: props.onRows, onStatus: props.onStatus, }) @@ -336,6 +368,34 @@ export function RunFooterView(props: RunFooterViewProps) { ], })) + useBindings(() => ({ + mode: OPENCODE_BASE_MODE, + enabled: active().type === "prompt" && route().type === "composer" && tabs().length > 0, + commands: [ + { + name: "session.child.first", + title: "View subagents", + category: "Session", + run: openSubagentMenu, + }, + ], + bindings: props.tuiConfig.keybinds.get("session.child.first"), + })) + + useBindings(() => ({ + mode: OPENCODE_BASE_MODE, + enabled: active().type === "prompt" && route().type === "composer" && queuedPrompts().length > 0, + commands: [ + { + name: "session.queued_prompts", + title: "Manage queued prompts", + category: "Session", + run: openQueuedMenu, + }, + ], + bindings: props.tuiConfig.keybinds.get("session.queued_prompts"), + })) + createEffect(() => { const current = route() if (current.type !== "subagent") { @@ -361,6 +421,11 @@ export function RunFooterView(props: RunFooterViewProps) { closePanel() }) + createEffect(() => { + if (route().type !== "queued-menu" || queuedPrompts().length > 0) return + closePanel() + }) + createEffect(() => { if (active().type === "prompt") { return @@ -371,6 +436,7 @@ export function RunFooterView(props: RunFooterViewProps) { current.type !== "command" && current.type !== "model" && current.type !== "variant" && + current.type !== "queued-menu" && current.type !== "subagent-menu" ) { return @@ -449,16 +515,32 @@ export function RunFooterView(props: RunFooterViewProps) { onRows={setSubagentMenuRows} /> + + void props.onQueuedRemove(item.messageID)} + onEdit={async (item) => { + if (!(await props.onQueuedRemove(item.messageID))) return + closePanel() + queueMicrotask(() => composer.replacePrompt(item.prompt)) + }} + onRows={setSubagentMenuRows} + /> + { props.onCycle() @@ -615,7 +697,7 @@ export function RunFooterView(props: RunFooterViewProps) { gap={1} flexShrink={0} > - 0 || subagentIndicator()}> + 0 || queuedIndicator() || subagentIndicator()}> @@ -665,11 +747,24 @@ export function RunFooterView(props: RunFooterViewProps) { {info().count} {info().label} · - + {subagentShortcut() || "leader+down"} to view )} + + {(info) => ( + + 0 || subagentIndicator()}> + · + + {info().count} queued {info().label} + · + {queuedShortcut() || "leader+q"} + to edit/remove + + )} + @@ -686,9 +781,9 @@ export function RunFooterView(props: RunFooterViewProps) { when={shell()} fallback={ <> - 0}> + 0}> - {queue()} queued + {additionalQueue()} queued 0}> diff --git a/packages/opencode/src/cli/cmd/run/runtime.queue.ts b/packages/opencode/src/cli/cmd/run/runtime.queue.ts index 79be71cadf..63ce618beb 100644 --- a/packages/opencode/src/cli/cmd/run/runtime.queue.ts +++ b/packages/opencode/src/cli/cmd/run/runtime.queue.ts @@ -1,17 +1,17 @@ // Serial prompt queue for direct interactive mode. // // Prompts arrive from the footer (user types and hits enter) and queue up -// here. The queue drains one turn at a time: it appends the user row to -// scrollback, calls input.run() to execute the turn through the stream -// transport, and waits for completion before starting the next prompt. +// here. The queue drains one turn at a time; ordinary prompts waiting behind +// an active ordinary turn are exposed for edit/removal until they begin. // // The queue also handles /exit, /quit, and /new commands, empty-prompt rejection, // and tracks per-turn wall-clock duration for the footer status line. // // Resolves when the footer closes and all in-flight work finishes. import * as Locale from "@/util/locale" +import { MessageID, PartID } from "@/session/schema" import { isExitCommand, isNewCommand } from "./prompt.shared" -import type { FooterApi, FooterEvent, RunPrompt } from "./types" +import type { FooterApi, FooterEvent, FooterQueuedPrompt, RunPrompt } from "./types" type Trace = { write(type: string, data?: unknown): void @@ -34,6 +34,8 @@ export type QueueInput = { type State = { queue: RunPrompt[] + queued: FooterQueuedPrompt[] + active?: RunPrompt ctrl?: AbortController closed: boolean } @@ -51,15 +53,15 @@ function defer(): Deferred { // Runs the prompt queue until the footer closes. // -// Subscribes to footer prompt events, queues them, and drains one at a -// time through input.run(). If the user submits multiple prompts while -// a turn is running, they queue up and execute in order. The footer shows -// the queue depth so the user knows how many are pending. +// Subscribes to footer prompt events and drains operations through input.run(). +// Ordinary prompts submitted during an ordinary active turn remain local and +// are exposed by the footer for edit/removal until their turn begins. export async function runPromptQueue(input: QueueInput): Promise { const stop = defer<{ type: "closed" }>() const done = defer() const state: State = { queue: [], + queued: [], closed: input.footer.isClosed, } let draining: Promise | undefined @@ -69,6 +71,24 @@ export async function runPromptQueue(input: QueueInput): Promise { input.footer.event(next) } + const syncQueue = () => { + const queue = state.queue.length + emit({ type: "queue", queue }, { queue }) + emit( + { + type: "queued.prompts", + prompts: [...state.queued], + }, + { queued: state.queued.length }, + ) + } + + const removeLocalQueued = (queued: FooterQueuedPrompt) => { + if (!state.queued.includes(queued)) return + state.queued = state.queued.filter((item) => item !== queued) + syncQueue() + } + const finish = () => { if (!state.closed || draining) { return @@ -84,6 +104,7 @@ export async function runPromptQueue(input: QueueInput): Promise { state.closed = true state.queue.length = 0 + state.queued.length = 0 state.ctrl?.abort() stop.resolve({ type: "closed" }) finish() @@ -102,16 +123,11 @@ export async function runPromptQueue(input: QueueInput): Promise { continue } + const queued = state.queued.find((item) => item.prompt === prompt) + if (queued) removeLocalQueued(queued) + if (prompt.mode !== "shell" && isNewCommand(prompt.text)) { - emit( - { - type: "queue", - queue: state.queue.length, - }, - { - queue: state.queue.length, - }, - ) + syncQueue() if (!input.onNewSession) { emit( { @@ -146,6 +162,8 @@ export async function runPromptQueue(input: QueueInput): Promise { continue } + state.active = prompt + emit( { type: "turn.send", @@ -192,6 +210,7 @@ export async function runPromptQueue(input: QueueInput): Promise { if (next.type === "error") { throw next.error } + } finally { if (state.ctrl === ctrl) { state.ctrl = undefined @@ -207,6 +226,7 @@ export async function runPromptQueue(input: QueueInput): Promise { duration, }, ) + state.active = undefined } } } catch (error) { @@ -241,16 +261,28 @@ export async function runPromptQueue(input: QueueInput): Promise { return } + const active = state.active + if ( + active && + active.mode !== "shell" && + !active.command && + prompt.mode !== "shell" && + !prompt.command && + !isNewCommand(prompt.text) + ) { + const queued: FooterQueuedPrompt = { + messageID: MessageID.ascending(), + partID: PartID.ascending(), + prompt, + } + state.queued = [...state.queued, queued] + state.queue.push(prompt) + syncQueue() + return + } + state.queue.push(prompt) - emit( - { - type: "queue", - queue: state.queue.length, - }, - { - queue: state.queue.length, - }, - ) + syncQueue() if (prompt.mode !== "shell" && isNewCommand(prompt.text)) { drain() return @@ -274,6 +306,13 @@ export async function runPromptQueue(input: QueueInput): Promise { const offClose = input.footer.onClose(() => { close() }) + const offRemoveQueued = input.footer.onQueuedRemove((messageID) => { + const queued = state.queued.find((item) => item.messageID === messageID) + if (!queued) return false + state.queue = state.queue.filter((prompt) => prompt !== queued.prompt) + removeLocalQueued(queued) + return true + }) try { if (state.closed) { @@ -289,6 +328,7 @@ export async function runPromptQueue(input: QueueInput): Promise { } finally { offPrompt() offClose() + offRemoveQueued() close() await draining?.catch(() => {}) } diff --git a/packages/opencode/src/cli/cmd/run/stream.transport.ts b/packages/opencode/src/cli/cmd/run/stream.transport.ts index 41a083c702..ca6a55d1d2 100644 --- a/packages/opencode/src/cli/cmd/run/stream.transport.ts +++ b/packages/opencode/src/cli/cmd/run/stream.transport.ts @@ -5,8 +5,8 @@ // produce scrollback commits and footer patches, which get forwarded to the // footer through stream.ts. // -// Prompt turns are one-at-a-time: runPromptTurn() sends the prompt to the -// SDK, arms a deferred Wait, and resolves when the session becomes idle. +// Prompt turns are one-at-a-time: runPromptTurn() sends the prompt, arms a +// deferred Wait, and resolves when the session becomes idle. // Prefer session.status idle events, but also poll session.status because some // transports can miss status events while still delivering message events. If // the turn is aborted (user interrupt), it flushes any in-progress parts as diff --git a/packages/opencode/src/cli/cmd/run/types.ts b/packages/opencode/src/cli/cmd/run/types.ts index 556b1f8623..8a88bb7ad9 100644 --- a/packages/opencode/src/cli/cmd/run/types.ts +++ b/packages/opencode/src/cli/cmd/run/types.ts @@ -31,6 +31,8 @@ export type RunCommand = NonNullable>["data"]>["all"][number] export type RunPrompt = { + messageID?: string + partID?: string text: string parts: RunPromptPart[] mode?: "shell" @@ -40,6 +42,12 @@ export type RunPrompt = { } } +export type FooterQueuedPrompt = { + messageID: string + partID: string + prompt: RunPrompt +} + export type RunAgent = NonNullable>["data"]>[number] type RunResourceMap = NonNullable>["data"]> @@ -162,6 +170,7 @@ export type FooterView = export type FooterPromptRoute = | { type: "composer" } + | { type: "queued-menu" } | { type: "subagent-menu" } | { type: "subagent"; sessionID: string } | { type: "command" } @@ -222,6 +231,10 @@ export type FooterEvent = type: "queue" queue: number } + | { + type: "queued.prompts" + prompts: FooterQueuedPrompt[] + } | { type: "first" first: boolean @@ -302,6 +315,7 @@ export type StreamCommit = { export type FooterApi = { readonly isClosed: boolean onPrompt(fn: (input: RunPrompt) => void): () => void + onQueuedRemove(fn: (messageID: string) => boolean | Promise): () => void onClose(fn: () => void): () => void event(next: FooterEvent): void append(commit: StreamCommit): void diff --git a/packages/opencode/src/cli/cmd/tui/config/keybind.ts b/packages/opencode/src/cli/cmd/tui/config/keybind.ts index c03123aed1..f69cb68fd8 100644 --- a/packages/opencode/src/cli/cmd/tui/config/keybind.ts +++ b/packages/opencode/src/cli/cmd/tui/config/keybind.ts @@ -95,6 +95,7 @@ export const Definitions = { session_compact: keybind("c", "Compact the session"), session_toggle_timestamps: keybind("none", "Toggle message timestamps"), session_toggle_generic_tool_output: keybind("none", "Toggle generic tool output"), + session_queued_prompts: keybind("q", "Manage queued prompts"), session_child_first: keybind("down", "Go to first child session"), session_child_cycle: keybind("right", "Go to next child session"), session_child_cycle_reverse: keybind("left", "Go to previous child session"), @@ -292,6 +293,7 @@ export const CommandMap = { session_compact: "session.compact", session_toggle_timestamps: "session.toggle.timestamps", session_toggle_generic_tool_output: "session.toggle.generic_tool_output", + session_queued_prompts: "session.queued_prompts", session_child_first: "session.child.first", session_child_cycle: "session.child.next", session_child_cycle_reverse: "session.child.previous", diff --git a/packages/opencode/test/cli/run/footer.view.test.tsx b/packages/opencode/test/cli/run/footer.view.test.tsx index 0f7ddb532e..a4f4c296e6 100644 --- a/packages/opencode/test/cli/run/footer.view.test.tsx +++ b/packages/opencode/test/cli/run/footer.view.test.tsx @@ -10,6 +10,7 @@ import { RUN_SUBAGENT_PANEL_ROWS, RunCommandMenuBody, RunModelSelectBody, + RunQueuedPromptSelectBody, RunSubagentSelectBody, RunVariantSelectBody, } from "@/cli/cmd/run/footer.command" @@ -23,6 +24,7 @@ import type { FooterView, RunCommand, RunInput, + RunPrompt, RunProvider, RunTuiConfig, StreamCommit, @@ -147,7 +149,14 @@ function footerState(input: Partial = {}) { })[0] } -async function renderFooter(input: { tuiConfig?: RunTuiConfig; onCycle?: () => void } = {}) { +async function renderFooter( + input: { + tuiConfig?: RunTuiConfig + commands?: RunCommand[] + onCycle?: () => void + onSubmit?: (prompt: RunPrompt) => boolean + } = {}, +) { const [view] = createSignal({ type: "prompt" }) const [subagents] = createSignal({ tabs: [], details: {}, permissions: [], questions: [] }) const state = footerState() @@ -166,7 +175,7 @@ async function renderFooter(input: { tuiConfig?: RunTuiConfig; onCycle?: () => v findFiles={async () => []} agents={() => []} resources={() => []} - commands={() => []} + commands={() => input.commands ?? []} providers={() => undefined} currentModel={() => undefined} variants={() => []} @@ -177,7 +186,7 @@ async function renderFooter(input: { tuiConfig?: RunTuiConfig; onCycle?: () => v theme={RUN_THEME_FALLBACK} tuiConfig={config} agent="opencode" - onSubmit={() => true} + onSubmit={input.onSubmit ?? (() => true)} onPermissionReply={() => {}} onQuestionReply={() => {}} onQuestionReject={() => {}} @@ -189,7 +198,8 @@ async function renderFooter(input: { tuiConfig?: RunTuiConfig; onCycle?: () => v onVariantSelect={() => {}} onRows={() => {}} onLayout={() => {}} - onStatus={() => {}} + onStatus={() => {}} + onQueuedRemove={async () => true} /> ) @@ -276,11 +286,13 @@ test("direct command panel renders grouped command palette", async () => { theme={() => RUN_THEME_FALLBACK.footer} commands={commands} subagents={subagents} + queued={() => []} variants={variants} variantCycle="ctrl+t" onClose={() => {}} onModel={() => {}} onSubagent={() => {}} + onQueued={() => {}} onVariant={() => {}} onVariantCycle={() => {}} onCommand={() => {}} @@ -334,11 +346,13 @@ test("direct command panel shows subagent entry when available", async () => { theme={() => RUN_THEME_FALLBACK.footer} commands={commands} subagents={subagents} + queued={() => []} variants={variants} variantCycle="ctrl+t" onClose={() => {}} onModel={() => {}} onSubagent={() => {}} + onQueued={() => {}} onVariant={() => {}} onVariantCycle={() => {}} onCommand={() => {}} @@ -407,6 +421,36 @@ test("direct subagent panel renders active subagents", async () => { } }) +test("direct queued prompt panel renders pending prompt actions", async () => { + const [prompts] = createSignal([ + { messageID: "m-1", partID: "p-1", prompt: { text: "fix the auth test", parts: [] } }, + ]) + + const app = await testRender( + () => ( + + RUN_THEME_FALLBACK.footer} + prompts={prompts} + onClose={() => {}} + onEdit={() => {}} + onDelete={() => {}} + /> + + ), + { width: 100, height: RUN_SUBAGENT_PANEL_ROWS }, + ) + + try { + await app.renderOnce() + expect(app.captureCharFrame()).toContain("Queued prompts") + expect(app.captureCharFrame()).toContain("fix the auth test") + expect(app.captureCharFrame()).toContain("queued") + } finally { + app.renderer.destroy() + } +}) + // OpenTUI currently segfaults when the full footer view suite creates several // keymap-backed test renderers in one process. Re-enable after the runtime fix. test.skip("direct footer opens command panel through keymap binding", async () => { @@ -462,11 +506,73 @@ test("direct footer keeps leader variant binding inactive when leader is disable } }) -test("direct footer shows subagent indicator while prompt is running", async () => { +test("direct footer submits slash autocomplete selections without dispatching shell completions", async () => { + const submits: RunPrompt[] = [] + const app = await renderFooter({ + commands: [command({ name: "review", description: "Review code" })], + onSubmit(prompt) { + submits.push(prompt) + return true + }, + }) + + try { + await app.renderOnce() + "/rev".split("").forEach((key) => app.mockInput.pressKey(key)) + await app.renderOnce() + app.mockInput.pressEnter() + await app.renderOnce() + + "/rev".split("").forEach((key) => app.mockInput.pressKey(key)) + await app.renderOnce() + app.mockInput.pressKey("TAB") + await app.renderOnce() + + "/re branch".split("").forEach((key) => app.mockInput.pressKey(key)) + Array.from({ length: 7 }).forEach(() => app.mockInput.pressKey("ARROW_LEFT")) + app.mockInput.pressKey("v") + await app.renderOnce() + app.mockInput.pressEnter() + await app.renderOnce() + + "/nx".split("").forEach((key) => app.mockInput.pressKey(key)) + app.mockInput.pressKey("ARROW_LEFT") + app.mockInput.pressKey("e") + await app.renderOnce() + app.mockInput.pressEnter() + await app.renderOnce() + + "/n scratch".split("").forEach((key) => app.mockInput.pressKey(key)) + Array.from({ length: 8 }).forEach(() => app.mockInput.pressKey("ARROW_LEFT")) + app.mockInput.pressKey("e") + await app.renderOnce() + app.mockInput.pressEnter() + await app.renderOnce() + + app.mockInput.pressKey("!") + "/rev".split("").forEach((key) => app.mockInput.pressKey(key)) + await app.renderOnce() + app.mockInput.pressEnter() + await app.renderOnce() + + expect(submits).toEqual([ + { text: "/review ", parts: [], command: { name: "review", arguments: "" } }, + { text: "/review ", parts: [], command: { name: "review", arguments: "" } }, + { text: "/review branch", parts: [], command: { name: "review", arguments: "branch" } }, + { text: "/new ", parts: [] }, + { text: "/new ", parts: [] }, + ]) + expect(app.captureCharFrame()).toContain("/review") + } finally { + app.cleanup() + } +}) + +test("direct footer shows editable prompts and additional queued work while running", async () => { const [state] = createSignal({ phase: "running", status: "", - queue: 0, + queue: 3, model: "gpt-5", duration: "", usage: "", @@ -502,6 +608,9 @@ test("direct footer shows subagent indicator while prompt is running", async () state={state} view={view} subagent={subagents} + queuedPrompts={() => [ + { messageID: "m-queued", partID: "p-queued", prompt: { text: "follow up", parts: [] } }, + ]} theme={RUN_THEME_FALLBACK} tuiConfig={tuiConfig} agent="opencode" @@ -518,6 +627,7 @@ test("direct footer shows subagent indicator while prompt is running", async () onRows={() => {}} onLayout={() => {}} onStatus={() => {}} + onQueuedRemove={async () => true} /> ) @@ -525,19 +635,21 @@ test("direct footer shows subagent indicator while prompt is running", async () const app = await testRender( () => ( - + ), { - width: 100, + width: 160, height: 8, }, ) try { await app.renderOnce() - expect(app.captureCharFrame()).toContain("interrupt · 1 agent · ↓ to view") + expect(app.captureCharFrame()).toContain("interrupt · 1 agent · ctrl+x down to view · 1 queued prompt · ctrl+x q") + expect(app.captureCharFrame()).toContain("2 queued") + expect(app.captureCharFrame()).not.toContain("agent · ·") } finally { app.renderer.currentFocusedRenderable?.blur() app.renderer.currentFocusedEditor?.blur() diff --git a/packages/opencode/test/cli/run/runtime.queue.test.ts b/packages/opencode/test/cli/run/runtime.queue.test.ts index 5515787caf..ead73af890 100644 --- a/packages/opencode/test/cli/run/runtime.queue.test.ts +++ b/packages/opencode/test/cli/run/runtime.queue.test.ts @@ -4,6 +4,7 @@ import type { FooterApi, FooterEvent, RunPrompt, StreamCommit } from "@/cli/cmd/ function footer() { const prompts = new Set<(input: RunPrompt) => void>() + const queuedRemoves = new Set<(messageID: string) => void>() const closes = new Set<() => void>() const events: FooterEvent[] = [] const commits: StreamCommit[] = [] @@ -19,6 +20,12 @@ function footer() { prompts.delete(fn) } }, + onQueuedRemove(fn) { + queuedRemoves.add(fn) + return () => { + queuedRemoves.delete(fn) + } + }, onClose(fn) { if (closed) { fn() @@ -66,6 +73,9 @@ function footer() { fn(next) } }, + removeQueued(messageID: string) { + for (const fn of [...queuedRemoves]) fn(messageID) + }, } } @@ -289,6 +299,82 @@ describe("run runtime queue", () => { expect(seen).toEqual(["one", "two"]) }) + test("exposes ordinary in-flight prompts for removal before sending", async () => { + const ui = footer() + const turns: RunPrompt[] = [] + let wake: (() => void) | undefined + const gate = new Promise((resolve) => { + wake = resolve + }) + + const task = runPromptQueue({ + footer: ui.api, + run: async (input) => { + turns.push(input) + await gate + }, + }) + + ui.submit("one") + ui.submit("two") + await Promise.resolve() + await Promise.resolve() + + expect(turns.map((item) => item.text)).toEqual(["one"]) + expect(turns[0]?.messageID).toBeUndefined() + expect(ui.commits.map((item) => item.text)).toEqual(["one"]) + const first = ui.events.find((item) => item.type === "queued.prompts") + const event = ui.events.findLast((item) => item.type === "queued.prompts") + expect(first?.type === "queued.prompts" ? first.prompts : []).toEqual([]) + expect(first?.type === "queued.prompts" && event?.type === "queued.prompts" ? first.prompts === event.prompts : true).toBe( + false, + ) + expect(ui.events.findLast((item) => item.type === "queue")).toEqual({ type: "queue", queue: 1 }) + expect(event?.type === "queued.prompts" ? event.prompts.map((item) => item.prompt.text) : []).toEqual(["two"]) + if (event?.type === "queued.prompts") ui.removeQueued(event.prompts[0]!.messageID) + await Promise.resolve() + + wake?.() + ui.api.close() + await task + expect(turns.map((item) => item.text)).toEqual(["one"]) + }) + + test("removing one managed queued prompt preserves the others", async () => { + const ui = footer() + const turns: string[] = [] + let wake: (() => void) | undefined + const gate = new Promise((resolve) => { + wake = resolve + }) + + const task = runPromptQueue({ + footer: ui.api, + run: async (input) => { + turns.push(input.text) + if (input.text === "active") await gate + if (input.text === "queued three") ui.api.close() + }, + }) + + ui.submit("active") + ui.submit("queued one") + ui.submit("queued two") + ui.submit("queued three") + await Promise.resolve() + await Promise.resolve() + + const event = ui.events.findLast((item) => item.type === "queued.prompts") + if (event?.type === "queued.prompts") { + const second = event.prompts.find((item) => item.prompt.text === "queued two") + if (second) ui.removeQueued(second.messageID) + } + + wake?.() + await task + expect(turns).toEqual(["active", "queued one", "queued three"]) + }) + test("drains a prompt queued during an in-flight turn", async () => { const ui = footer() const seen: string[] = [] diff --git a/packages/opencode/test/cli/run/stream.test.ts b/packages/opencode/test/cli/run/stream.test.ts index 9fb6e7b614..e6b40dd925 100644 --- a/packages/opencode/test/cli/run/stream.test.ts +++ b/packages/opencode/test/cli/run/stream.test.ts @@ -9,6 +9,7 @@ function footer() { const api: FooterApi = { isClosed: false, onPrompt: () => () => {}, + onQueuedRemove: () => () => {}, onClose: () => () => {}, event: (next) => { events.push(next) diff --git a/packages/opencode/test/cli/run/stream.transport.test.ts b/packages/opencode/test/cli/run/stream.transport.test.ts index d1b145db24..74fb7ec028 100644 --- a/packages/opencode/test/cli/run/stream.transport.test.ts +++ b/packages/opencode/test/cli/run/stream.transport.test.ts @@ -358,6 +358,7 @@ function footer(fn?: (commit: StreamCommit) => void) { return closed }, onPrompt: () => () => {}, + onQueuedRemove: () => () => {}, onClose: () => () => {}, event(next) { events.push(next) From fd2278eefcdfdda26d640af51abe0987f21140d7 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Mon, 1 Jun 2026 07:27:27 +0000 Subject: [PATCH 050/840] chore: generate --- packages/opencode/src/cli/cmd/run/footer.command.tsx | 5 ++++- packages/opencode/src/cli/cmd/run/footer.view.tsx | 8 ++++++-- packages/opencode/src/cli/cmd/run/runtime.queue.ts | 1 - packages/opencode/test/cli/run/footer.view.test.tsx | 4 ++-- packages/opencode/test/cli/run/runtime.queue.test.ts | 6 +++--- 5 files changed, 15 insertions(+), 9 deletions(-) diff --git a/packages/opencode/src/cli/cmd/run/footer.command.tsx b/packages/opencode/src/cli/cmd/run/footer.command.tsx index d007707e8b..90ba6fc673 100644 --- a/packages/opencode/src/cli/cmd/run/footer.command.tsx +++ b/packages/opencode/src/cli/cmd/run/footer.command.tsx @@ -329,7 +329,10 @@ export function RunCommandMenuBody(props: { category: "Suggested", display: "Manage queued prompts", footer: `${props.queued().length} queued`, - keywords: props.queued().map((item) => item.prompt.text).join(" "), + keywords: props + .queued() + .map((item) => item.prompt.text) + .join(" "), }, ] : []), diff --git a/packages/opencode/src/cli/cmd/run/footer.view.tsx b/packages/opencode/src/cli/cmd/run/footer.view.tsx index 839765d0af..816f6c9926 100644 --- a/packages/opencode/src/cli/cmd/run/footer.view.tsx +++ b/packages/opencode/src/cli/cmd/run/footer.view.tsx @@ -203,7 +203,9 @@ export function RunFooterView(props: RunFooterViewProps) { const subagentShortcut = useKeymapSelector( (keymap: OpenTuiKeymap) => formatKeyBindings( - keymap.getCommandBindings({ visibility: "registered", commands: ["session.child.first"] }).get("session.child.first"), + keymap + .getCommandBindings({ visibility: "registered", commands: ["session.child.first"] }) + .get("session.child.first"), props.tuiConfig, ) ?? "", ) @@ -697,7 +699,9 @@ export function RunFooterView(props: RunFooterViewProps) { gap={1} flexShrink={0} > - 0 || queuedIndicator() || subagentIndicator()}> + 0 || queuedIndicator() || subagentIndicator()} + > diff --git a/packages/opencode/src/cli/cmd/run/runtime.queue.ts b/packages/opencode/src/cli/cmd/run/runtime.queue.ts index 63ce618beb..64172e8280 100644 --- a/packages/opencode/src/cli/cmd/run/runtime.queue.ts +++ b/packages/opencode/src/cli/cmd/run/runtime.queue.ts @@ -210,7 +210,6 @@ export async function runPromptQueue(input: QueueInput): Promise { if (next.type === "error") { throw next.error } - } finally { if (state.ctrl === ctrl) { state.ctrl = undefined diff --git a/packages/opencode/test/cli/run/footer.view.test.tsx b/packages/opencode/test/cli/run/footer.view.test.tsx index a4f4c296e6..ed2df583c0 100644 --- a/packages/opencode/test/cli/run/footer.view.test.tsx +++ b/packages/opencode/test/cli/run/footer.view.test.tsx @@ -198,8 +198,8 @@ async function renderFooter( onVariantSelect={() => {}} onRows={() => {}} onLayout={() => {}} - onStatus={() => {}} - onQueuedRemove={async () => true} + onStatus={() => {}} + onQueuedRemove={async () => true} /> ) diff --git a/packages/opencode/test/cli/run/runtime.queue.test.ts b/packages/opencode/test/cli/run/runtime.queue.test.ts index ead73af890..728e18fcfe 100644 --- a/packages/opencode/test/cli/run/runtime.queue.test.ts +++ b/packages/opencode/test/cli/run/runtime.queue.test.ts @@ -326,9 +326,9 @@ describe("run runtime queue", () => { const first = ui.events.find((item) => item.type === "queued.prompts") const event = ui.events.findLast((item) => item.type === "queued.prompts") expect(first?.type === "queued.prompts" ? first.prompts : []).toEqual([]) - expect(first?.type === "queued.prompts" && event?.type === "queued.prompts" ? first.prompts === event.prompts : true).toBe( - false, - ) + expect( + first?.type === "queued.prompts" && event?.type === "queued.prompts" ? first.prompts === event.prompts : true, + ).toBe(false) expect(ui.events.findLast((item) => item.type === "queue")).toEqual({ type: "queue", queue: 1 }) expect(event?.type === "queued.prompts" ? event.prompts.map((item) => item.prompt.text) : []).toEqual(["two"]) if (event?.type === "queued.prompts") ui.removeQueued(event.prompts[0]!.messageID) From 50b4ad89b3aa2b940ab29e3cd7a7deefcc2c2644 Mon Sep 17 00:00:00 2001 From: smagnuso Date: Mon, 1 Jun 2026 01:38:28 -0700 Subject: [PATCH 051/840] fix(acp): honor session/cancel by aborting the running turn (#30145) Co-authored-by: Shoubhit Dash --- packages/opencode/src/acp/service.ts | 33 ++++++---- .../opencode/test/acp/service-session.test.ts | 66 +++++++++---------- 2 files changed, 52 insertions(+), 47 deletions(-) diff --git a/packages/opencode/src/acp/service.ts b/packages/opencode/src/acp/service.ts index 39840c0910..b8c700ea06 100644 --- a/packages/opencode/src/acp/service.ts +++ b/packages/opencode/src/acp/service.ts @@ -330,25 +330,34 @@ export function make(input: { } }) + const abortBackingSession = Effect.fn("ACP.abortBackingSession")(function* (current: ACPSession.Info) { + yield* request( + () => input.sdk.session.abort({ directory: current.cwd, sessionID: current.id }, { throwOnError: true }), + "session", + ).pipe( + Effect.catch((error) => + Effect.sync(() => { + log.error("failed to abort ACP backing session", { error, sessionID: current.id }) + }), + ), + ) + }) + const closeSession = Effect.fn("ACP.closeSession")(function* (params: CloseSessionRequest) { const removed = yield* session.remove(params.sessionId) registeredMcp.delete(params.sessionId) sessionSnapshots.delete(params.sessionId) if (!removed) return {} - yield* request( - () => input.sdk.session.abort({ directory: removed.cwd, sessionID: params.sessionId }, { throwOnError: true }), - "session", - ).pipe( - Effect.catch((error) => - Effect.sync(() => { - log.error("failed to abort session while closing ACP session", { error, sessionID: params.sessionId }) - }), - ), - ) + yield* abortBackingSession(removed) return {} }) + const cancel = Effect.fn("ACP.cancel")(function* (params: CancelNotification) { + const current = yield* session.get(params.sessionId) + yield* abortBackingSession(current) + }) + const forkSession = Effect.fn("ACP.forkSession")(function* (params: ForkSessionRequest) { const snapshot = yield* directorySnapshot(params.cwd) const forked = yield* request( @@ -563,9 +572,7 @@ export function make(input: { yield* sendUsageUpdate(input.usage, input.sdk, input.connection, current.id, current.cwd) return promptResponse(undefined, params.messageId) }), - cancel: Effect.fn("ACP.cancel")(function* (_input: CancelNotification) { - return yield* new ACPError.UnsupportedOperationError({ method: "session/cancel" }) - }), + cancel, } } diff --git a/packages/opencode/test/acp/service-session.test.ts b/packages/opencode/test/acp/service-session.test.ts index 878d331c44..e5f1bc64c6 100644 --- a/packages/opencode/test/acp/service-session.test.ts +++ b/packages/opencode/test/acp/service-session.test.ts @@ -12,10 +12,9 @@ import type { } from "@agentclientprotocol/sdk" import type { OpencodeClient } from "@opencode-ai/sdk/v2" import { ProviderV2 } from "@opencode-ai/core/provider" -import { Effect, ManagedRuntime } from "effect" +import { Effect } from "effect" import * as ACPService from "@/acp/service" import * as ACPError from "@/acp/error" -import { ACPSession } from "@/acp/session" import { UsageService } from "@/acp/usage" import type { Provider } from "@/provider/provider" @@ -142,7 +141,10 @@ const provider: Provider.Info = { } describe("ACP service sessions", () => { - const makeService = (messages: readonly { info: unknown; parts: readonly unknown[] }[] = []) => { + const makeService = ( + messages: readonly { info: unknown; parts: readonly unknown[] }[] = [], + options?: { abort?: (input: { sessionID: string }) => Promise<{ data: boolean }> }, + ) => { const updates: SessionNotification[] = [] const mcpAdds: string[] = [] const aborts: string[] = [] @@ -220,10 +222,12 @@ describe("ACP service sessions", () => { summarizes.push(input) return Promise.resolve({ data: true }) }, - abort: (input: { sessionID: string }) => { - aborts.push(input.sessionID) - return Promise.resolve({ data: true }) - }, + abort: + options?.abort ?? + ((input: { sessionID: string }) => { + aborts.push(input.sessionID) + return Promise.resolve({ data: true }) + }), fork: (input: { sessionID: string }) => { forks.push(input.sessionID) return Promise.resolve({ data: { id: `fork_${input.sessionID}` } }) @@ -381,34 +385,28 @@ describe("ACP service sessions", () => { expect(await Effect.runPromise(service.closeSession({ sessionId: "missing" }))).toEqual({}) }) - it("does not fail close when backing abort fails", async () => { - const sessionService = ManagedRuntime.make(ACPSession.defaultLayer).runSync( - ACPSession.Service.use((service) => Effect.succeed(service)), - ) - const { service } = makeService() - const sdk = { - config: { - providers: () => Promise.resolve({ data: { providers: [provider], default: { test: modelID } } }), - get: () => Promise.resolve({ data: {} }), - }, - app: { - agents: () => Promise.resolve({ data: [{ name: "build", mode: "primary", permission: [], options: {} }] }), - skills: () => Promise.resolve({ data: [] }), - }, - command: { - list: () => Promise.resolve({ data: [] }), - }, - session: { - abort: () => Promise.reject(new Error("nope")), - }, - mcp: { - add: () => Promise.resolve({ data: {} }), - }, - } as unknown as OpencodeClient - const closing = ACPService.make({ sdk, session: sessionService }) - await Effect.runPromise(sessionService.create({ id: "ses_close", cwd: "/workspace" })) + it("cancel aborts the backing session and keeps the ACP session", async () => { + const { service, aborts } = makeService() + const created = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] })) - expect(await Effect.runPromise(closing.closeSession({ sessionId: "ses_close" }))).toEqual({}) + await Effect.runPromise(service.cancel({ sessionId: created.sessionId })) + + // The running turn was aborted via the core session API. + expect(aborts).toEqual([created.sessionId]) + // Unlike closeSession, the ACP session is still present afterwards so + // the client can keep prompting. + const stillUsable = await Effect.runPromise( + service.setSessionConfigOption({ sessionId: created.sessionId, configId: "effort", value: "high" }), + ) + expect(stillUsable).toBeDefined() + }) + + it("does not fail cancel or close when the backing abort fails", async () => { + const { service } = makeService([], { abort: () => Promise.reject(new Error("nope")) }) + const created = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] })) + + await Effect.runPromise(service.cancel({ sessionId: created.sessionId })) + expect(await Effect.runPromise(service.closeSession({ sessionId: created.sessionId }))).toEqual({}) expect(await Effect.runPromise(service.closeSession({ sessionId: "missing" }))).toEqual({}) }) From bba76009a8842db4265787cb364c64bd114e7c9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Orca=E4=B8=B6?= <93272799+dauphinYan@users.noreply.github.com> Date: Mon, 1 Jun 2026 16:42:14 +0800 Subject: [PATCH 052/840] fix(tui): prevent prompt corruption when pasting near wide characters (#29710) Co-authored-by: opencode-agent[bot] Co-authored-by: Simon Klee --- .../opencode/src/cli/cmd/prompt-display.ts | 2 +- .../cli/cmd/tui/component/prompt/index.tsx | 35 ++++++++----------- .../src/cli/cmd/tui/component/prompt/part.ts | 11 ++++++ .../test/cli/cmd/tui/prompt-part.test.ts | 34 +++++++++++++++++- 4 files changed, 59 insertions(+), 23 deletions(-) diff --git a/packages/opencode/src/cli/cmd/prompt-display.ts b/packages/opencode/src/cli/cmd/prompt-display.ts index 4e8cb9046a..4c22942ea8 100644 --- a/packages/opencode/src/cli/cmd/prompt-display.ts +++ b/packages/opencode/src/cli/cmd/prompt-display.ts @@ -1,6 +1,6 @@ const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" }) -function promptOffsetWidth(value: string) { +export function promptOffsetWidth(value: string) { let width = 0 for (const part of graphemes.segment(value)) { // Textarea offsets count newlines as one position; Bun.stringWidth counts them as zero. 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 966ba932d4..d2877dd77f 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -25,10 +25,11 @@ import { useSync } from "@tui/context/sync" import { useEvent } from "@tui/context/event" import { editorSelectionKey, useEditorContext, type EditorSelection } from "@tui/context/editor" import { MessageID, PartID } from "@/session/schema" +import { promptOffsetWidth } from "@/cli/cmd/prompt-display" import { createStore, produce, unwrap } from "solid-js/store" import { usePromptHistory, type PromptInfo } from "./history" import { computePromptTraits } from "./traits" -import { assign, expandPastedTextPlaceholders } from "./part" +import { assign, expandPastedTextPlaceholders, expandTrackedPastedText } from "./part" import { usePromptStash } from "./stash" import { DialogStash } from "../dialog-stash" import { type AutocompleteRef, Autocomplete } from "./autocomplete" @@ -1109,23 +1110,15 @@ export function Prompt(props: PromptProps) { } const messageID = MessageID.ascending() - let inputText = store.prompt.input - - // Expand pasted text inline before submitting - const allExtmarks = input.extmarks.getAllForTypeId(promptPartTypeId) - const sortedExtmarks = allExtmarks.sort((a: { start: number }, b: { start: number }) => b.start - a.start) - - for (const extmark of sortedExtmarks) { - const partIndex = store.extmarkToPartIndex.get(extmark.id) - if (partIndex !== undefined) { - const part = store.prompt.parts[partIndex] - if (part?.type === "text" && part.text) { - const before = inputText.slice(0, extmark.start) - const after = inputText.slice(extmark.end) - inputText = before + part.text + after - } - } - } + const inputText = expandTrackedPastedText( + store.prompt.input, + input.extmarks.getAllForTypeId(promptPartTypeId).flatMap((extmark) => { + const partIndex = store.extmarkToPartIndex.get(extmark.id) + const part = partIndex === undefined ? undefined : store.prompt.parts[partIndex] + if (part?.type !== "text") return [] + return [{ start: extmark.start, end: extmark.end, text: part.text }] + }), + ) // Filter out text parts (pasted content) since they're now expanded inline const nonTextParts = store.prompt.parts.filter((part) => part.type !== "text") @@ -1242,9 +1235,9 @@ export function Prompt(props: PromptProps) { const exit = useExit() function pasteText(text: string, virtualText: string) { - const currentOffset = input.visualCursor.offset + const currentOffset = input.cursorOffset const extmarkStart = currentOffset - const extmarkEnd = extmarkStart + virtualText.length + const extmarkEnd = extmarkStart + promptOffsetWidth(virtualText) input.insertText(virtualText + " ") @@ -1336,7 +1329,7 @@ export function Prompt(props: PromptProps) { } async function pasteAttachment(file: { filename?: string; filepath?: string; content: string; mime: string }) { - const currentOffset = input.visualCursor.offset + const currentOffset = input.cursorOffset const extmarkStart = currentOffset const pdf = file.mime === "application/pdf" const count = store.prompt.parts.filter((x) => { diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/part.ts b/packages/opencode/src/cli/cmd/tui/component/prompt/part.ts index c5ab85bc1e..4ef870492a 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/part.ts +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/part.ts @@ -1,4 +1,5 @@ import { PartID } from "@/session/schema" +import { displaySlice } from "@/cli/cmd/prompt-display" import type { PromptInfo } from "./history" type Item = PromptInfo["parts"][number] @@ -21,3 +22,13 @@ export function expandPastedTextPlaceholders(text: string, parts: PromptInfo["pa return result.replace(part.source.text.value, part.text) }, text) } + +export function expandTrackedPastedText(text: string, ranges: { start: number; end: number; text: string }[]) { + return ranges + .slice() + .sort((a, b) => b.start - a.start) + .reduce( + (result, part) => displaySlice(result, 0, part.start) + part.text + displaySlice(result, part.end), + text, + ) +} diff --git a/packages/opencode/test/cli/cmd/tui/prompt-part.test.ts b/packages/opencode/test/cli/cmd/tui/prompt-part.test.ts index 326d3e624d..661a368d1c 100644 --- a/packages/opencode/test/cli/cmd/tui/prompt-part.test.ts +++ b/packages/opencode/test/cli/cmd/tui/prompt-part.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test" import type { PromptInfo } from "../../../../src/cli/cmd/tui/component/prompt/history" -import { assign, strip } from "../../../../src/cli/cmd/tui/component/prompt/part" +import { assign, expandTrackedPastedText, strip } from "../../../../src/cli/cmd/tui/component/prompt/part" describe("prompt part", () => { test("strip removes persisted ids from reused file parts", () => { @@ -44,4 +44,36 @@ describe("prompt part", () => { url: "data:image/png;base64,abc", }) }) + + test("expandTrackedPastedText preserves wide characters around pasted text", () => { + const marker = "[Pasted ~3 lines]" + const prefix = "你好你好\n" + + expect( + expandTrackedPastedText(prefix + marker + "\n阿斯顿法国红酒看来", [ + { + start: Bun.stringWidth("你好你好") + 1, + end: Bun.stringWidth("你好你好") + 1 + Bun.stringWidth(marker), + text: "public:\n\tvoid ExecuteTask();\nprivate:", + }, + ]), + ).toBe( + "你好你好\npublic:\n\tvoid ExecuteTask();\nprivate:\n阿斯顿法国红酒看来", + ) + }) + + test("expandTrackedPastedText only expands the tracked placeholder occurrence", () => { + const marker = "[Pasted ~3 lines]" + const prefix = `keep ${marker} then ` + + expect( + expandTrackedPastedText(prefix + marker + " tail", [ + { + start: Bun.stringWidth(prefix), + end: Bun.stringWidth(prefix + marker), + text: "alpha\nbeta\ngamma", + }, + ]), + ).toBe(`keep ${marker} then alpha\nbeta\ngamma tail`) + }) }) From 2d2f587bfa4f326e3e86dd5b21112f42ddf705a9 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Mon, 1 Jun 2026 14:15:57 +0530 Subject: [PATCH 053/840] fix(opencode): avoid nullable webfetch format schema (#30215) --- packages/opencode/src/tool/webfetch.ts | 2 +- .../__snapshots__/parameters.test.ts.snap | 21 +++++++------------ .../opencode/test/tool/parameters.test.ts | 18 ++++++++++++++-- 3 files changed, 24 insertions(+), 17 deletions(-) diff --git a/packages/opencode/src/tool/webfetch.ts b/packages/opencode/src/tool/webfetch.ts index f8a4b6233a..e615034545 100644 --- a/packages/opencode/src/tool/webfetch.ts +++ b/packages/opencode/src/tool/webfetch.ts @@ -17,7 +17,7 @@ export const Parameters = Schema.Struct({ description: "The format to return the content in (text, markdown, or html). Defaults to markdown.", default: "markdown", }) - .pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed("markdown" as const))), + .pipe(Schema.withDecodingDefault(Effect.succeed("markdown" as const))), timeout: Schema.optional(Schema.Number).annotate({ description: "Optional timeout in seconds (max 120)" }), }) diff --git a/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap b/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap index 1be32979dd..0bf2357eff 100644 --- a/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap +++ b/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap @@ -396,21 +396,14 @@ exports[`tool parameters JSON Schema (wire shape) webfetch 1`] = ` "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "format": { - "anyOf": [ - { - "default": "markdown", - "description": "The format to return the content in (text, markdown, or html). Defaults to markdown.", - "enum": [ - "text", - "markdown", - "html", - ], - "type": "string", - }, - { - "type": "null", - }, + "default": "markdown", + "description": "The format to return the content in (text, markdown, or html). Defaults to markdown.", + "enum": [ + "text", + "markdown", + "html", ], + "type": "string", }, "timeout": { "description": "Optional timeout in seconds (max 120)", diff --git a/packages/opencode/test/tool/parameters.test.ts b/packages/opencode/test/tool/parameters.test.ts index 3a124be81b..4e56c61d23 100644 --- a/packages/opencode/test/tool/parameters.test.ts +++ b/packages/opencode/test/tool/parameters.test.ts @@ -82,6 +82,13 @@ describe("tool parameters", () => { properties: { value: { minimum: Number.MIN_SAFE_INTEGER, maximum: Number.MAX_SAFE_INTEGER } }, }) }) + + test("does not expose defaulted optional keys as nullable", () => { + expect(toJsonSchema(WebFetch)).toMatchObject({ + properties: { format: { type: "string", enum: ["text", "markdown", "html"], default: "markdown" } }, + }) + expect(toJsonSchema(WebFetch).properties?.format).not.toHaveProperty("anyOf") + }) }) describe("apply_patch", () => { @@ -257,8 +264,15 @@ describe("tool parameters", () => { }) describe("webfetch", () => { - test("accepts url-only", () => { - expect(parse(WebFetch, { url: "https://example.com" }).url).toBe("https://example.com") + test("defaults omitted format to markdown", () => { + expect(parse(WebFetch, { url: "https://example.com" })).toEqual({ + url: "https://example.com", + format: "markdown", + }) + expect(parse(WebFetch, { url: "https://example.com", format: undefined })).toEqual({ + url: "https://example.com", + format: "markdown", + }) }) }) From 68676f2c5c993ae90b5a2c50dd2cd724da47253c Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Mon, 1 Jun 2026 08:47:26 +0000 Subject: [PATCH 054/840] chore: generate --- packages/opencode/src/cli/cmd/tui/component/prompt/part.ts | 5 +---- packages/opencode/test/cli/cmd/tui/prompt-part.test.ts | 4 +--- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/part.ts b/packages/opencode/src/cli/cmd/tui/component/prompt/part.ts index 4ef870492a..55b2e9a3f1 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/part.ts +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/part.ts @@ -27,8 +27,5 @@ export function expandTrackedPastedText(text: string, ranges: { start: number; e return ranges .slice() .sort((a, b) => b.start - a.start) - .reduce( - (result, part) => displaySlice(result, 0, part.start) + part.text + displaySlice(result, part.end), - text, - ) + .reduce((result, part) => displaySlice(result, 0, part.start) + part.text + displaySlice(result, part.end), text) } diff --git a/packages/opencode/test/cli/cmd/tui/prompt-part.test.ts b/packages/opencode/test/cli/cmd/tui/prompt-part.test.ts index 661a368d1c..d4158e3636 100644 --- a/packages/opencode/test/cli/cmd/tui/prompt-part.test.ts +++ b/packages/opencode/test/cli/cmd/tui/prompt-part.test.ts @@ -57,9 +57,7 @@ describe("prompt part", () => { text: "public:\n\tvoid ExecuteTask();\nprivate:", }, ]), - ).toBe( - "你好你好\npublic:\n\tvoid ExecuteTask();\nprivate:\n阿斯顿法国红酒看来", - ) + ).toBe("你好你好\npublic:\n\tvoid ExecuteTask();\nprivate:\n阿斯顿法国红酒看来") }) test("expandTrackedPastedText only expands the tracked placeholder occurrence", () => { From d85f8cd4d850dc9477542e208e72e63df1ec7f50 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Mon, 1 Jun 2026 16:08:23 +0530 Subject: [PATCH 055/840] fix(core): contain lsp warmup defects (#30226) --- packages/opencode/src/tool/read.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts index 33bff77b9f..3230687287 100644 --- a/packages/opencode/src/tool/read.ts +++ b/packages/opencode/src/tool/read.ts @@ -87,7 +87,8 @@ export const ReadTool = Tool.define( }) const warm = Effect.fn("ReadTool.warm")(function* (filepath: string) { - yield* lsp.touchFile(filepath).pipe(Effect.ignore, Effect.forkIn(scope)) + // LSP warm-up is optional; do not let a background defect fail an otherwise successful read. + yield* lsp.touchFile(filepath).pipe(Effect.ignoreCause, Effect.forkIn(scope)) }) const readSample = Effect.fn("ReadTool.readSample")(function* ( From 6a3b2f339a656c5dbbb6dfae43b26388b2398053 Mon Sep 17 00:00:00 2001 From: Simon Klee Date: Mon, 1 Jun 2026 15:11:45 +0200 Subject: [PATCH 056/840] add run --replay mode (#30239) --- bun.lock | 30 +- package.json | 6 +- packages/opencode/src/cli/cmd/run.ts | 2 +- packages/opencode/src/cli/cmd/run/footer.ts | 40 +- .../src/cli/cmd/run/runtime.lifecycle.ts | 44 +- .../opencode/src/cli/cmd/run/runtime.queue.ts | 23 +- packages/opencode/src/cli/cmd/run/runtime.ts | 81 ++- .../opencode/src/cli/cmd/run/session-data.ts | 5 + .../src/cli/cmd/run/session-replay.ts | 111 ++++- .../src/cli/cmd/run/stream.transport.ts | 232 ++++++++- packages/opencode/src/cli/cmd/run/types.ts | 15 + .../__snapshots__/help-snapshots.test.ts.snap | 2 +- .../test/cli/run/runtime.queue.test.ts | 5 +- .../test/cli/run/session-replay.test.ts | 284 ++++++++++- .../test/cli/run/stream.transport.test.ts | 464 +++++++++++++++++- packages/plugin/package.json | 6 +- 16 files changed, 1292 insertions(+), 58 deletions(-) diff --git a/bun.lock b/bun.lock index 74b6df0fc8..e9f71f3388 100644 --- a/bun.lock +++ b/bun.lock @@ -611,9 +611,9 @@ "typescript": "catalog:", }, "peerDependencies": { - "@opentui/core": ">=0.3.0", - "@opentui/keymap": ">=0.3.0", - "@opentui/solid": ">=0.3.0", + "@opentui/core": ">=0.3.1", + "@opentui/keymap": ">=0.3.1", + "@opentui/solid": ">=0.3.1", }, "optionalPeers": [ "@opentui/core", @@ -862,9 +862,9 @@ "@npmcli/arborist": "9.4.0", "@octokit/rest": "22.0.0", "@openauthjs/openauth": "0.0.0-20250322224806", - "@opentui/core": "0.3.0", - "@opentui/keymap": "0.3.0", - "@opentui/solid": "0.3.0", + "@opentui/core": "0.3.1", + "@opentui/keymap": "0.3.1", + "@opentui/solid": "0.3.1", "@pierre/diffs": "1.1.0-beta.18", "@playwright/test": "1.59.1", "@sentry/solid": "10.36.0", @@ -1758,23 +1758,23 @@ "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="], - "@opentui/core": ["@opentui/core@0.3.0", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.3.0", "@opentui/core-darwin-x64": "0.3.0", "@opentui/core-linux-arm64": "0.3.0", "@opentui/core-linux-x64": "0.3.0", "@opentui/core-win32-arm64": "0.3.0", "@opentui/core-win32-x64": "0.3.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-wvNESYGYGRLuvarZ3QY4CTB+BziZ/j6Snd9qRKD4fQ7SF6G4UpYElLTFrg7uzRo1v7WJTqbquymcTvWEHMnpYA=="], + "@opentui/core": ["@opentui/core@0.3.1", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.3.1", "@opentui/core-darwin-x64": "0.3.1", "@opentui/core-linux-arm64": "0.3.1", "@opentui/core-linux-x64": "0.3.1", "@opentui/core-win32-arm64": "0.3.1", "@opentui/core-win32-x64": "0.3.1" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-kQFSsSCgtlasSqTigCgKmM67xaquGvTg+vwimDnFSZtcBEt4E3dz7qLrbeh5FVvTA+RMbwe+Bozq03PW+SgjXw=="], - "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.3.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-/eDfAcutAHJqR9spwHMLuo6LMqngymev/m+i6uqlk98gX1EJiJe2pJ16sKbp3RctgH/Gz/8TYOhVHpPGYJl7yQ=="], + "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.3.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-krvVfiBpeBY+727R8yogdqIcxkK3RUVcI97bqjl8jTeDMcWOkFFfHezssRMPmbR5x++1tX669Fz3fuxoe7XUIg=="], - "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.3.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-/j6EWAvdwhz1wU/mWfXepAf3+NuMYz2Ic5ozaid5LdwIpPomIkM9yCUDm76mQhRBbjsAl/7UeSeUA0qSCMSZBg=="], + "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.3.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-D/6ec5H8SPpSBMr01/sqgSddIl1Qc1QMKsDl/wV5MpbxYc7Qvie9qlNvvoSsWNfAXAbafLRb1jQBzouk41cp1w=="], - "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.3.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-uUFVT3V35KkM1m8gaLmRcTV9dsJzXnxwM+dv6+NjScx0W/Y0CJKbW9wDYwnLyPnBNgaFUi171zmJra5gTtFTsw=="], + "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.3.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-E/FFBoAsWJyS/EO/cF7h7DuEENYa9nAdSv1W/TIyKXpBisN6K3U1Xgbk528TkfWjrwJjhGs+9OMYdXuAHd5LTw=="], - "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.3.0", "", { "os": "linux", "cpu": "x64" }, "sha512-73bNNNU2OaqZQLIlvzDOdAzQmzBAqf+cSilmJ+Y9JnybrBn1d6VShC66+V4xxIgonq1swk7BD+SUHYbwwGilQA=="], + "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.3.1", "", { "os": "linux", "cpu": "x64" }, "sha512-Btb7Q4BOC55Aj2qCs0VoxGuj87DNfUEaSx0z89oeU4npTN+6SpJApyGZTCNNeSe2sdmOGeh/8eAR4X96ORjcKg=="], - "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.3.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-jg5KrV/4mVQ0mdkcL9CtQVtBk0NAtQ+2rCKoZ/jNHB6GxGK0ot9vDV6P3X68hZVkvpb2pdXfg6GRsZJ+Np4hZA=="], + "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.3.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-+lt24u3KwEPG69oXDOLz9N484wPcAHvrPbDNU77OT6DvWew+StAjh40eY+Zeu0TkTNDWfj7qnQKV0GKWtFA3cw=="], - "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.3.0", "", { "os": "win32", "cpu": "x64" }, "sha512-kiM3C5bwQBTfrJKAOfb+L3U6MMkPSQlMhAERlLMjqSurc+llcyqygr/wbXSvfAqJtKlIpf3MKJRnVFTyfRIdng=="], + "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.3.1", "", { "os": "win32", "cpu": "x64" }, "sha512-eVkKMYirYgpn92lI0YT/GKru4J+UiXjzwyzNRFX+P59OHXvL3GFdqJMcJmX4/zvyjg4c8HDnU79YLnyG+TlXLw=="], - "@opentui/keymap": ["@opentui/keymap@0.3.0", "", { "dependencies": { "@opentui/core": "0.3.0" }, "peerDependencies": { "@opentui/react": "0.3.0", "@opentui/solid": "0.3.0", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-lJN57DanKujy3u0IhfSMCShvXIobRjhprdkrdM3brQoX6wxk7gTFE8fTCCz9z1nINkXNsKHQ6grZO1dsT/0mzA=="], + "@opentui/keymap": ["@opentui/keymap@0.3.1", "", { "dependencies": { "@opentui/core": "0.3.1" }, "peerDependencies": { "@opentui/react": "0.3.1", "@opentui/solid": "0.3.1", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-BTj+ggsarO2uyvd6CWzvgfsekA8c4aEclbAPKPZGVjBI3Fo5+KAHUrXvteFO5qpGMANfEJTtVHoRu5cic1Nlaw=="], - "@opentui/solid": ["@opentui/solid@0.3.0", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.3.0", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-AUtNzvgkdW81Ftl0sahAy3tY1LIPSMzBw3APBC8jiDAzzPv4kYVdyWXryTxLbU2q+Pgtr57VwKwHgc5wsNrd2w=="], + "@opentui/solid": ["@opentui/solid@0.3.1", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.3.1", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-2R6wEijfMub9COTBCm8IKVj2y7+Sc4fZZjJawxk8sE6+++mzeUaokKNJTlYhZXpMju4LKMv6j9CjWkG8JYfbcg=="], "@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="], diff --git a/package.json b/package.json index 0b5119d94c..e1aed6e8b1 100644 --- a/package.json +++ b/package.json @@ -38,9 +38,9 @@ "@types/cross-spawn": "6.0.6", "@octokit/rest": "22.0.0", "@hono/zod-validator": "0.4.2", - "@opentui/core": "0.3.0", - "@opentui/keymap": "0.3.0", - "@opentui/solid": "0.3.0", + "@opentui/core": "0.3.1", + "@opentui/keymap": "0.3.1", + "@opentui/solid": "0.3.1", "ulid": "3.0.1", "@kobalte/core": "0.13.11", "@types/luxon": "3.7.1", diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index b80a2389ef..cdbf4562d5 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -221,7 +221,7 @@ export const RunCommand = effectCmd({ .option("replay", { type: "boolean", default: false, - describe: "replay visible session history on interactive resume", + describe: "replay interactive session history on resume and after resize", }) .option("replay-limit", { type: "number", diff --git a/packages/opencode/src/cli/cmd/run/footer.ts b/packages/opencode/src/cli/cmd/run/footer.ts index 16c6b24207..90ca009e3e 100644 --- a/packages/opencode/src/cli/cmd/run/footer.ts +++ b/packages/opencode/src/cli/cmd/run/footer.ts @@ -171,6 +171,7 @@ export class RunFooter implements FooterApi { private queue: StreamCommit[] = [] private pending = false private flushing: Promise = Promise.resolve() + private flushError: unknown // Fixed portion of footer height above the textarea. private base: number private rows = TEXTAREA_MIN_ROWS @@ -204,6 +205,15 @@ export class RunFooter implements FooterApi { private requestExitHandler: (() => boolean) | undefined private scrollback: RunScrollbackStream + private createScrollback(wrote: boolean): RunScrollbackStream { + return new RunScrollbackStream(this.renderer, this.options.theme, { + diffStyle: this.options.diffStyle, + wrote, + sessionID: this.options.sessionID, + treeSitterClient: this.options.treeSitterClient, + }) + } + constructor( private renderer: CliRenderer, private options: RunFooterOptions, @@ -257,12 +267,7 @@ export class RunFooter implements FooterApi { this.queuedPrompts = queuedPrompts this.setQueuedPrompts = setQueuedPrompts this.base = Math.max(1, renderer.footerHeight - TEXTAREA_MIN_ROWS) - this.scrollback = new RunScrollbackStream(renderer, options.theme, { - diffStyle: options.diffStyle, - wrote: options.wrote, - sessionID: options.sessionID, - treeSitterClient: options.treeSitterClient, - }) + this.scrollback = this.createScrollback(options.wrote ?? false) this.renderer.on(CliRenderEvents.DESTROY, this.handleDestroy) @@ -465,7 +470,9 @@ export class RunFooter implements FooterApi { }, ), ) - .catch(() => {}) + .catch((error) => { + this.flushError = error + }) } private present(view: FooterView): void { @@ -523,6 +530,12 @@ export class RunFooter implements FooterApi { } return this.flushing.then(async () => { + if (this.flushError !== undefined) { + const error = this.flushError + this.flushError = undefined + throw error + } + if (this.isGone) { return } @@ -535,6 +548,15 @@ export class RunFooter implements FooterApi { }) } + public resetForReplay(wrote: boolean): void { + if (this.isGone) { + return + } + + this.scrollback.destroy() + this.scrollback = this.createScrollback(wrote) + } + public close(): void { if (this.closed) { return @@ -936,6 +958,8 @@ export class RunFooter implements FooterApi { }, ), ) - .catch(() => {}) + .catch((error) => { + this.flushError = error + }) } } diff --git a/packages/opencode/src/cli/cmd/run/runtime.lifecycle.ts b/packages/opencode/src/cli/cmd/run/runtime.lifecycle.ts index bc44aafa96..389f868ee8 100644 --- a/packages/opencode/src/cli/cmd/run/runtime.lifecycle.ts +++ b/packages/opencode/src/cli/cmd/run/runtime.lifecycle.ts @@ -8,7 +8,7 @@ // // Also wires SIGINT so Ctrl-c clears a live prompt draft first, then falls // back to the usual two-press exit sequence through RunFooter.requestExit(). -import { createCliRenderer, type CliRenderer, type ScrollbackWriter } from "@opentui/core" +import { CliRenderEvents, createCliRenderer, type CliRenderer, type ScrollbackWriter } from "@opentui/core" import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui" import { Session as SessionApi } from "@/session/session" import { registerOpencodeKeymap } from "@/cli/cmd/tui/keymap" @@ -75,6 +75,8 @@ export type LifecycleInput = { export type Lifecycle = { footer: FooterApi + onResize(fn: () => void): () => void + resetForReplay(input: { sessionTitle?: string; sessionID?: string; history: RunPrompt[] }): Promise close(input: { showExit: boolean; sessionTitle?: string; sessionID?: string; history?: RunPrompt[] }): Promise } @@ -307,6 +309,46 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise { + if (width === renderer.terminalWidth && height === renderer.terminalHeight) { + return + } + + width = renderer.terminalWidth + height = renderer.terminalHeight + fn() + } + renderer.on(CliRenderEvents.RESIZE, resize) + return () => renderer.off(CliRenderEvents.RESIZE, resize) + }, + async resetForReplay(next) { + if (closed || renderer.isDestroyed || footer.isClosed) { + throw new Error("runtime closed") + } + + await footer.idle() + if (closed || renderer.isDestroyed || footer.isClosed) { + throw new Error("runtime closed") + } + + footer.resetForReplay(true) + renderer.resetSplitFooterForReplay({ clearSavedLines: true }) + const splash = splashInfo(next.sessionTitle ?? input.sessionTitle, next.history) + renderer.writeToScrollback( + entrySplash({ + ...splashMeta({ + title: splash.title, + session_id: next.sessionID ?? input.getSessionID?.() ?? input.sessionID, + }), + theme: theme.splash, + showSession: splash.showSession, + }), + ) + renderer.requestRender() + }, close, } } catch (error) { diff --git a/packages/opencode/src/cli/cmd/run/runtime.queue.ts b/packages/opencode/src/cli/cmd/run/runtime.queue.ts index 64172e8280..e575647afd 100644 --- a/packages/opencode/src/cli/cmd/run/runtime.queue.ts +++ b/packages/opencode/src/cli/cmd/run/runtime.queue.ts @@ -162,7 +162,14 @@ export async function runPromptQueue(input: QueueInput): Promise { continue } - state.active = prompt + const sent = + prompt.mode === "shell" + ? prompt + : { + ...prompt, + messageID: prompt.messageID ?? queued?.messageID ?? MessageID.ascending(), + } + state.active = sent emit( { @@ -185,18 +192,24 @@ export async function runPromptQueue(input: QueueInput): Promise { break } - if (prompt.mode !== "shell") { - const commit = { kind: "user", text: prompt.text, phase: "start", source: "system" } as const + if (sent.mode !== "shell") { + const commit = { + kind: "user", + text: sent.text, + phase: "start", + source: "system", + messageID: sent.messageID, + } as const input.trace?.write("ui.commit", commit) input.footer.append(commit) } - input.onSend?.(prompt) + input.onSend?.(sent) if (state.closed) { break } - const task = input.run(prompt, ctrl.signal).then( + const task = input.run(sent, ctrl.signal).then( () => ({ type: "done" as const }), (error) => ({ type: "error" as const, error }), ) diff --git a/packages/opencode/src/cli/cmd/run/runtime.ts b/packages/opencode/src/cli/cmd/run/runtime.ts index 2e2b07398e..01665db1c9 100644 --- a/packages/opencode/src/cli/cmd/run/runtime.ts +++ b/packages/opencode/src/cli/cmd/run/runtime.ts @@ -14,13 +14,14 @@ // 4. runs the prompt queue until the footer closes. import { createOpencodeClient } from "@opencode-ai/sdk/v2" import { Flag } from "@opencode-ai/core/flag/flag" +import { MessageID } from "@/session/schema" import { createRunDemo } from "./demo" import { resolveModelInfo, resolveRunTuiConfig, resolveSessionInfo } from "./runtime.boot" import { createRuntimeLifecycle } from "./runtime.lifecycle" import { recordRunSpanError, setRunSpanAttributes, withRunSpan } from "./otel" import { trace } from "./trace" import { cycleVariant, formatModelLabel, resolveSavedVariant, resolveVariant, saveVariant } from "./variant.shared" -import type { RunInput, RunPrompt, RunProvider } from "./types" +import type { LocalReplayAnchor, LocalReplayRow, RunInput, RunPrompt, RunProvider, StreamCommit } from "./types" /** @internal Exported for testing */ export { pickVariant, resolveVariant } from "./variant.shared" @@ -114,6 +115,7 @@ type RuntimeState = { activeVariant: string | undefined sessionID: string history: RunPrompt[] + localRows: LocalReplayRow[] sessionTitle?: string agent: string | undefined switching?: Promise @@ -139,6 +141,9 @@ function variantsFor(providers: RunProvider[], model: RunInput["model"]) { return Object.keys(providers.find((item) => item.id === model.providerID)?.models?.[model.modelID]?.variants ?? {}) } +const REPLAY_RESIZE_DELAY = 250 +const LOCAL_REPLAY_ROW_LIMIT = 100 + async function resolveExitTitle( ctx: BootContext, input: RunRuntimeInput, @@ -196,6 +201,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise { activeVariant: resolveVariant(ctx.variant, session.variant, savedVariant, []), sessionID: ctx.sessionID, history: [...session.history], + localRows: [], sessionTitle: ctx.sessionTitle, agent: ctx.agent, } @@ -374,6 +380,9 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise { }, }) const footer = shell.footer + const rememberLocal = (commit: StreamCommit, after?: LocalReplayAnchor) => { + state.localRows = [...state.localRows, { commit, after }].slice(-LOCAL_REPLAY_ROW_LIMIT) + } const loadCatalog = async (): Promise => { if (footer.isClosed) { @@ -510,6 +519,36 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise { return next } + let replayResizeTimer: ReturnType | undefined + const offResize = input.replay + ? shell.onResize(() => { + if (replayResizeTimer) { + clearTimeout(replayResizeTimer) + } + + replayResizeTimer = setTimeout(() => { + replayResizeTimer = undefined + if (footer.isClosed || !state.stream) { + return + } + + void state.stream + .then((item) => + item.handle.replayOnResize({ + localRows: () => state.localRows, + reset: () => + shell.resetForReplay({ + sessionTitle: state.sessionTitle, + sessionID: state.sessionID, + history: state.history, + }), + }), + ) + .catch(() => {}) + }, REPLAY_RESIZE_DELAY) + }) + : () => {} + const runQueue = async () => { let includeFiles = true if (state.demo) { @@ -525,6 +564,15 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise { onSend: (prompt) => { state.shown = true state.history.push(prompt) + if (prompt.mode !== "shell") { + rememberLocal({ + kind: "user", + text: prompt.text, + phase: "start", + source: "system", + messageID: prompt.messageID, + }) + } }, onNewSession: createSession ? async () => { @@ -545,6 +593,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise { state.sessionTitle = created.sessionTitle state.agent = created.agent ?? state.agent state.history = [] + state.localRows = [] includeFiles = true state.demo = input.demo ? createRunDemo({ @@ -598,12 +647,15 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise { status: "failed to start new session", }, }) - footer.append({ + const commit = { kind: "error", text: error instanceof Error ? error.message : String(error), phase: "start", source: "system", - }) + messageID: MessageID.ascending(), + } as const + rememberLocal(commit) + footer.append(commit) } } : undefined, @@ -614,6 +666,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise { await state.switching?.catch(() => {}) + let outputAnchor: LocalReplayAnchor | undefined return withRunSpan( "RunInteractive.turn", { @@ -644,8 +697,16 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise { prompt, files: input.files, includeFiles, + onVisibleOutput: (anchor) => { + outputAnchor = anchor + }, signal, }) + if (prompt.messageID) { + state.localRows = state.localRows.filter( + (row) => row.commit.kind !== "user" || row.commit.messageID !== prompt.messageID, + ) + } includeFiles = false } catch (error) { if (signal.aborted || footer.isClosed) { @@ -656,7 +717,15 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise { const text = (await state.stream?.then((item) => item.mod).catch(() => undefined))?.formatUnknownError(error) ?? (error instanceof Error ? error.message : String(error)) - footer.append({ kind: "error", text, phase: "start", source: "system" }) + const commit = { + kind: "error", + text, + phase: "start", + source: "system", + messageID: prompt.messageID, + } as const + rememberLocal(commit, outputAnchor) + footer.append(commit) } }, ) @@ -683,6 +752,10 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise { try { await runQueue() } finally { + if (replayResizeTimer) { + clearTimeout(replayResizeTimer) + } + offResize() await state.stream?.then((item) => item.handle.close()).catch(() => {}) } } finally { diff --git a/packages/opencode/src/cli/cmd/run/session-data.ts b/packages/opencode/src/cli/cmd/run/session-data.ts index 4a3a49fb83..03951ec4c9 100644 --- a/packages/opencode/src/cli/cmd/run/session-data.ts +++ b/packages/opencode/src/cli/cmd/run/session-data.ts @@ -60,6 +60,7 @@ type SessionCommit = StreamCommit // - part: part ID → "assistant" | "reasoning" (text parts only) // - text: part ID → full accumulated text so far // - sent: part ID → byte offset of last flushed text (for incremental output) +// - visible: part ID → rendered text for an active part after display transforms // - end: part IDs whose time.end has arrived (part is finished) // - shell: shell call ID → chosen transcript source for direct shell calls // - echo: message ID → bash outputs to strip from the next assistant chunk @@ -82,6 +83,7 @@ export type SessionData = { part: Map text: Map sent: Map + visible: Map end: Set echo: Map> } @@ -119,6 +121,7 @@ export function createSessionData( part: new Map(), text: new Map(), sent: new Map(), + visible: new Map(), end: new Set(), echo: new Map(), } @@ -538,6 +541,7 @@ function flushPart(data: SessionData, commits: SessionCommit[], partID: string, if (chunk) { data.sent.set(partID, text.length) + data.visible.set(partID, (data.visible.get(partID) ?? "") + chunk) commits.push({ kind, text: chunk, @@ -567,6 +571,7 @@ function drop(data: SessionData, partID: string) { data.part.delete(partID) data.text.delete(partID) data.sent.delete(partID) + data.visible.delete(partID) data.msg.delete(partID) data.end.delete(partID) } diff --git a/packages/opencode/src/cli/cmd/run/session-replay.ts b/packages/opencode/src/cli/cmd/run/session-replay.ts index f43bff5bed..dde41f3249 100644 --- a/packages/opencode/src/cli/cmd/run/session-replay.ts +++ b/packages/opencode/src/cli/cmd/run/session-replay.ts @@ -1,7 +1,7 @@ import type { Event, PermissionRequest, QuestionRequest } from "@opencode-ai/sdk/v2" import { bootstrapSessionData, createSessionData, reduceSessionData, type SessionData } from "./session-data" import { messagePrompt, type SessionMessages } from "./session.shared" -import type { FooterPatch, StreamCommit } from "./types" +import type { FooterPatch, LocalReplayRow, StreamCommit } from "./types" type ReplayInput = { messages: SessionMessages @@ -186,3 +186,112 @@ export function replaySession(input: ReplayInput): SessionReplay { patch: replayPatch(data, patch), } } + +export function replayLocalRows(messages: SessionMessages, commits: StreamCommit[], rows: LocalReplayRow[]): StreamCommit[] { + const persisted = new Set(messages.map((message) => message.info.id)) + return rows.reduce((out, local) => { + const row = local.commit + if (row.kind === "user" && row.messageID && persisted.has(row.messageID)) { + return out + } + + if (!row.messageID) { + return [...out, row] + } + + const exact = local.after + ? out.findIndex( + (commit) => + commit.kind === local.after?.kind && + commit.text === local.after.text && + commit.phase === local.after.phase && + commit.toolState === local.after.toolState && + (local.after.partID ? commit.partID === local.after.partID : commit.messageID === local.after.messageID), + ) + : -1 + const anchored = + exact !== -1 + ? exact + : local.after + ? out.findLastIndex((commit) => + local.after?.partID + ? commit.partID === local.after.partID + : commit.kind === local.after?.kind && commit.messageID === local.after.messageID, + ) + : -1 + if (anchored !== -1) { + const commit = out[anchored] + const visible = local.after?.visible + if (commit && visible && commit.text.startsWith(visible) && commit.text.length > visible.length) { + return [ + ...out.slice(0, anchored), + { ...commit, text: visible }, + row, + { ...commit, text: commit.text.slice(visible.length) }, + ...out.slice(anchored + 1), + ] + } + + return [...out.slice(0, anchored + 1), row, ...out.slice(anchored + 1)] + } + + const after = out.findIndex((commit) => commit.kind === "user" && commit.messageID === row.messageID) + if (after !== -1) { + return [...out.slice(0, after + 1), row, ...out.slice(after + 1)] + } + + const before = out.findIndex((commit) => commit.messageID && row.messageID! < commit.messageID) + if (before === -1) { + return [...out, row] + } + + return [...out.slice(0, before), row, ...out.slice(before)] + }, commits) +} + +export function replayActiveText(data: SessionData, current: SessionData): StreamCommit[] { + return [...current.part.entries()].flatMap(([partID, kind]) => { + if (kind === "user" || current.end.has(partID) || data.ids.has(partID)) { + return [] + } + + const text = current.text.get(partID) ?? "" + const existing = data.text.get(partID) ?? "" + const sent = current.sent.get(partID) ?? 0 + const existingSent = data.sent.get(partID) ?? 0 + const visible = current.visible.get(partID) ?? "" + const existingVisible = data.visible.get(partID) ?? "" + if (!text.startsWith(existing) || existingSent > sent || !visible.startsWith(existingVisible)) { + return [] + } + + data.part.set(partID, kind) + data.text.set(partID, text) + data.sent.set(partID, sent) + data.visible.set(partID, visible) + const messageID = current.msg.get(partID) + if (messageID) { + data.msg.set(partID, messageID) + const role = current.role.get(messageID) + if (role) { + data.role.set(messageID, role) + } + } + + const chunk = visible.slice(existingVisible.length) + if (!chunk) { + return [] + } + + return [ + { + kind, + text: chunk, + phase: "progress", + source: kind, + ...(messageID ? { messageID } : {}), + partID, + }, + ] satisfies StreamCommit[] + }) +} diff --git a/packages/opencode/src/cli/cmd/run/stream.transport.ts b/packages/opencode/src/cli/cmd/run/stream.transport.ts index ca6a55d1d2..5641b8f072 100644 --- a/packages/opencode/src/cli/cmd/run/stream.transport.ts +++ b/packages/opencode/src/cli/cmd/run/stream.transport.ts @@ -27,7 +27,7 @@ import { reduceSessionData, type SessionData, } from "./session-data" -import { replaySession } from "./session-replay" +import { replayActiveText, replayLocalRows, replaySession } from "./session-replay" import { bootstrapSubagentCalls, bootstrapSubagentData, @@ -51,6 +51,8 @@ import type { FooterSubagentState, FooterSubagentTab, FooterView, + LocalReplayAnchor, + LocalReplayRow, RunFilePart, RunInput, RunPrompt, @@ -81,6 +83,7 @@ type Wait = { tick: number armed: boolean live: boolean + onVisibleOutput?: (anchor: LocalReplayAnchor) => void done: Deferred.Deferred } @@ -91,15 +94,22 @@ export type SessionTurnInput = { prompt: RunPrompt files: RunFilePart[] includeFiles: boolean + onVisibleOutput?: (anchor: LocalReplayAnchor) => void signal?: AbortSignal } export type SessionTransport = { runPromptTurn(input: SessionTurnInput): Promise selectSubagent(sessionID: string | undefined): void + replayOnResize(input: SessionResizeReplayInput): Promise close(): Promise } +export type SessionResizeReplayInput = { + localRows: () => LocalReplayRow[] + reset: () => Promise +} + type State = { data: SessionData subagent: SubagentData @@ -115,6 +125,7 @@ type State = { type TransportService = { readonly runPromptTurn: (input: SessionTurnInput) => Effect.Effect readonly selectSubagent: (sessionID: string | undefined) => Effect.Effect + readonly replayOnResize: (input: SessionResizeReplayInput) => Effect.Effect readonly close: () => Effect.Effect } @@ -440,6 +451,9 @@ function createLayer(input: StreamInput) { blockers: new Map(), } let booting = true + let replaying = false + let replayDisabled = false + let replayPending: SessionResizeReplayInput | undefined const buffered: Event[] = [] const replayedParts = new Set() const recovering = new Set() @@ -594,6 +608,38 @@ function createLayer(input: StreamInput) { Effect.orElseSucceed(() => []), ) + const replayMessages = () => + Effect.promise(() => + input.sdk.session.messages({ + sessionID: input.sessionID, + ...(input.replayLimit === undefined + ? {} + : { limit: Math.max(input.replayLimit, SUBAGENT_BOOTSTRAP_LIMIT) }), + }), + ).pipe(Effect.flatMap((item) => (item.error ? Effect.fail(item.error) : Effect.succeed(item.data ?? [])))) + + const replayRequests = () => + Effect.all( + [ + Effect.promise(() => input.sdk.permission.list()).pipe( + Effect.flatMap((item) => (item.error ? Effect.fail(item.error) : Effect.succeed(item.data ?? []))), + ), + Effect.promise(() => input.sdk.question.list()).pipe( + Effect.flatMap((item) => (item.error ? Effect.fail(item.error) : Effect.succeed(item.data ?? []))), + ), + ], + { concurrency: "unbounded" }, + ) + + const markReplayedParts = (data: SessionData) => { + replayedParts.clear() + for (const [partID] of data.text) { + if (data.part.has(partID)) { + replayedParts.add(partID) + } + } + } + const bootstrapSubagentHistory = Effect.fn("RunStreamTransport.bootstrapSubagentHistory")(function* ( sessions: string[], ) { @@ -681,7 +727,6 @@ function createLayer(input: StreamInput) { }) : history - replayedParts.clear() if (history) { state.data = history.data } @@ -695,14 +740,8 @@ function createLayer(input: StreamInput) { }) } - if (replay) { - for (const [partID] of replay.data.text) { - if (!replay.data.part.has(partID)) { - continue - } - - replayedParts.add(partID) - } + if (history) { + markReplayedParts(history.data) } bootstrapSubagentData({ @@ -862,6 +901,20 @@ function createLayer(input: StreamInput) { limits: input.limits(), }) state.data = next.data + const visible = next.commits.at(-1) + if (visible) { + state.wait?.onVisibleOutput?.({ + kind: visible.kind, + text: visible.text, + phase: visible.phase, + messageID: visible.messageID, + partID: visible.partID, + toolState: visible.toolState, + ...(visible.partID && state.data.visible.has(visible.partID) + ? { visible: state.data.visible.get(visible.partID) } + : {}), + }) + } if ( event.type === "message.part.updated" && @@ -910,15 +963,163 @@ function createLayer(input: StreamInput) { yield* applyEvent(event) } - if (!changed) { + const arrived = buffered.splice(0) + if (!changed && arrived.length === 0) { buffered.push(...next) return } - pending = next + pending = [...next, ...arrived] } }) + const replayOnResize: (next: SessionResizeReplayInput) => Effect.Effect = Effect.fn( + "RunStreamTransport.replayOnResize", + )(function* (next: SessionResizeReplayInput) { + if (!input.replay || replayDisabled || booting || closed || input.footer.isClosed) { + return false + } + + if (replaying) { + replayPending = next + return false + } + + const finish: () => Effect.Effect = Effect.fnUntraced(function* () { + yield* drainBuffered() + const pending = replayPending + replayPending = undefined + if (!pending || replayDisabled || closed || input.footer.isClosed) { + replaying = false + return + } + + replaying = false + yield* replayOnResize(pending).pipe(Effect.asVoid) + }) + + replayedParts.clear() + replaying = true + input.trace?.write("replay.resize.start", { + sessionID: input.sessionID, + }) + const source = yield* Effect.all([replayMessages(), replayRequests()], { concurrency: "unbounded" }).pipe( + Effect.exit, + ) + if (Exit.isFailure(source)) { + input.trace?.write("replay.resize.abort", { + sessionID: input.sessionID, + phase: "snapshot", + }) + yield* finish() + return false + } + + const [messagesList, [permissions, questions]] = source.value + const sessionPermissions = permissions.filter((item) => item.sessionID === input.sessionID) + const sessionQuestions = questions.filter((item) => item.sessionID === input.sessionID) + const snapshot = yield* Effect.try({ + try: () => { + const history = replaySession({ + messages: messagesList, + permissions: sessionPermissions, + questions: sessionQuestions, + thinking: input.thinking, + limits: input.limits(), + }) + const activeCommits = replayActiveText(history.data, state.data) + return { + history, + activeCommits, + patch: + history.data.part.size > 0 || history.data.tools.size > 0 + ? { ...history.patch, phase: "running" as const } + : history.patch, + visible: + input.replayLimit !== undefined && messagesList.length > input.replayLimit + ? replaySession({ + messages: messagesList.slice(-input.replayLimit), + permissions: sessionPermissions, + questions: sessionQuestions, + thinking: input.thinking, + limits: input.limits(), + }) + : history, + } + }, + catch: (error) => error, + }).pipe(Effect.exit) + if (Exit.isFailure(snapshot)) { + input.trace?.write("replay.resize.abort", { + sessionID: input.sessionID, + phase: "snapshot", + }) + yield* finish() + return false + } + + const idle = yield* Effect.promise(() => input.footer.idle()).pipe(Effect.exit) + if (Exit.isFailure(idle) || closed || input.footer.isClosed) { + yield* finish() + return false + } + + const reset = yield* Effect.promise(() => next.reset()).pipe(Effect.exit) + if (Exit.isFailure(reset)) { + replayDisabled = true + input.trace?.write("replay.resize.disable", { + sessionID: input.sessionID, + phase: "reset", + }) + input.footer.append({ + kind: "error", + text: "resize replay failed; disabled for this session", + phase: "start", + source: "system", + }) + yield* finish() + return false + } + + state.data = snapshot.value.history.data + for (const request of [...state.data.permissions, ...state.data.questions]) { + seedBlocker(request.id) + } + + for (const commit of replayLocalRows( + messagesList, + [...snapshot.value.visible.commits, ...snapshot.value.activeCommits], + next.localRows(), + )) { + input.trace?.write("ui.commit", commit) + input.footer.append(commit) + } + + syncFooter([], snapshot.value.patch, currentSubagentState()) + const rebuilt = yield* Effect.promise(() => input.footer.idle()).pipe(Effect.exit) + if (Exit.isFailure(rebuilt)) { + replayDisabled = true + input.trace?.write("replay.resize.disable", { + sessionID: input.sessionID, + phase: "rebuild", + }) + input.footer.append({ + kind: "error", + text: "resize replay failed; disabled for this session", + phase: "start", + source: "system", + }) + yield* finish() + return false + } + + input.trace?.write("replay.resize.complete", { + sessionID: input.sessionID, + }) + yield* finish() + return true + }) + const watch = Effect.fn("RunStreamTransport.watch")(() => Stream.fromAsyncIterable(events.stream, (error) => error instanceof Error ? error : new Error(String(error)), @@ -943,7 +1144,7 @@ function createLayer(input: StreamInput) { } const sessionID = sid(event) - if (booting) { + if (booting || replaying) { if (sessionID) { input.trace?.write("recv.event", event) buffered.push(event) @@ -1005,6 +1206,7 @@ function createLayer(input: StreamInput) { tick: state.tick, armed: false, live: false, + onVisibleOutput: next.onVisibleOutput, done: yield* Deferred.make(), } state.wait = item @@ -1020,6 +1222,7 @@ function createLayer(input: StreamInput) { const req = { sessionID: input.sessionID, + messageID: next.prompt.messageID, agent: next.agent, model: next.model, variant: next.variant, @@ -1081,6 +1284,7 @@ function createLayer(input: StreamInput) { input.sdk.session.command( { sessionID: input.sessionID, + messageID: next.prompt.messageID, agent: next.agent, model: next.model ? `${next.model.providerID}/${next.model.modelID}` : undefined, variant: next.variant, @@ -1231,6 +1435,7 @@ function createLayer(input: StreamInput) { return Service.of({ runPromptTurn, selectSubagent, + replayOnResize, close, }) }), @@ -1254,6 +1459,7 @@ export async function createSessionTransport(input: StreamInput): Promise runtime.runPromise((svc) => svc.runPromptTurn(next)), selectSubagent: (sessionID) => runtime.runSync((svc) => svc.selectSubagent(sessionID)), + replayOnResize: (next) => runtime.runPromise((svc) => svc.replayOnResize(next)), close: () => runtime.runPromise((svc) => svc.close()), } } diff --git a/packages/opencode/src/cli/cmd/run/types.ts b/packages/opencode/src/cli/cmd/run/types.ts index 8a88bb7ad9..65099394c2 100644 --- a/packages/opencode/src/cli/cmd/run/types.ts +++ b/packages/opencode/src/cli/cmd/run/types.ts @@ -309,6 +309,21 @@ export type StreamCommit = { } } +export type LocalReplayAnchor = { + kind: EntryKind + text: string + phase: StreamPhase + messageID?: string + partID?: string + toolState?: StreamToolState + visible?: string +} + +export type LocalReplayRow = { + commit: StreamCommit + after?: LocalReplayAnchor +} + // The public contract between the stream transport / prompt queue and // the footer. RunFooter implements this. The transport and queue never // touch the renderer directly -- they go through this interface. diff --git a/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap b/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap index 14882e264b..b7148ebcee 100644 --- a/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap +++ b/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap @@ -103,7 +103,7 @@ Options: --variant model variant (provider-specific reasoning effort, e.g., high, max, minimal) [string] --thinking show thinking blocks [boolean] - --replay replay visible session history on interactive resume + --replay replay interactive session history on resume and after resize [boolean] [default: false] --replay-limit cap visible interactive replay to the newest N messages [number] diff --git a/packages/opencode/test/cli/run/runtime.queue.test.ts b/packages/opencode/test/cli/run/runtime.queue.test.ts index 728e18fcfe..7eba8bb251 100644 --- a/packages/opencode/test/cli/run/runtime.queue.test.ts +++ b/packages/opencode/test/cli/run/runtime.queue.test.ts @@ -143,6 +143,7 @@ describe("run runtime queue", () => { text: "hello", phase: "start", source: "system", + messageID: expect.any(String), }, ]) }) @@ -225,6 +226,7 @@ describe("run runtime queue", () => { text: " hello ", phase: "start", source: "system", + messageID: expect.any(String), }, ]) }) @@ -260,6 +262,7 @@ describe("run runtime queue", () => { text: "/fmt bash", phase: "start", source: "system", + messageID: expect.any(String), }, ]) ui.api.close() @@ -321,7 +324,7 @@ describe("run runtime queue", () => { await Promise.resolve() expect(turns.map((item) => item.text)).toEqual(["one"]) - expect(turns[0]?.messageID).toBeUndefined() + expect(turns[0]?.messageID).toEqual(expect.any(String)) expect(ui.commits.map((item) => item.text)).toEqual(["one"]) const first = ui.events.find((item) => item.type === "queued.prompts") const event = ui.events.findLast((item) => item.type === "queued.prompts") diff --git a/packages/opencode/test/cli/run/session-replay.test.ts b/packages/opencode/test/cli/run/session-replay.test.ts index 36d25c6b43..a6642c4bbd 100644 --- a/packages/opencode/test/cli/run/session-replay.test.ts +++ b/packages/opencode/test/cli/run/session-replay.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import { replaySession } from "@/cli/cmd/run/session-replay" +import { replayLocalRows, replaySession } from "@/cli/cmd/run/session-replay" import type { SessionMessages } from "@/cli/cmd/run/session.shared" function userMessage(id: string, text: string): SessionMessages[number] { @@ -156,4 +156,286 @@ describe("run session replay", () => { }), ) }) + + test("merges failed local rows ahead of later persisted prompts", () => { + const persisted = { + kind: "user", + text: "successful", + phase: "start", + source: "system", + messageID: "msg-user-2", + } as const + const failed = { + kind: "user", + text: "failed", + phase: "start", + source: "system", + messageID: "msg-user-1", + } as const + const error = { + kind: "error", + text: "network unavailable", + phase: "start", + source: "system", + messageID: "msg-user-1", + } as const + + expect(replayLocalRows([userMessage("msg-user-2", "successful")], [persisted], [{ commit: failed }, { commit: error }])).toEqual([ + failed, + error, + persisted, + ]) + }) + + test("retains local errors but not duplicate local prompts once a prompt persists", () => { + const persisted = { + kind: "user", + text: "failed after persistence", + phase: "start", + source: "system", + messageID: "msg-user-1", + } as const + const error = { + kind: "error", + text: "connection closed", + phase: "start", + source: "system", + messageID: "msg-user-1", + } as const + + expect(replayLocalRows([userMessage("msg-user-1", "failed after persistence")], [persisted], [{ commit: persisted }, { commit: error }])).toEqual([ + persisted, + error, + ]) + }) + + test("keeps a local turn failure below assistant output already visible for that turn", () => { + const first = { + kind: "user", + text: "start", + phase: "start", + source: "system", + messageID: "msg-user-1", + } as const + const answer = { + kind: "assistant", + text: "partial answer", + phase: "progress", + source: "assistant", + messageID: "msg-assistant-1", + } as const + const error = { + kind: "error", + text: "stream failed", + phase: "start", + source: "system", + messageID: "msg-user-1", + } as const + const second = { + kind: "user", + text: "retry", + phase: "start", + source: "system", + messageID: "msg-user-2", + } as const + + expect( + replayLocalRows( + [userMessage("msg-user-1", "start"), userMessage("msg-user-2", "retry")], + [first, answer, second], + [ + { + commit: error, + after: { kind: "assistant", text: "partial answer", phase: "progress", messageID: "msg-assistant-1" }, + }, + ], + ), + ).toEqual([first, answer, error, second]) + }) + + test("keeps a local failure above assistant output received after the failure", () => { + const first = { + kind: "user", + text: "start", + phase: "start", + source: "system", + messageID: "msg-user-1", + } as const + const error = { + kind: "error", + text: "request failed", + phase: "start", + source: "system", + messageID: "msg-user-1", + } as const + const late = { + kind: "assistant", + text: "late answer", + phase: "progress", + source: "assistant", + messageID: "msg-assistant-1", + } as const + + expect(replayLocalRows([userMessage("msg-user-1", "start")], [first, late], [{ commit: error }])).toEqual([ + first, + error, + late, + ]) + }) + + test("inserts a local failure between persisted output chunks spanning that failure", () => { + const first = { + kind: "user", + text: "start", + phase: "start", + source: "system", + messageID: "msg-user-1", + } as const + const complete = { + kind: "assistant", + text: "before after", + phase: "progress", + source: "assistant", + messageID: "msg-assistant-1", + partID: "part-1", + } as const + const error = { + kind: "error", + text: "stream failed", + phase: "start", + source: "system", + messageID: "msg-user-1", + } as const + + expect( + replayLocalRows([userMessage("msg-user-1", "start")], [first, complete], [ + { + commit: error, + after: { + kind: "assistant", + text: "before ", + phase: "progress", + messageID: "msg-assistant-1", + partID: "part-1", + visible: "before ", + }, + }, + ]), + ).toEqual([first, { ...complete, text: "before " }, error, { ...complete, text: "after" }]) + }) + + test("places an unpersisted failed prompt before live output from that turn", () => { + const prompt = { + kind: "user", + text: "start", + phase: "start", + source: "system", + messageID: "msg-1", + } as const + const answer = { + kind: "assistant", + text: "partial answer", + phase: "progress", + source: "assistant", + messageID: "msg-2", + } as const + const error = { + kind: "error", + text: "stream failed", + phase: "start", + source: "system", + messageID: "msg-1", + } as const + + expect( + replayLocalRows([], [answer], [ + { commit: prompt }, + { + commit: error, + after: { kind: "assistant", text: "partial answer", phase: "progress", messageID: "msg-2" }, + }, + ]), + ).toEqual([prompt, answer, error]) + }) + + test("anchors a failure after the visible start of a tool that later completes", () => { + const prompt = { + kind: "user", + text: "run ls", + phase: "start", + source: "system", + messageID: "msg-user-1", + } as const + const running = { + kind: "tool", + text: "running bash", + phase: "start", + source: "tool", + messageID: "msg-assistant-1", + partID: "part-tool-1", + toolState: "running", + } as const + const completed = { + kind: "tool", + text: "file.txt", + phase: "final", + source: "tool", + messageID: "msg-assistant-1", + partID: "part-tool-1", + toolState: "completed", + } as const + const error = { + kind: "error", + text: "connection lost", + phase: "start", + source: "system", + messageID: "msg-user-1", + } as const + + expect( + replayLocalRows([userMessage("msg-user-1", "run ls")], [prompt, running, completed], [ + { + commit: error, + after: { + kind: "tool", + text: "running bash", + phase: "start", + messageID: "msg-assistant-1", + partID: "part-tool-1", + toolState: "running", + }, + }, + ]), + ).toEqual([prompt, running, error, completed]) + }) + + test("retains an unpersisted local diagnostic before later persisted prompts", () => { + const first = { + kind: "user", + text: "before", + phase: "start", + source: "system", + messageID: "msg-user-1", + } as const + const error = { + kind: "error", + text: "failed to start new session", + phase: "start", + source: "system", + messageID: "msg-user-2", + } as const + const second = { + kind: "user", + text: "after", + phase: "start", + source: "system", + messageID: "msg-user-3", + } as const + + expect( + replayLocalRows([userMessage("msg-user-1", "before"), userMessage("msg-user-3", "after")], [first, second], [ + { commit: error }, + ]), + ).toEqual([first, error, second]) + }) }) diff --git a/packages/opencode/test/cli/run/stream.transport.test.ts b/packages/opencode/test/cli/run/stream.transport.test.ts index 74fb7ec028..2dcfc3c4c8 100644 --- a/packages/opencode/test/cli/run/stream.transport.test.ts +++ b/packages/opencode/test/cli/run/stream.transport.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, mock, spyOn, test } from "bun:test" import { OpencodeClient, type GlobalEvent } from "@opencode-ai/sdk/v2" import { createSessionTransport } from "@/cli/cmd/run/stream.transport" -import type { FooterApi, FooterEvent, RunFilePart, StreamCommit } from "@/cli/cmd/run/types" +import type { FooterApi, FooterEvent, LocalReplayRow, RunFilePart, StreamCommit } from "@/cli/cmd/run/types" type EventStream = Awaited>["stream"] type GlobalEventStream = Awaited>["stream"] @@ -11,6 +11,7 @@ type SessionChild = NonNullable type SessionStatusMap = NonNullable>["data"]> type TextPart = Extract +type ReasoningPart = Extract afterEach(() => { mock.restore() @@ -298,6 +299,29 @@ function textUpdated(part: TextPart): SdkEvent { } } +function reasoningPart(id: string, messageID: string, text: string): ReasoningPart { + return { + id, + sessionID: "session-1", + messageID, + type: "reasoning", + text, + time: { start: 1 }, + } +} + +function reasoningUpdated(part: ReasoningPart): SdkEvent { + return { + id: `evt-${part.id}-updated`, + type: "message.part.updated", + properties: { + sessionID: part.sessionID, + part, + time: 1, + }, + } +} + function toolUpdated(part: SessionToolPart): SdkEvent { return { id: `evt-${part.id}-updated`, @@ -721,6 +745,444 @@ describe("run stream transport", () => { } }) + test("rebuilds session output on resize and continues live deltas from replayed state", async () => { + const src = eventFeed() + const ui = footer() + let calls = 0 + const transport = await createSessionTransport({ + sdk: sdk({ + stream: src.stream, + messages: async () => { + calls += 1 + if (calls === 1) { + return ok([]) + } + + return ok([ + assistantMessage({ + sessionID: "session-1", + id: "msg-1", + parts: [textPart("text-1", "msg-1", "Hello")], + }), + ]) + }, + }), + sessionID: "session-1", + thinking: true, + replay: true, + limits: () => ({}), + footer: ui.api, + }) + const localRows: LocalReplayRow[] = [ + { commit: { kind: "user", text: "pending prompt", phase: "start", source: "system", messageID: "msg-pending" } }, + ] + const reset = mock(() => { + localRows.push({ + commit: { + kind: "user", + text: "sent during reset", + phase: "start", + source: "system", + messageID: "msg-during-reset", + }, + }) + return Promise.resolve() + }) + + try { + expect( + await transport.replayOnResize({ + localRows: () => localRows, + reset, + }), + ).toBe(true) + expect(reset).toHaveBeenCalledTimes(1) + expect(ui.commits).toEqual( + expect.arrayContaining([ + expect.objectContaining({ kind: "assistant", text: "Hello" }), + expect.objectContaining({ kind: "user", text: "sent during reset", messageID: "msg-during-reset" }), + ]), + ) + + src.push(textUpdated(textPart("text-1", "msg-1", "Hello world"))) + await waitFor(() => ui.commits.find((commit) => commit.kind === "assistant" && commit.text === " world")) + expect(ui.commits.filter((commit) => commit.kind === "assistant").map((commit) => commit.text)).toEqual([ + "Hello", + " world", + ]) + } finally { + src.close() + await transport.close() + } + }) + + test("coalesces active resize requests into one trailing replay", async () => { + const src = eventFeed() + const ui = footer() + const firstReset = defer() + const resetA = mock(() => firstReset.promise) + const resetB = mock(() => Promise.resolve()) + const resetC = mock(() => Promise.resolve()) + const transport = await createSessionTransport({ + sdk: sdk({ stream: src.stream }), + sessionID: "session-1", + thinking: true, + replay: true, + limits: () => ({}), + footer: ui.api, + }) + + try { + const active = transport.replayOnResize({ localRows: () => [], reset: resetA }) + await waitFor(() => (resetA.mock.calls.length === 1 ? true : undefined)) + + expect(await transport.replayOnResize({ localRows: () => [], reset: resetB })).toBe(false) + expect(await transport.replayOnResize({ localRows: () => [], reset: resetC })).toBe(false) + expect(resetB).not.toHaveBeenCalled() + + firstReset.resolve() + expect(await active).toBe(true) + expect(resetA).toHaveBeenCalledTimes(1) + expect(resetB).not.toHaveBeenCalled() + expect(resetC).toHaveBeenCalledTimes(1) + } finally { + src.close() + await transport.close() + } + }) + + test("keeps coalescing resize requests while buffered events drain", async () => { + const src = eventFeed() + const ui = footer() + const firstReset = defer() + const statusGate = defer() + const statusStarted = defer() + let blockStatus = false + const trace = mock((_type: string, _data?: unknown) => {}) + const resetA = mock(() => firstReset.promise) + const resetB = mock(() => Promise.resolve()) + const resetC = mock(() => Promise.resolve()) + const transport = await createSessionTransport({ + sdk: sdk({ + stream: src.stream, + status: async () => { + if (blockStatus) { + statusStarted.resolve() + await statusGate.promise + } + return ok(statusMap(true)) + }, + }), + sessionID: "session-1", + thinking: true, + replay: true, + limits: () => ({}), + footer: ui.api, + trace: { write: trace }, + }) + const turn = transport.runPromptTurn({ + agent: undefined, + model: undefined, + variant: undefined, + prompt: { text: "active", parts: [] }, + files: [], + includeFiles: false, + }) + + try { + await waitFor(() => ui.events.find((event) => event.type === "turn.wait")) + const active = transport.replayOnResize({ localRows: () => [], reset: resetA }) + await waitFor(() => (resetA.mock.calls.length === 1 ? true : undefined)) + blockStatus = true + src.push(busy()) + src.push(idle()) + await waitFor(() => (trace.mock.calls.filter((call) => call[0] === "recv.event").length >= 2 ? true : undefined)) + + expect(await transport.replayOnResize({ localRows: () => [], reset: resetB })).toBe(false) + firstReset.resolve() + await Promise.race([ + statusStarted.promise, + Bun.sleep(1_000).then(() => { + throw new Error("timed out waiting for buffered status drain") + }), + ]) + + expect(await transport.replayOnResize({ localRows: () => [], reset: resetC })).toBe(false) + expect(resetC).not.toHaveBeenCalled() + blockStatus = false + statusGate.resolve() + + expect( + await Promise.race([ + active, + Bun.sleep(1_000).then(() => { + throw new Error("timed out waiting for trailing resize replay") + }), + ]), + ).toBe(true) + expect(resetB).not.toHaveBeenCalled() + expect(resetC).toHaveBeenCalledTimes(1) + } finally { + src.close() + await transport.close() + await turn + } + }) + + test("preserves assistant deltas not yet persisted when replaying during a live stream", async () => { + const src = eventFeed() + const ui = footer() + let calls = 0 + const transport = await createSessionTransport({ + sdk: sdk({ + stream: src.stream, + messages: async () => { + calls += 1 + if (calls === 1) { + return ok([]) + } + + return ok([ + assistantMessage({ + sessionID: "session-1", + id: "msg-live", + parts: [textPart("text-live", "msg-live", "")], + }), + ]) + }, + }), + sessionID: "session-1", + thinking: true, + replay: true, + limits: () => ({}), + footer: ui.api, + }) + + try { + src.push(assistant("msg-live")) + src.push(textUpdated(textPart("text-live", "msg-live", ""))) + src.push(textDelta("msg-live", "text-live", "Hello")) + await waitFor(() => ui.commits.find((commit) => commit.kind === "assistant" && commit.text === "Hello")) + ui.commits.length = 0 + + expect(await transport.replayOnResize({ localRows: () => [], reset: () => Promise.resolve() })).toBe(true) + src.push(textDelta("msg-live", "text-live", "Hello")) + src.push( + textUpdated({ + ...textPart("text-live", "msg-live", "HelloHello"), + time: { start: 1, end: 2 }, + }), + ) + + await waitFor(() => + ui.commits.filter((commit) => commit.kind === "assistant" && commit.text === "Hello").length === 2 + ? true + : undefined, + ) + expect( + ui.commits.filter((commit) => commit.kind === "assistant" && commit.text).map((commit) => commit.text), + ).toEqual(["Hello", "Hello"]) + } finally { + src.close() + await transport.close() + } + }) + + test("preserves the display prefix for active reasoning restored during replay", async () => { + const src = eventFeed() + const ui = footer() + let calls = 0 + const transport = await createSessionTransport({ + sdk: sdk({ + stream: src.stream, + messages: async () => { + calls += 1 + if (calls === 1) { + return ok([]) + } + + return ok([ + assistantMessage({ + sessionID: "session-1", + id: "msg-thinking", + parts: [reasoningPart("thinking-1", "msg-thinking", "")], + }), + ]) + }, + }), + sessionID: "session-1", + thinking: true, + replay: true, + limits: () => ({}), + footer: ui.api, + }) + + try { + src.push(assistant("msg-thinking")) + src.push(reasoningUpdated(reasoningPart("thinking-1", "msg-thinking", ""))) + src.push(textDelta("msg-thinking", "thinking-1", "plan")) + await waitFor(() => ui.commits.find((commit) => commit.kind === "reasoning" && commit.text === "Thinking: plan")) + ui.commits.length = 0 + + expect(await transport.replayOnResize({ localRows: () => [], reset: () => Promise.resolve() })).toBe(true) + expect(ui.commits.filter((commit) => commit.kind === "reasoning").map((commit) => commit.text)).toEqual([ + "Thinking: plan", + ]) + } finally { + src.close() + await transport.close() + } + }) + + test("does not overlay stale active text when persistence completes during replay", async () => { + const src = eventFeed() + const ui = footer() + let calls = 0 + const transport = await createSessionTransport({ + sdk: sdk({ + stream: src.stream, + messages: async () => { + calls += 1 + if (calls === 1) { + return ok([]) + } + + return ok([ + assistantMessage({ + sessionID: "session-1", + id: "msg-finished", + parts: [ + { + ...textPart("text-finished", "msg-finished", "Hello"), + time: { start: 1, end: 2 }, + }, + ], + }), + ]) + }, + }), + sessionID: "session-1", + thinking: true, + replay: true, + limits: () => ({}), + footer: ui.api, + }) + + try { + src.push(assistant("msg-finished")) + src.push(textUpdated(textPart("text-finished", "msg-finished", ""))) + src.push(textDelta("msg-finished", "text-finished", "Hello")) + await waitFor(() => ui.commits.find((commit) => commit.kind === "assistant" && commit.text === "Hello")) + ui.commits.length = 0 + + expect(await transport.replayOnResize({ localRows: () => [], reset: () => Promise.resolve() })).toBe(true) + expect( + ui.commits.filter((commit) => commit.kind === "assistant" && commit.text).map((commit) => commit.text), + ).toEqual(["Hello"]) + } finally { + src.close() + await transport.close() + } + }) + + test("does not clear the terminal when resize replay snapshot fetch fails", async () => { + const src = eventFeed() + const ui = footer() + let calls = 0 + const transport = await createSessionTransport({ + sdk: sdk({ + stream: src.stream, + messages: async () => { + calls += 1 + if (calls === 1) { + return ok([]) + } + + throw new Error("snapshot failed") + }, + }), + sessionID: "session-1", + thinking: true, + replay: true, + limits: () => ({}), + footer: ui.api, + }) + const reset = mock(() => Promise.resolve()) + + try { + expect(await transport.replayOnResize({ localRows: () => [], reset })).toBe(false) + expect(reset).not.toHaveBeenCalled() + expect(ui.commits).toEqual([]) + } finally { + src.close() + await transport.close() + } + }) + + test("disables resize replay for the session after terminal reset fails", async () => { + const src = eventFeed() + const ui = footer() + const transport = await createSessionTransport({ + sdk: sdk({ stream: src.stream }), + sessionID: "session-1", + thinking: true, + replay: true, + limits: () => ({}), + footer: ui.api, + }) + const reset = mock(() => Promise.reject(new Error("clear failed"))) + + try { + expect(await transport.replayOnResize({ localRows: () => [], reset })).toBe(false) + expect(await transport.replayOnResize({ localRows: () => [], reset })).toBe(false) + expect(reset).toHaveBeenCalledTimes(1) + expect(ui.commits).toContainEqual({ + kind: "error", + text: "resize replay failed; disabled for this session", + phase: "start", + source: "system", + }) + } finally { + src.close() + await transport.close() + } + }) + + test("disables resize replay when rebuilding scrollback fails after terminal reset", async () => { + const src = eventFeed() + const ui = footer() + let cleared = false + const idle = ui.api.idle + ui.api.idle = () => (cleared ? Promise.reject(new Error("render failed")) : idle()) + const transport = await createSessionTransport({ + sdk: sdk({ stream: src.stream }), + sessionID: "session-1", + thinking: true, + replay: true, + limits: () => ({}), + footer: ui.api, + }) + const reset = mock(() => { + cleared = true + return Promise.resolve() + }) + + try { + expect(await transport.replayOnResize({ localRows: () => [], reset })).toBe(false) + expect(await transport.replayOnResize({ localRows: () => [], reset })).toBe(false) + expect(reset).toHaveBeenCalledTimes(1) + expect(ui.commits).toContainEqual({ + kind: "error", + text: "resize replay failed; disabled for this session", + phase: "start", + source: "system", + }) + } finally { + src.close() + await transport.close() + } + }) + test("drops completed historical subagent tabs during bootstrap", async () => { const src = eventFeed() const ui = footer() diff --git a/packages/plugin/package.json b/packages/plugin/package.json index bcbf22459e..5cd7d82b28 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -22,9 +22,9 @@ "zod": "catalog:" }, "peerDependencies": { - "@opentui/core": ">=0.3.0", - "@opentui/keymap": ">=0.3.0", - "@opentui/solid": ">=0.3.0" + "@opentui/core": ">=0.3.1", + "@opentui/keymap": ">=0.3.1", + "@opentui/solid": ">=0.3.1" }, "peerDependenciesMeta": { "@opentui/core": { From 10095bfad43cf88d544631fc8cd1d8b398d369c7 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Mon, 1 Jun 2026 13:13:19 +0000 Subject: [PATCH 057/840] chore: generate --- .../src/cli/cmd/run/session-replay.ts | 6 +- .../test/cli/run/session-replay.test.ts | 101 ++++++++++-------- 2 files changed, 63 insertions(+), 44 deletions(-) diff --git a/packages/opencode/src/cli/cmd/run/session-replay.ts b/packages/opencode/src/cli/cmd/run/session-replay.ts index dde41f3249..8074aa4f63 100644 --- a/packages/opencode/src/cli/cmd/run/session-replay.ts +++ b/packages/opencode/src/cli/cmd/run/session-replay.ts @@ -187,7 +187,11 @@ export function replaySession(input: ReplayInput): SessionReplay { } } -export function replayLocalRows(messages: SessionMessages, commits: StreamCommit[], rows: LocalReplayRow[]): StreamCommit[] { +export function replayLocalRows( + messages: SessionMessages, + commits: StreamCommit[], + rows: LocalReplayRow[], +): StreamCommit[] { const persisted = new Set(messages.map((message) => message.info.id)) return rows.reduce((out, local) => { const row = local.commit diff --git a/packages/opencode/test/cli/run/session-replay.test.ts b/packages/opencode/test/cli/run/session-replay.test.ts index a6642c4bbd..da4bfd382e 100644 --- a/packages/opencode/test/cli/run/session-replay.test.ts +++ b/packages/opencode/test/cli/run/session-replay.test.ts @@ -180,11 +180,9 @@ describe("run session replay", () => { messageID: "msg-user-1", } as const - expect(replayLocalRows([userMessage("msg-user-2", "successful")], [persisted], [{ commit: failed }, { commit: error }])).toEqual([ - failed, - error, - persisted, - ]) + expect( + replayLocalRows([userMessage("msg-user-2", "successful")], [persisted], [{ commit: failed }, { commit: error }]), + ).toEqual([failed, error, persisted]) }) test("retains local errors but not duplicate local prompts once a prompt persists", () => { @@ -203,10 +201,13 @@ describe("run session replay", () => { messageID: "msg-user-1", } as const - expect(replayLocalRows([userMessage("msg-user-1", "failed after persistence")], [persisted], [{ commit: persisted }, { commit: error }])).toEqual([ - persisted, - error, - ]) + expect( + replayLocalRows( + [userMessage("msg-user-1", "failed after persistence")], + [persisted], + [{ commit: persisted }, { commit: error }], + ), + ).toEqual([persisted, error]) }) test("keeps a local turn failure below assistant output already visible for that turn", () => { @@ -308,19 +309,23 @@ describe("run session replay", () => { } as const expect( - replayLocalRows([userMessage("msg-user-1", "start")], [first, complete], [ - { - commit: error, - after: { - kind: "assistant", - text: "before ", - phase: "progress", - messageID: "msg-assistant-1", - partID: "part-1", - visible: "before ", + replayLocalRows( + [userMessage("msg-user-1", "start")], + [first, complete], + [ + { + commit: error, + after: { + kind: "assistant", + text: "before ", + phase: "progress", + messageID: "msg-assistant-1", + partID: "part-1", + visible: "before ", + }, }, - }, - ]), + ], + ), ).toEqual([first, { ...complete, text: "before " }, error, { ...complete, text: "after" }]) }) @@ -348,13 +353,17 @@ describe("run session replay", () => { } as const expect( - replayLocalRows([], [answer], [ - { commit: prompt }, - { - commit: error, - after: { kind: "assistant", text: "partial answer", phase: "progress", messageID: "msg-2" }, - }, - ]), + replayLocalRows( + [], + [answer], + [ + { commit: prompt }, + { + commit: error, + after: { kind: "assistant", text: "partial answer", phase: "progress", messageID: "msg-2" }, + }, + ], + ), ).toEqual([prompt, answer, error]) }) @@ -393,19 +402,23 @@ describe("run session replay", () => { } as const expect( - replayLocalRows([userMessage("msg-user-1", "run ls")], [prompt, running, completed], [ - { - commit: error, - after: { - kind: "tool", - text: "running bash", - phase: "start", - messageID: "msg-assistant-1", - partID: "part-tool-1", - toolState: "running", + replayLocalRows( + [userMessage("msg-user-1", "run ls")], + [prompt, running, completed], + [ + { + commit: error, + after: { + kind: "tool", + text: "running bash", + phase: "start", + messageID: "msg-assistant-1", + partID: "part-tool-1", + toolState: "running", + }, }, - }, - ]), + ], + ), ).toEqual([prompt, running, error, completed]) }) @@ -433,9 +446,11 @@ describe("run session replay", () => { } as const expect( - replayLocalRows([userMessage("msg-user-1", "before"), userMessage("msg-user-3", "after")], [first, second], [ - { commit: error }, - ]), + replayLocalRows( + [userMessage("msg-user-1", "before"), userMessage("msg-user-3", "after")], + [first, second], + [{ commit: error }], + ), ).toEqual([first, error, second]) }) }) From 913c4ef0b14b517f5c4202e2b0ed0754e441ccd8 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Mon, 1 Jun 2026 13:34:43 +0000 Subject: [PATCH 058/840] chore: update nix node_modules hashes --- nix/hashes.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/nix/hashes.json b/nix/hashes.json index 21f9e60e22..c4fa680845 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-3OI8L9IT1XFN44CO3YEgHy/zKqVNhzkJjW1P2+9X4lU=", - "aarch64-linux": "sha256-YZSeZIUBQCII3+dLwKhvKv4RKHwAkLSnvGXXEHbmjeQ=", - "aarch64-darwin": "sha256-8CsowndXBpFDTd2RNSMh7xaULwLoz3Lu6kfqkacOk2s=", - "x86_64-darwin": "sha256-pWyjVHZVBjcGvVU9AoSR4nhuMNKIHKJK5faihD8UzhQ=" + "x86_64-linux": "sha256-51jxaHLvv2Staz9NN9N4EYoNmr2fZeDvfKZ5enf/Wx0=", + "aarch64-linux": "sha256-oGMMlgSJx7Yw5qN6LOquCD/K8GPLy4kDo34AOJGmcso=", + "aarch64-darwin": "sha256-j7IvnyY8Cj4a509D85i+FgfsyQiBstxagvVWMp6y5hI=", + "x86_64-darwin": "sha256-Dd36AN6LopLNV79d7i9lGz5kKOuv6mk1YyBlmdcIhZY=" } } From 44350bced997bd8f9b45260643e111355ad2d315 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 1 Jun 2026 11:48:59 -0500 Subject: [PATCH 059/840] fix(stats): restore leaderboard spacing --- packages/stats/app/src/routes/index.css | 100 +++++++----------------- packages/stats/app/src/routes/index.tsx | 81 ++++++++++++++----- 2 files changed, 89 insertions(+), 92 deletions(-) diff --git a/packages/stats/app/src/routes/index.css b/packages/stats/app/src/routes/index.css index d8aee2d6ac..ca341eff10 100644 --- a/packages/stats/app/src/routes/index.css +++ b/packages/stats/app/src/routes/index.css @@ -83,6 +83,10 @@ background: var(--stats-bg); } +[data-page="stats"] [hidden] { + display: none !important; +} + [data-page="stats"] [data-component="content"] { color: var(--stats-text); font-family: @@ -1706,15 +1710,30 @@ font-weight: 400; } +[data-page="stats"] [data-component="leaderboard"], +[data-page="stats"] [data-slot="leaderboard-featured"], +[data-page="stats"] [data-slot="leaderboard-compact"], +[data-page="stats"] [data-slot="leaderboard-column"], +[data-page="stats"] [data-slot="leaderboard-mobile"] { + display: grid; +} + [data-page="stats"] [data-component="leaderboard"] { - display: block; + gap: 0; width: 100%; + margin-top: 36px; + scroll-margin-top: 88px; +} + +[data-page="stats"] [data-slot="leaderboard-featured"], +[data-page="stats"] [data-slot="leaderboard-compact"] { + grid-template-columns: repeat(auto-fit, minmax(min(100%, 260px), 1fr)); + gap: 8px; } [data-page="stats"] [data-slot="leaderboard-pattern"] { - scroll-margin-top: 88px; height: 16px; - margin: 32px 0 16px; + margin: 16px 0; overflow: hidden; background: var(--stats-hero-pattern); mask-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 6 6' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0 0H2V2H0V0Z' fill='black'/%3E%3C/svg%3E"); @@ -1727,30 +1746,15 @@ -webkit-mask-size: 6px 6px; } -[data-page="stats"] [data-slot="leaderboard-scroll"] { - box-sizing: border-box; - display: flex; +[data-page="stats"] [data-slot="leaderboard-column"] { gap: 8px; - width: calc(100% + 80px); - margin-inline: -40px; - padding: 4px 40px 8px; - overflow-x: auto; - overscroll-behavior-x: contain; - scroll-padding-inline: 40px; - scroll-snap-type: x proximity; - scrollbar-width: none; + align-content: start; } -[data-page="stats"] [data-slot="leaderboard-scroll"]::-webkit-scrollbar { +[data-page="stats"] [data-slot="leaderboard-mobile"] { display: none; } -[data-page="stats"] [data-slot="leaderboard-scroll"] [data-component="leader-card"] { - flex: 0 0 clamp(240px, 31vw, 360px); - width: clamp(240px, 31vw, 360px); - scroll-snap-align: start; -} - [data-page="stats"] [data-component="leader-card"] { position: relative; box-sizing: border-box; @@ -2598,14 +2602,6 @@ [data-page="stats"] [data-section="session-cost"] { padding: 64px 32px; } - - [data-page="stats"] [data-slot="leaderboard-scroll"] { - width: calc(100% + 64px); - margin-inline: -32px; - padding-right: 32px; - padding-left: 32px; - scroll-padding-inline: 32px; - } } @media (max-width: 58rem) { @@ -2649,9 +2645,9 @@ flex-wrap: nowrap; } - [data-page="stats"] [data-slot="leaderboard-scroll"] [data-component="leader-card"] { - flex-basis: 280px; - width: 280px; + [data-page="stats"] [data-slot="leaderboard-featured"], + [data-page="stats"] [data-slot="leaderboard-compact"] { + grid-template-columns: 1fr; } [data-page="stats"] [data-component="leader-card"][data-size="featured"], @@ -2760,49 +2756,11 @@ } [data-page="stats"] [data-slot="leaderboard-featured"], + [data-page="stats"] [data-slot="leaderboard-pattern"], [data-page="stats"] [data-slot="leaderboard-compact"] { display: none; } - [data-page="stats"] [data-slot="leaderboard-pattern"] { - margin: 24px 0 12px; - } - - [data-page="stats"] [data-slot="leaderboard-scroll"] { - gap: 12px; - width: calc(100% + 48px); - margin-inline: -24px; - padding-right: 24px; - padding-left: 24px; - scroll-padding-inline: 24px; - } - - [data-page="stats"] [data-slot="leaderboard-scroll"] [data-component="leader-card"] { - flex: 0 0 240px; - width: 240px; - min-height: 156px; - gap: 32px; - } - - [data-page="stats"] [data-slot="leaderboard-scroll"] [data-slot="leader-body"] { - flex-direction: column; - align-items: flex-start; - gap: 16px; - } - - [data-page="stats"] [data-slot="leaderboard-scroll"] [data-slot="leader-avatar"] { - width: 28px; - height: 28px; - padding: 4px; - border-radius: 6px; - } - - [data-page="stats"] [data-slot="leaderboard-scroll"] [data-slot="leader-watermark"] { - right: -68px; - width: 240px; - height: 240px; - } - [data-page="stats"] [data-slot="leaderboard-mobile"] { box-sizing: border-box; display: flex; diff --git a/packages/stats/app/src/routes/index.tsx b/packages/stats/app/src/routes/index.tsx index a770a9f046..4c76d8ccb2 100644 --- a/packages/stats/app/src/routes/index.tsx +++ b/packages/stats/app/src/routes/index.tsx @@ -368,7 +368,7 @@ function formatUpdatedAtLabel(value: { date: string; time: string }) { } function TopModelsSection(props: { data: StatsHomeData["usage"]; leaderboard: StatsHomeData["leaderboard"] }) { - const [product, setProduct] = createSignal("All Users") + const [product, setProduct] = createSignal("Go") const [range, setRange] = createSignal("2M") const [sheet, setSheet] = createSignal<"product" | "range">() const [activeModel, setActiveModel] = createSignal() @@ -409,7 +409,6 @@ function TopModelsSection(props: { data: StatsHomeData["usage"]; leaderboard: St onActiveModelChange={setActiveModel} /> -