From aa86a20b1d7d23697d239f0b4dfd9af92503b60a Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sat, 30 May 2026 19:40:30 -0400 Subject: [PATCH 001/412] 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 67293f7c5..7e9c3f123 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 002/412] 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 a12de5d47..22edc1340 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 c9e139673..c9e7e4ea2 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 7e9c3f123..23655faef 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 000000000..b4efc0745 --- /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 003/412] 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 b4efc0745..72c735f6c 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 004/412] 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 | 368 +--- .../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 | 129 +- 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 | 77 +- 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 | 364 ++-- 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 | 388 ++-- packages/opencode/test/lib/effect.ts | 43 +- packages/opencode/test/lsp/index.test.ts | 136 +- packages/opencode/test/lsp/lifecycle.test.ts | 174 +- .../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 | 464 ++--- 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 | 102 +- .../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 | 66 +- 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 | 45 +- 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, 11106 insertions(+), 9143 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 0ae2fbe26..7f07577f8 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 fa39b00a4..1ee5be8b0 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 240e57429..9b33108f2 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 b1593f9d9..5bef47c3c 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 1d16c33ad..7caf8e0cd 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 1b4fd556e..a90ac4e2f 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 000000000..0ce73631f --- /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 000000000..f171a7527 --- /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 5c24b5189..1c3f2a3f0 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 000000000..8c5f1f6cc --- /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 a124a9a15..4de8176e4 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")]), - cause: Schema.Defect, +export class Org extends Schema.Class("Org")({ + id: OrgID, + name: Schema.String, }) {} -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 -} +export class AccountRepoError extends Schema.TaggedErrorClass()("AccountRepoError", { + message: Schema.String, + cause: Schema.optional(Schema.Defect), +}) {} -const decodeV1 = Schema.decodeUnknownOption(Schema.Record(Schema.String, Credential)) +export class AccountServiceError extends Schema.TaggedErrorClass()("AccountServiceError", { + message: Schema.String, + cause: Schema.optional(Schema.Defect), +}) {} -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, +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, }) - 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 + 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 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)) +export type AccountError = AccountRepoError | AccountServiceError | AccountTransportError - 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, - }), - }, - } +export class Login extends Schema.Class("Login")({ + code: DeviceCode, + user: UserCode, + url: Schema.String, + server: Schema.String, + expiry: Schema.Duration, + interval: Schema.Duration, +}) {} - yield* write(next) - return [undefined, next] as const - }), - ) - }), +export class PollSuccess extends Schema.TaggedClass()("PollSuccess", { + email: Schema.String, +}) {} - 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] - } +export class PollPending extends Schema.TaggedClass()("PollPending", {}) {} - 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) - } - }), +export class PollSlow extends Schema.TaggedClass()("PollSlow", {}) {} - activate, - } +export class PollExpired extends Schema.TaggedClass()("PollExpired", {}) {} - return Service.of(result) - }), -) +export class PollDenied extends Schema.TaggedClass()("PollDenied", {}) {} -export const defaultLayer = layer.pipe( - Layer.provide(AppFileSystem.defaultLayer), - Layer.provide(Global.defaultLayer), - Layer.provide(EventV2.defaultLayer), -) +export class PollError extends Schema.TaggedClass()("PollError", { + cause: Schema.Defect, +}) {} -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 35bfd1e3e..4f45651d7 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 ec7dfa2ad..c4971b272 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 5fa229430..4560f8a2c 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 000000000..916bef9d1 --- /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 22edc1340..d31854485 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 c9e7e4ea2..d5f7bfdff 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 1afaf7cbc..ef5195216 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 000000000..ba7aa91b0 --- /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 000000000..4447c6008 --- /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 000000000..42aebf02d --- /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 000000000..468a7103f --- /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 000000000..d31a533db --- /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 000000000..8077182d9 --- /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 000000000..cc816951e --- /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 000000000..430407156 --- /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 000000000..83e2978f7 --- /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 000000000..380e9cc68 --- /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 000000000..63671a84f --- /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 000000000..86e20a66d --- /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 000000000..2b1996fba --- /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 000000000..3b37a0bfc --- /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 000000000..a03477e09 --- /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 000000000..20b1f9163 --- /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 000000000..0b0bd133a --- /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 000000000..a60ef377f --- /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 000000000..6c5b078f8 --- /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 000000000..33e855491 --- /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 000000000..df7e90fc9 --- /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 000000000..5dcd1f658 --- /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 000000000..7ff0b6618 --- /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 000000000..2fe023543 --- /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 000000000..02a41e07c --- /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 000000000..cb9272adf --- /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 000000000..d2304a547 --- /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 339fbddec..105fb12dd 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 547a80f0f..6bccc0fbb 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 000000000..847a5c032 --- /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 23655faef..a43486fa2 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 68c9a8f79..9613885c9 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 ec8038f71..07c7d8e7b 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 df8a40c1f..1d854f18f 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 4e77701f3..56b544480 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 4456ac190..98004600e 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 4733833fd..7ee38ac6d 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 188078749..eb84a73ac 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 fd02d322a..000000000 --- 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 78bd74f1c..20367f4d9 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 90638873d..f71b82824 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 2d486114a..1588446cf 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 7ba2172ad..1c237d3ec 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 523a4eace..b5cee90a5 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 756531e32..3dc268385 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 a98d9cc05..c8b4aac50 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 000000000..a1896a9de --- /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 bbdf59c55..99fc3243c 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 73b6dd7da..9de73a17b 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 000000000..3044aee52 --- /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 000000000..8562a097e --- /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 948deca4c..cc474cd96 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 f337e106a..a7a08d0c0 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 000000000..b39c0f7f0 --- /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 000000000..30d33abbe --- /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 1e50fd340..4e69df25d 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 c59449e93..2aef20335 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 9736e2a73..d2314adc6 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 0d8562313..34ca99b09 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 21f9e3e3d..47e0206d4 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 000000000..e0e1b3f68 --- /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 f5cce54de..53d233089 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 18670d690..1b917c5af 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 e6c661859..980269b37 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 9a9cb861e..234127236 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 c15568eeb..d0e7c80e7 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 f9efa294f..b2cba8e46 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 fdb7bf75e..8844208e4 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 579d70da5..0eb3f538b 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 a6d25ac18..f99510ac7 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 4f4dd6abe..405488071 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 63af32dae..de4953b06 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 c331b5585..42736eb7d 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 b1ae5b45c..94ea40f67 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 000000000..74671bb5b --- /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 000000000..8720d88cf --- /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 000000000..2bc480ffb --- /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 ad673a263..cc29f5019 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 d367f4408..f07170c58 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 e60d5530a..b56bdf187 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 0f0d55b46..e6a4171f7 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 33db38d84..c93ae46d1 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 f5eaf7932..000000000 --- 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 2d855e0e9..9d9f7e4a2 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 tx = (f: DbTransactionCallback) => - Effect.try({ - try: () => Database.transaction(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 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) - - 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), + query( + db.transaction((tx) => + Effect.gen(function* () { + const url = normalizeServerUrl(input.url) + + 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 f83a75ef1..32630a620 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 c49613dd0..eb0b78859 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 717290728..39840c091 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 82a9f6175..7b7dc9d4a 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 aae324669..a6db606b5 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 064a59f59..9dba3445b 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 5a9e52ef0..000000000 --- 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 73ec18d73..000000000 --- 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 b113455f3..9e7e37e18 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()) - }, -}) - -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() - } - }, + instance: false, + handler: Effect.fn("Cli.db.path")(function* () { + console.log(Database.path()) + }), }) -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 c74c1c907..0c310474e 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 2a127e5db..124dfd135 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 9eb1faffe..e6bff506c 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 9ac605f46..e12604c3a 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 569aa309a..7cad05bae 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) + 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 SessionLegacy.Part + const { id: partId, sessionID: _s, messageID, ...partData } = partInfo + yield* db + .insert(PartTable) .values({ - id, + id: partId, + message_id: messageID, session_id: row.id, - time_created: msgInfo.time?.created ?? Date.now(), - data: msgData, + data: partData, }) .onConflictDoNothing() - .run(), - ) - - for (const part of msg.parts) { - const partInfo = decodePart(part) as MessageV2.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(), - ) + .run() + .pipe(Effect.orDie) } } diff --git a/packages/opencode/src/cli/cmd/mcp.ts b/packages/opencode/src/cli/cmd/mcp.ts index f7ea030aa..75e41b3c8 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 909b0b40b..3349d3c5e 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 7ee16c2e2..22dee1477 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 bebb1fc6a..73412b877 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 d15fb3920..381ecca7e 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 96e171733..6ef2ab780 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 e5fa13714..0b052f5c9 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 1954543f4..000000000 --- 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 daa837453..f54a878db 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 2e6aff1be..52229e563 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 9f44d2233..d3147ec99 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 - - 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] - }) + if (ids.length === 0) return Effect.succeed(true) + + 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 b6956032a..000000000 --- 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 2bef35ed0..5434bb713 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 7f1853852..da3c10ff9 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 99f16f437..a51c2938d 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 d95932c2d..49636c1f4 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 5765afee4..c26e10aba 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 4c6c79a70..673bf1f15 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 0992289fe..c4aa2e1cf 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 91ad9ff4d..eeb3e5f5f 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 a31c5bd05..4df2ce8c2 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 2a3c4fa5c..8ecf0dcfc 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 d20f29dd4..d8bcdff7b 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 1f8dc116b..4367a2679 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 205cba6f2..25da0b10c 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 3117b834c..a0fcfb3fc 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 2bac6cc84..1717799e3 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 9c29dcd98..48b4293d1 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 1814c5ab2..35546db84 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 717dff8db..478114209 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 a7e67d45e..e6c5d698a 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 1f513fb1b..ccac93ae1 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 e511a75ff..000000000 --- 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 d2b5729dd..69e3ca9aa 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 a304fec54..3dfc1aafe 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 06b388b48..0d5a5130c 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 49c582e0a..468ee34ab 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 db05b4784..000000000 --- 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 9905aa4f6..9816faffe 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 0978e5208..cf6751fb1 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 e03af848b..051ae7afd 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 d5f10f47d..f7b657da3 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 c5fb2420a..2ded2c2cd 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 eff336b3c..0f0b695ea 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 33e6a8e4a..49f43f015 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 90be9f218..c40a3bf00 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 f50fd3351..b7a50962d 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 b7be4044f..c6b2fab40 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 0d8e49022..3a9ae0c6d 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 e770a7cfb..edf50927a 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 4061bec29..e995c2160 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 f80869b64..a63a9a958 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 1b61204c4..8b4fc608f 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 b9766ca97..e1377b6f7 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 0d4b9ff98..773fb4123 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 ffe8d0baa..5269f3546 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 0ecebf451..31ecd5eff 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 daa799b7a..0514ea56a 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 0d9273d8c..c9cfe33bc 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 ff4e098fb..f6f126335 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 f3bfe0668..c5cbc7b82 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 66fe1e12e..873abd834 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 45a71a7bf..12761ab7f 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 770e4588b..d01f15d21 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 4f87edf64..c20cf7e07 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 ad9a74445..855c58ba5 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 ea2efc99d..0ea62147c 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 347134240..60847dab3 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 68157f5f4..209b1e05d 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 39c842f94..e5332992f 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 d01fe5c62..343c8408e 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 a287c3b00..f124f7eea 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 ae5b9c5d2..000000000 --- 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 20edf479a..000000000 --- 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 22fe4d81c..f51635bf8 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 ae1a46579..de20b7f45 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 a11bd5e67..206304b39 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 463bc27a9..bcfb54c47 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 950d533a3..f33330704 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 8f0051dfb..1b92dce68 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 f1622b695..4a49d110c 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 e84da3340..b055c488b 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,11 +301,21 @@ 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({ @@ -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 089559e2c..a7a6c5f87 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 aa4b8719b..0b0d46526 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 005b3b7c4..37598f9d5 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 f45df9d0f..20ffb60e1 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 a13b6c9de..b27bc728a 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 ab2d9d151..bf0ae3b84 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 c1c6d0d6f..1d21774ee 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 fa6190925..000000000 --- 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 0dba8dcef..000000000 --- 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 06f1f84a9..000000000 --- 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 3930e591a..00a10e6d9 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 0c12cee62..01d47fcb5 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 857363661..000000000 --- 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 84e84cc39..356d09f65 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 ea3aac348..79df2fa1b 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 af206f66a..01ca4a69c 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 d7f7de778..aae98a8c7 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 a9a29debb..bf52030d9 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 f072773fa..4edbec94c 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 c2be73ab1..40de52279 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 e3a599d8e..000000000 --- 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 551f030ff..000000000 --- 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 d9743e563..27041925d 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 137665154..42851fc19 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 ffe5d78a1..04d425e2c 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 aa5fc12df..5cc48d78f 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 7a09fd1cc..878d331c4 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 c03388037..a7801218e 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 343a27013..0366f2321 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 d79e01c78..60604e811 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 55e950aab..58ce6ea71 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 dfe653dd1..000000000 --- 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 645a94fb3..000000000 --- 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 084498616..000000000 --- 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 263f3a45f..35f8e44d7 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 22ad59aaa..14882e264 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 4d5aaf6fe..85cb78a32 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 762bb5d57..fbeb7eeb2 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 09810d57d..b8928f87a 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 16538bb8a..08c8fef43 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 36579f48e..8227cb50a 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 5f8f7a330..e90bde29e 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 c205da486..3137f6c7d 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 db4a5df20..88f1097f2 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 0b26359ad..41a095312 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 224c5ef1f..cf00d9e7b 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 9c201d398..b3dceddf8 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 41468c4d0..96b5ad5ae 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") }), ), - ), - ) - - 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, - }, - }, - ), + { 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.instance( + "status() keeps built-in formatters when config object is provided", + () => + 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.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.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.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.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.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.live("service initializes without error", () => provideTmpdirInstance(() => Format.Service.use(() => Effect.void))) + it.instance("service initializes without error", () => Format.Service.use(() => Effect.void)) - it.live("file() returns false when no formatter runs", () => - provideTmpdirInstance( - (dir) => - Effect.gen(function* () { - const file = `${dir}/test.txt` - yield* Effect.promise(() => Bun.write(file, "x")) + 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, - }, - }, - ), + const formatted = yield* Format.use.file(file) + expect(formatted).toBe(false) + }), + { config: { formatter: false } }, ) - it.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 }, @@ -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")) - - 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 - - yield* Effect.acquireUseRelease( + 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, + } + + 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 = [".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"] - } + Formatter.gofmt.extensions = one.extensions + Formatter.gofmt.enabled = one.enabled + Formatter.mix.extensions = two.extensions + Formatter.mix.enabled = two.enabled }), - () => - 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) - }), - ) - - 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"], - }, + 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"], }, }, }, - ), + }, ) }) diff --git a/packages/opencode/test/lib/effect.ts b/packages/opencode/test/lib/effect.ts index f04829601..952cc6b62 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) } +type InstanceOptions = { + git?: boolean + config?: Partial | (() => Partial) + init?: (directory: string) => Effect.Effect +} -function isInstanceOptions(options: InstanceOptions | number | TestOptions | undefined): options is InstanceOptions { - return !!options && typeof options === "object" && ("git" in options || "config" in options) +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, +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 78543c458..b99131e26 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 11b191f00..f6db6c087 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.live("workspaceSymbol() returns empty array with no clients", () => - provideTmpdirInstance(() => + it.instance( + "hasClients() keeps built-in LSPs when config object is provided", + () => LSP.Service.use((lsp) => Effect.gen(function* () { - const result = yield* lsp.workspaceSymbol("test") - expect(Array.isArray(result)).toBe(true) - expect(result.length).toBe(0) + 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("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("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("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("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("multiple init() calls are idempotent", () => - provideTmpdirInstance(() => - LSP.Service.use((lsp) => - Effect.gen(function* () { - yield* lsp.init() - yield* lsp.init() - yield* lsp.init() - }), - ), + 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.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 17bdba690..542069fc6 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 16d6a2d46..92f473b11 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 e969e67ff..b05da50cb 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 c10957996..58ffe1977 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 ad03d229f..316fb2bc4 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 3716bc3ac..a3ed8334a 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 79964d3de..2953aaa5f 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 24b804819..1e9567c59 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 6efd670c5..006ae2473 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 869326d87..bbad81a71 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() - - 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() + const workspaceID = WorkspaceV2.ID.ascending() + + 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 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 result = yield* project.fromDirectory(tmp) + + 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 b1d637302..be8d22331 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 fa70ecb89..230ade33a 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 fedf98371..3da2d02ea 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 763b724b6..084d28dd4 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 0c692c50c..cf18e842f 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 665c792de..59c3f8da7 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 4ac62cf69..563fa0255 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 0763ed201..e52d6885c 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 1ea2c8242..c05547657 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 6da3f493b..7fb22ddf5 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 0fa710f02..20975d986 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 9fda48cc9..74c9f70ec 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 4886f250f..2a6124f5d 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 5f6f87972..47c71e9e0 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 df49ae084..278e36a96 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 66bd0bced..000000000 --- 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 44d421ea0..14e246c2b 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 ce94ddda9..6bd060e52 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 b14647680..86cbd13d9 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 7842752ad..bd261cbe4 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 e1fe93ba7..49830686f 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 aa7e4946d..694956f46 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 c464c42db..eec2f9fbc 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 2087ad830..65bdfa7c5 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 000000000..a780391ae --- /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 25181f3b2..52b508705 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 c221bdd19..f217bf844 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(Layer.mergeAll(Session.defaultLayer, Database.defaultLayer, httpApiLayer)) -const it = testEffect(Session.defaultLayer) +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 6e99fa7b1..972891f9a 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 db06a7a77..7eac7d4f3 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 6a1c1624c..0db044a86 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 50a400674..f2f72dc5f 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 35d6cb313..15bfe4279 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 290023ead..e79f655fb 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 b22777861..fb9118a2f 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 1a16016a5..093ec43c0 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 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 headers = { "Content-Type": "application/json" } + + 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) - - const forked = yield* Effect.promise(() => - Promise.resolve( - app.request(`/session/${session.id}/fork`, { - method: "POST", - headers, - body: JSON.stringify({}), - }), - ), - ) + expect(((yield* fetched.json) as SessionNs.Info).metadata).toEqual(next.metadata) + + 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 cec1dbcd9..f7f22b432 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 363e89337..3ae4c2f13 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 6cd17d255..c176c8e03 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 0f3875ae1..a54a77c3f 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 9f73cd8a4..62a618588 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 55ddc621c..c4e651095 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 0f9c340dd..3855e9c3a 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 33e96d121..9aa8ce95d 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 076d4c9f7..29c25d1ad 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 cd381ecd0..892700302 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 82bed0e9c..09d19cda3 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 e558d07b5..5da80ea3e 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 ede122297..e68ad962f 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 4c4647457..889d2a6d8 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 e1cbec036..32caaa113 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 c70c17d45..0df791096 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 4fcb323fe..1323c2aba 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 906414fdb..92249c4a0 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 2b958cb68..2883a1ab2 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") - }) - yield* Effect.addFinalizer(() => Effect.sync(unsubCreated)) - - const unsubUpdated = subscribeGlobal(SessionNs.Event.Updated.type, () => { - push("updated") + 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(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 89ed11613..b5fed974a 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 125c63c0f..f2d28864b 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 806c57483..14bc876c8 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 1daa4c2c8..a3662f60c 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(), - ), - ) - - yield* bus.publish(Session.Event.Diff, { + 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* 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 fc1f6bff6..e078b5eaf 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 8b4219195..81642f574 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 ba7f0912a..000000000 --- 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 598a635cd..0ac2f2c59 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 2d3064697..29a09acb7 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 e3307d2ae..000000000 --- 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 be5754f3b..01e326ec5 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 3f644ed53..9abc33885 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 e59caaa72..06019001f 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() + it.instance("no-ops for paths inside the instance directory", () => + Effect.gen(function* () { + const test = yield* TestInstance + const { requests, ctx } = makeCtx() - yield* assertExternalDirectoryEffect(ctx, path.join("/tmp/project", "file.txt")) + yield* assertExternalDirectoryEffect(ctx, path.join(test.directory, "file.txt")) - expect(requests.length).toBe(0) - }), - ), + expect(requests.length).toBe(0) + }), ) - it.live("asks with a single canonical glob", () => + it.instance("asks with a single canonical glob", () => Effect.gen(function* () { + const test = yield* TestInstance const { requests, ctx } = makeCtx() - const directory = "/tmp/project" - const target = "/tmp/outside/file.txt" + 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 027d5201c..a8cf5c9a3 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 875edc1c0..f3b1d7efd 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 854c1f891..0bbc58d44 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 f8c656ccf..4853cbe2f 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 25c50678a..489a756d5 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 2d7c70efc..75b103e2f 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 c854e51a3..34c29b717 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 ddaa5c2ec..fb8f95882 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 6732b42bb..f96730094 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 17e7fbea6..58391e0e3 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 b8edc2dc2..349606dec 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 08f156092..6cc72f383 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 588521281..a8d69c7be 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 000000000..3e8344676 --- /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 005/412] 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 | 190 +- 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 | 260 +- 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 | 128 +- .../opencode/test/project/project.test.ts | 23 +- packages/opencode/test/project/vcs.test.ts | 8 +- .../test/project/worktree-remove.test.ts | 186 +- .../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 | 50 +- .../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 | 102 +- packages/opencode/test/tool/read.test.ts | 8 +- .../opencode/test/tool/repo_clone.test.ts | 244 +- .../opencode/test/tool/repo_overview.test.ts | 162 +- packages/opencode/test/tool/skill.test.ts | 156 +- .../test/v2/session-message-updater.test.ts | 332 +- packages/sdk/js/src/v2/gen/sdk.gen.ts | 10 +- packages/sdk/js/src/v2/gen/types.gen.ts | 5868 ++++--- packages/sdk/openapi.json | 13659 +++++++++------- 71 files changed, 12548 insertions(+), 10185 deletions(-) diff --git a/packages/core/migration/20260530232709_lovely_romulus/snapshot.json b/packages/core/migration/20260530232709_lovely_romulus/snapshot.json index f171a7527..8c979997c 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 8c5f1f6cc..a74d39f4e 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 4560f8a2c..f3d39f3ac 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 4447c6008..1a6918b33 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 63671a84f..bf39f3e5b 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 86e20a66d..1603c3fa7 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 3b37a0bfc..18483e1cf 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 0b0bd133a..32e67decf 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 02a41e07c..5dda2cd2c 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 cb9272adf..d7471a440 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 105fb12dd..0be8c64ef 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 99fc3243c..1c1e21880 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.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.agent.switched": (event) => { + return adapter.appendMessage( + new SessionMessage.AgentSwitched({ id: event.id, - type: "assistant", + type: "agent-switched", + metadata: event.metadata, 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.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.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.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.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.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.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.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.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 d2314adc6..121b1fe01 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 e0e1b3f68..5b0b08c96 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 53d233089..c3e5d2d75 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 8720d88cf..37e255391 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 c42b7d524..8b091d4ff 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 35546db84..220abc834 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 d84e21dad..35f58f666 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() - } - - 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(), - }) + 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)) - .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 69e3ca9aa..d809bc31f 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 468ee34ab..1806fb6a7 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 2ded2c2cd..b9142beab 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 076307291..7bbbfcca6 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 edf50927a..e1294c177 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 31ecd5eff..22039ca45 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 c20cf7e07..ceb78e632 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 0ea62147c..eb63ee534 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 209b1e05d..862e06fc2 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 f51635bf8..36e394f0a 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 b055c488b..f87a24a74 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 0b0d46526..e32ce9803 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 bf0ae3b84..665b62898 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 1d21774ee..fc2fd0799 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 aae98a8c7..fed3824a7 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 27041925d..656555573 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 35f8e44d7..1530df66b 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 96b5ad5ae..e0388a7fd 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 b99131e26..86f3a5dad 100644 --- a/packages/opencode/test/lsp/index.test.ts +++ b/packages/opencode/test/lsp/index.test.ts @@ -19,53 +19,26 @@ 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", () => { 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 { - 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.touchFile(path.join(dir, "..", "outside.ts")) yield* lsp.hover({ - file: path.join(dir, "src", "inside.ts"), + file: path.join(dir, "..", "hover.ts"), line: 0, character: 0, }) @@ -75,28 +48,49 @@ describe("lsp.spawn", () => { } }), ), + { 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 - 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() - } - }), - ), + 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 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 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") + }), { 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) - - try { - yield* lsp.hover({ - file: path.join(dir, "src", "inside.ts"), - line: 0, - character: 0, - }) - expect(spy).toHaveBeenCalledTimes(1) - } finally { - spy.mockRestore() - } - }), - ), + 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: { @@ -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) - - 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() - } - }), - ), + 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() + } + }), + ), { 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) - - 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() - } - }), - ), + 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() + } + }), + ), { 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) - - 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() - } - }), - ), + 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() + } + }), + ), { config: { lsp: true } }, ) }) diff --git a/packages/opencode/test/lsp/lifecycle.test.ts b/packages/opencode/test/lsp/lifecycle.test.ts index f6db6c087..5d0313e6d 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 b05da50cb..a6d7e2ead 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 316fb2bc4..909e46c7d 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 2953aaa5f..7fcc26cec 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 bbad81a71..c10c42337 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 be8d22331..b30923482 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 230ade33a..c71757802 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) - - yield* Effect.promise(() => $`git worktree add --no-checkout -b ${branch} ${dir}`.cwd(root).quiet()) - yield* Effect.promise(() => $`git reset --hard`.cwd(dir).quiet()) + 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()) + + 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 prev = yield* Effect.acquireRelease( + Effect.sync(() => { + const prev = process.env.PATH ?? "" + process.env.PATH = `${bin}${path.delimiter}${prev}` + return prev + }), + (prev) => + Effect.sync(() => { + process.env.PATH = prev + }), + ) + void prev - const real = (yield* Effect.promise(() => $`which git`.quiet().text())).trim() - expect(real).toBeTruthy() + const ok = yield* svc.remove({ directory: dir }) - const bin = path.join(root, "bin") - const shim = path.join(bin, "git") - yield* Effect.promise(() => fs.mkdir(bin, { recursive: true })) + expect(ok).toBe(true) + expect( 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( - Effect.sync(() => { - const prev = process.env.PATH ?? "" - process.env.PATH = `${bin}${path.delimiter}${prev}` - return prev - }), - (prev) => - Effect.sync(() => { - process.env.PATH = prev - }), - ) - void prev - - const ok = yield* svc.remove({ directory: dir }) - - 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 ref = yield* Effect.promise(() => - $`git show-ref --verify --quiet refs/heads/${branch}`.cwd(root).quiet().nothrow(), - ) - expect(ref.exitCode).not.toBe(0) + 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 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) - - 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 ok = yield* svc.remove({ directory: dir }) - - 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 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()) + + 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 }) + + 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) }), { git: true }, ) diff --git a/packages/opencode/test/provider/amazon-bedrock.test.ts b/packages/opencode/test/provider/amazon-bedrock.test.ts index 084d28dd4..470d42fd1 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 c05547657..2492fb271 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 2a6124f5d..fa7f9277d 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 47c71e9e0..5ae076b43 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 14e246c2b..667341561 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 f2f72dc5f..ee27d3e3a 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 3ae4c2f13..f98f8e42a 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 c4e651095..9bff89c34 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 892700302..2376750ee 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 09d19cda3..75850e59f 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 889d2a6d8..f04925b98 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 32caaa113..080db82bc 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 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", - }) + 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) + + 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 2883a1ab2..06bd6f952 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 a3662f60c..feb070a09 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 81642f574..b71577334 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 29a09acb7..a33d6d5e5 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 f3b1d7efd..c456ae6cc 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 { 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") + 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") + + 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 { 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") + 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") + + 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 { 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") + 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") + + 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 4853cbe2f..b42bd80e7 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 75b103e2f..8b0072bac 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") - - 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), - ) - - 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") + 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]) + + 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") }), ) 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") - - 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 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}`]) - - 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") + 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]) + + 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)) + + 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), + ) + + 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") - - 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), - ) - - 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") + 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]) + + 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") }), ) 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" }, - ] - - 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 }, - ) + 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) + + 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) - - 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") - } + 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") + } }), ) 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) - - 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", - ) - } + 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", + ) + } }), ) }) diff --git a/packages/opencode/test/tool/repo_overview.test.ts b/packages/opencode/test/tool/repo_overview.test.ts index 34c29b717..953703919 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") - - 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") + 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) + + 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 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}`) + 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) + + 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) - - 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") - } + 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") + } }), ) 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 f96730094..73f1ae180 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")) - - const home = process.env.OPENCODE_TEST_HOME - process.env.OPENCODE_TEST_HOME = dir - yield* Effect.addFinalizer(() => + ), + ) + 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(() => + 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 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}`) + } + + 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}`) }), ) 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, + }, ) - - 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.') - } + .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.') + } }), ) }) diff --git a/packages/opencode/test/v2/session-message-updater.test.ts b/packages/opencode/test/v2/session-message-updater.test.ts index a8d69c7be..365a8af20 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)) - - 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 }, + } 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 }, + }, + 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)) - - 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)) + } 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), + ) 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), + ) + + 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.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.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.success", - data: { - sessionID, - timestamp: DateTime.makeUnsafe(4), - callID, - structured: {}, - 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 @@ -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: 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.ended", - data: { - sessionID, - timestamp: DateTime.makeUnsafe(4), - text: "final summary", - include: "recent context", - }, - } 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(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), + ) 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 6c5ea3725..be1e4abc6 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 a1a720688..b19a5f244 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -5,17 +5,44 @@ export type ClientOptions = { } export type Event = - | EventTuiPromptAppend - | EventTuiCommandExecute - | EventTuiToastShow1 - | EventTuiSessionSelect - | EventServerConnected - | EventGlobalDisposed - | EventServerInstanceDisposed + | EventModelsDevRefreshed + | EventPluginAdded + | EventCatalogModelUpdated | EventFileEdited + | 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 | EventFileWatcherUpdated - | EventLspClientDiagnostics - | EventLspUpdated + | EventSessionCreated + | EventSessionUpdated + | EventSessionDeleted + | EventMessageUpdated + | EventMessageRemoved + | EventMessagePartUpdated + | EventMessagePartRemoved | EventMessagePartDelta | EventPermissionAsked | EventPermissionReplied @@ -27,11 +54,16 @@ export type Event = | EventTodoUpdated | EventSessionStatus | EventSessionIdle + | EventSessionCompacted + | EventLspUpdated + | EventTuiPromptAppend2 + | EventTuiCommandExecute2 + | EventTuiToastShow2 + | EventTuiSessionSelect2 | EventMcpToolsChanged | EventMcpBrowserOpenFailed | EventCommandExecuted | EventProjectUpdated - | EventSessionCompacted | EventVcsBranchUpdated | EventWorkspaceReady | EventWorkspaceFailed @@ -44,42 +76,8 @@ export type Event = | 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 + | EventServerConnected + | EventGlobalDisposed | EventAccountAdded | EventAccountRemoved | EventAccountSwitched @@ -120,82 +118,113 @@ export type InvalidRequestError = { field?: string } -export type EventTuiPromptAppend = { - id: string - type: "tui.prompt.append" - properties: { - text: string - } +export type Prompt = { + text: string + files?: Array + agents?: Array + references?: Array } -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 SnapshotFileDiff = { + file?: string + patch?: string + additions: number + deletions: number + status?: "added" | "deleted" | "modified" } -export type EventTuiToastShow = { +export type Session = { id: string - type: "tui.toast.show" - properties: { - title?: string - message: string - variant: "info" | "success" | "warning" | "error" - duration?: number + 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 EventTuiSessionSelect = { - id: string - type: "tui.session.select" - properties: { - /** - * Session ID to navigate to - */ - sessionID: string - } +export type OutputFormatText = { + type: "text" } -export type PermissionRequest = { +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 - permission: string - patterns: Array - metadata: { - [key: string]: unknown + role: "user" + time: { + created: number } - always: Array - tool?: { - messageID: string - callID: string + 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 SnapshotFileDiff = { - file?: string - patch?: string - additions: number - deletions: number - status?: "added" | "deleted" | "modified" } export type ProviderAuthError = { @@ -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,756 +596,1914 @@ 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 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?: PermissionRuleset - revert?: { - messageID: string - partID?: string - snapshot?: string - diff?: 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 Prompt = { - text: string - files?: Array - agents?: Array - references?: Array +export type QuestionTool = { + messageID: string + callID: string } -export type GlobalEvent = { - directory: string - 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 +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 } -/** - * Log level - */ -export type LogLevel = "DEBUG" | "INFO" | "WARN" | "ERROR" - -/** - * Server configuration for opencode serve and web commands - */ -export type ServerConfig = { - port?: number - hostname?: string - mdns?: boolean - mdnsDomain?: string - cors?: Array -} - -export type ReferenceConfigEntry = - | string +export type SessionStatus = | { - /** - * Git repository URL, host/path reference, or GitHub owner/repo shorthand - */ - repository: string - branch?: string + type: "idle" } | { - /** - * Absolute path, ~/ path, or workspace-relative path to a local reference directory - */ - path: string + type: "retry" + attempt: number + message: string + action?: { + reason: string + provider: string + title: string + message: string + label: string + link?: string + } + next: number } - -export type ReferenceConfig = { - [key: string]: ReferenceConfigEntry -} - -export type PermissionActionConfig = "ask" | "allow" | "deny" - -export type PermissionObjectConfig = { - [key: string]: PermissionActionConfig -} - -export type PermissionRuleConfig = PermissionActionConfig | PermissionObjectConfig - -export type PermissionConfig = - | PermissionActionConfig | { - read?: PermissionRuleConfig - edit?: PermissionRuleConfig - glob?: PermissionRuleConfig - grep?: PermissionRuleConfig - list?: PermissionRuleConfig - bash?: PermissionRuleConfig - task?: PermissionRuleConfig - external_directory?: PermissionRuleConfig - todowrite?: PermissionActionConfig - question?: PermissionActionConfig - webfetch?: PermissionActionConfig - websearch?: PermissionActionConfig - repo_clone?: PermissionRuleConfig - repo_overview?: PermissionRuleConfig - lsp?: PermissionRuleConfig - doom_loop?: PermissionActionConfig - skill?: PermissionRuleConfig - [key: string]: PermissionRuleConfig | PermissionActionConfig | undefined + type: "busy" } -export type AgentConfig = { - model?: string - variant?: string - temperature?: number - top_p?: number - prompt?: string - tools?: { - [key: string]: boolean - } - disable?: boolean - description?: string - mode?: "subagent" | "primary" | "all" - hidden?: boolean - options?: { - [key: string]: unknown - } - /** - * Hex color code (e.g., #FF5733) or theme color (e.g., primary) - */ - color?: string | "primary" | "secondary" | "accent" | "success" | "warning" | "error" | "info" - steps?: number - maxSteps?: number - permission?: PermissionConfig - [key: string]: - | unknown - | string - | number +export type Pty = { + id: string + title: string + command: string + args: Array + cwd: string + status: "running" | "exited" + pid: number +} + +export type GlobalEvent = { + directory: string + project?: string + workspace?: string + payload: | { - [key: string]: boolean + id: string + type: "models-dev.refreshed" + properties: { + [key: string]: unknown + } } - | boolean - | "subagent" - | "primary" - | "all" | { - [key: string]: unknown + id: string + type: "plugin.added" + properties: { + id: string + } } - | string - | "primary" - | "secondary" - | "accent" - | "success" - | "warning" - | "error" - | "info" - | number - | PermissionConfig - | undefined -} - -export type ProviderConfig = { - api?: string - name?: string - env?: Array - id?: string - npm?: string - whitelist?: Array - blacklist?: Array - options?: { - apiKey?: string - baseURL?: string - enterpriseUrl?: string - setCacheKey?: boolean - /** - * Timeout in milliseconds for full requests to this provider. Set to false to disable timeout. - */ - timeout?: number | false - /** - * Timeout in milliseconds to wait for response headers. Provider integrations may set defaults. Set to false to disable timeout. - */ - headerTimeout?: number | false - chunkTimeout?: number - [key: string]: unknown | string | boolean | number | false | number | false | number | undefined - } - models?: { - [key: string]: { - id?: string - name?: string - family?: string - release_date?: string - attachment?: boolean - reasoning?: boolean - temperature?: boolean - tool_call?: boolean - interleaved?: - | true - | { - field: "reasoning_content" | "reasoning_details" + | { + 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 } - cost?: { - input: number - output: number - cache_read?: number - cache_write?: number - context_over_200k?: { - input: number - output: number - cache_read?: number - cache_write?: number } } - limit?: { - context: number - input?: number - output: number + | { + id: string + type: "session.next.prompted" + properties: { + timestamp: number + sessionID: string + prompt: Prompt + } } - modalities?: { - input?: Array<"text" | "audio" | "image" | "video" | "pdf"> - output?: Array<"text" | "audio" | "image" | "video" | "pdf"> + | { + id: string + type: "session.next.synthetic" + properties: { + timestamp: number + sessionID: string + text: string + } } - experimental?: boolean - status?: "alpha" | "beta" | "deprecated" | "active" - provider?: { - npm?: string - api?: string + | { + id: string + type: "session.next.shell.started" + properties: { + timestamp: number + sessionID: string + callID: string + command: string + } } - options?: { - [key: string]: unknown + | { + id: string + type: "session.next.shell.ended" + properties: { + timestamp: number + sessionID: string + callID: string + output: string + } } - headers?: { - [key: string]: string + | { + id: string + type: "session.next.step.started" + properties: { + timestamp: number + sessionID: string + agent: string + model: { + id: string + providerID: string + variant?: string + } + snapshot?: string + } } - /** - * Variant-specific configuration - */ - variants?: { - [key: string]: { - disabled?: boolean - [key: string]: unknown | boolean | undefined + | { + 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 McpLocalConfig = { - /** - * Type of MCP server connection - */ - type: "local" - /** - * Command and arguments to run the MCP server - */ + | { + 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 + } + } +} + +/** + * Log level + */ +export type LogLevel = "DEBUG" | "INFO" | "WARN" | "ERROR" + +/** + * Server configuration for opencode serve and web commands + */ +export type ServerConfig = { + port?: number + hostname?: string + mdns?: boolean + mdnsDomain?: string + cors?: Array +} + +export type ReferenceConfigEntry = + | string + | { + /** + * Git repository URL, host/path reference, or GitHub owner/repo shorthand + */ + repository: string + branch?: string + } + | { + /** + * Absolute path, ~/ path, or workspace-relative path to a local reference directory + */ + path: string + } + +export type ReferenceConfig = { + [key: string]: ReferenceConfigEntry +} + +export type PermissionActionConfig = "ask" | "allow" | "deny" + +export type PermissionObjectConfig = { + [key: string]: PermissionActionConfig +} + +export type PermissionRuleConfig = PermissionActionConfig | PermissionObjectConfig + +export type PermissionConfig = + | PermissionActionConfig + | { + read?: PermissionRuleConfig + edit?: PermissionRuleConfig + glob?: PermissionRuleConfig + grep?: PermissionRuleConfig + list?: PermissionRuleConfig + bash?: PermissionRuleConfig + task?: PermissionRuleConfig + external_directory?: PermissionRuleConfig + todowrite?: PermissionActionConfig + question?: PermissionActionConfig + webfetch?: PermissionActionConfig + websearch?: PermissionActionConfig + repo_clone?: PermissionRuleConfig + repo_overview?: PermissionRuleConfig + lsp?: PermissionRuleConfig + doom_loop?: PermissionActionConfig + skill?: PermissionRuleConfig + [key: string]: PermissionRuleConfig | PermissionActionConfig | undefined + } + +export type AgentConfig = { + model?: string + variant?: string + temperature?: number + top_p?: number + prompt?: string + tools?: { + [key: string]: boolean + } + disable?: boolean + description?: string + mode?: "subagent" | "primary" | "all" + hidden?: boolean + options?: { + [key: string]: unknown + } + /** + * Hex color code (e.g., #FF5733) or theme color (e.g., primary) + */ + color?: string | "primary" | "secondary" | "accent" | "success" | "warning" | "error" | "info" + steps?: number + maxSteps?: number + permission?: PermissionConfig + [key: string]: + | unknown + | string + | number + | { + [key: string]: boolean + } + | boolean + | "subagent" + | "primary" + | "all" + | { + [key: string]: unknown + } + | string + | "primary" + | "secondary" + | "accent" + | "success" + | "warning" + | "error" + | "info" + | number + | PermissionConfig + | undefined +} + +export type ProviderConfig = { + api?: string + name?: string + env?: Array + id?: string + npm?: string + whitelist?: Array + blacklist?: Array + options?: { + apiKey?: string + baseURL?: string + enterpriseUrl?: string + setCacheKey?: boolean + /** + * Timeout in milliseconds for full requests to this provider. Set to false to disable timeout. + */ + timeout?: number | false + /** + * Timeout in milliseconds to wait for response headers. Provider integrations may set defaults. Set to false to disable timeout. + */ + headerTimeout?: number | false + chunkTimeout?: number + [key: string]: unknown | string | boolean | number | false | number | false | number | undefined + } + models?: { + [key: string]: { + id?: string + name?: string + family?: string + release_date?: string + attachment?: boolean + reasoning?: boolean + temperature?: boolean + tool_call?: boolean + interleaved?: + | true + | { + field: "reasoning_content" | "reasoning_details" + } + cost?: { + input: number + output: number + cache_read?: number + cache_write?: number + context_over_200k?: { + input: number + output: number + cache_read?: number + cache_write?: number + } + } + limit?: { + context: number + input?: number + output: number + } + modalities?: { + input?: Array<"text" | "audio" | "image" | "video" | "pdf"> + output?: Array<"text" | "audio" | "image" | "video" | "pdf"> + } + experimental?: boolean + status?: "alpha" | "beta" | "deprecated" | "active" + provider?: { + npm?: string + api?: string + } + options?: { + [key: string]: unknown + } + headers?: { + [key: string]: string + } + /** + * Variant-specific configuration + */ + variants?: { + [key: string]: { + disabled?: boolean + [key: string]: unknown | boolean | undefined + } + } + } + } +} + +export type McpLocalConfig = { + /** + * Type of MCP server connection + */ + type: "local" + /** + * Command and arguments to run the MCP server + */ command: Array environment?: { [key: string]: string } - enabled?: boolean - timeout?: number + enabled?: boolean + timeout?: number +} + +export type McpOAuthConfig = { + clientId?: string + clientSecret?: string + scope?: string + callbackPort?: number + redirectUri?: string +} + +export type McpRemoteConfig = { + /** + * Type of MCP server connection + */ + type: "remote" + /** + * URL of the remote MCP server + */ + url: string + enabled?: boolean + headers?: { + [key: string]: string + } + /** + * OAuth authentication configuration for the MCP server. Set to false to disable OAuth auto-detection. + */ + oauth?: McpOAuthConfig | false + timeout?: number +} + +/** + * @deprecated Always uses stretch layout. + */ +export type LayoutConfig = "auto" | "stretch" + +export type ImageAttachmentConfig = { + auto_resize?: boolean + max_width?: number + max_height?: number + max_base64_bytes?: number +} + +export type AttachmentConfig = { + image?: ImageAttachmentConfig +} + +export type Config = { + $schema?: string + shell?: string + logLevel?: LogLevel + server?: ServerConfig + command?: { + [key: string]: { + template: string + description?: string + agent?: string + model?: string + subtask?: boolean + } + } + skills?: { + paths?: Array + urls?: Array + } + reference?: ReferenceConfig + watcher?: { + ignore?: Array + } + snapshot?: boolean + plugin?: Array< + | string + | [ + string, + { + [key: string]: unknown + }, + ] + > + share?: "manual" | "auto" | "disabled" + autoshare?: boolean + /** + * Automatically update to the latest version. Set to true to auto-update, false to disable, or 'notify' to show update notifications + */ + autoupdate?: boolean | "notify" + disabled_providers?: Array + enabled_providers?: Array + model?: string + small_model?: string + default_agent?: string + username?: string + mode?: { + build?: AgentConfig + plan?: AgentConfig + [key: string]: AgentConfig | undefined + } + agent?: { + plan?: AgentConfig + build?: AgentConfig + general?: AgentConfig + explore?: AgentConfig + scout?: AgentConfig + title?: AgentConfig + summary?: AgentConfig + compaction?: AgentConfig + [key: string]: AgentConfig | undefined + } + provider?: { + [key: string]: ProviderConfig + } + mcp?: { + [key: string]: + | McpLocalConfig + | McpRemoteConfig + | { + enabled: boolean + } + } + /** + * Enable or configure formatters. Omit or set to false to disable, true to enable built-ins, or an object to enable built-ins with overrides. + */ + formatter?: + | boolean + | { + [key: string]: { + disabled?: boolean + command?: Array + environment?: { + [key: string]: string + } + extensions?: Array + } + } + /** + * Enable or configure LSP servers. Omit or set to false to disable, true to enable built-ins, or an object to enable built-ins with overrides. + */ + lsp?: + | boolean + | { + [key: string]: + | { + disabled: true + } + | { + command: Array + extensions?: Array + disabled?: boolean + env?: { + [key: string]: string + } + initialization?: { + [key: string]: unknown + } + } + } + instructions?: Array + layout?: LayoutConfig + permission?: PermissionConfig + tools?: { + [key: string]: boolean + } + attachment?: AttachmentConfig + enterprise?: { + url?: string + } + tool_output?: { + max_lines?: number + max_bytes?: number + } + compaction?: { + auto?: boolean + prune?: boolean + tail_turns?: number + preserve_recent_tokens?: number + reserved?: number + } + experimental?: { + disable_paste_summary?: boolean + batch_tool?: boolean + openTelemetry?: boolean + primary_tools?: Array + continue_loop_on_deny?: boolean + mcp_timeout?: number + policies?: Array + } +} + +export type Model = { + id: string + providerID: string + api: { + id: string + url: string + npm: string + } + name: string + family?: string + capabilities: { + temperature: boolean + reasoning: boolean + attachment: boolean + toolcall: boolean + input: { + text: boolean + audio: boolean + image: boolean + video: boolean + pdf: boolean + } + output: { + text: boolean + audio: boolean + image: boolean + video: boolean + pdf: boolean + } + interleaved: + | boolean + | { + field: "reasoning_content" | "reasoning_details" + } + } + cost: { + input: number + output: number + cache: { + read: number + write: number + } + tiers?: Array<{ + input: number + output: number + cache: { + read: number + write: number + } + tier: { + type: "context" + size: number + } + }> + experimentalOver200K?: { + input: number + output: number + cache: { + read: number + write: number + } + } + } + limit: { + context: number + input?: number + output: number + } + status: "alpha" | "beta" | "deprecated" | "active" + options: { + [key: string]: unknown + } + headers: { + [key: string]: string + } + release_date: string + variants?: { + [key: string]: { + [key: string]: unknown + } + } +} + +export type Provider = { + id: string + name: string + source: "env" | "config" | "custom" | "api" + env: Array + key?: string + options: { + [key: string]: unknown + } + models: { + [key: string]: Model + } +} + +export type ConsoleState = { + consoleManagedProviders: Array + activeOrgName?: string + switchableOrgCount: number +} + +export type EffectHttpApiErrorInternalServerError = { + _tag: "InternalServerError" +} + +export type ToolListItem = { + id: string + description: string + parameters: unknown +} + +export type ToolList = Array + +export type ToolIds = Array + +export type WorktreeError = { + name: + | "WorktreeNotGitError" + | "WorktreeNameGenerationFailedError" + | "WorktreeCreateFailedError" + | "WorktreeStartCommandFailedError" + | "WorktreeRemoveFailedError" + | "WorktreeResetFailedError" + | "WorktreeListFailedError" + data: { + message: string + } +} + +export type WorktreeCreateInput = { + name?: string + /** + * Additional startup script to run after the project's start command + */ + startCommand?: string +} + +export type Worktree = { + name: string + branch?: string + directory: string +} + +export type WorktreeRemoveInput = { + directory: string +} + +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 + worktree: string +} + +export type GlobalSession = { + 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 + } + project: ProjectSummary | null +} + +export type McpResource = { + name: string + uri: string + description?: string + mimeType?: string + client: string +} + +export type Symbol = { + name: string + kind: number + location: { + uri: string + range: Range + } +} + +export type FileNode = { + name: string + path: string + absolute: string + type: "file" | "directory" + ignored: boolean +} + +export type FileContent = { + type: "text" | "binary" + content: string + diff?: string + patch?: { + oldFileName: string + newFileName: string + oldHeader?: string + newHeader?: string + hunks: Array<{ + oldStart: number + oldLines: number + newStart: number + newLines: number + lines: Array + }> + index?: string + } + encoding?: "base64" + mimeType?: string +} + +export type File = { + path: string + added: number + removed: number + status: "added" | "deleted" | "modified" +} + +export type Path = { + home: string + state: string + config: string + worktree: string + directory: string +} + +export type VcsInfo = { + branch?: string + default_branch?: string +} + +export type VcsFileStatus = { + file: string + additions: number + deletions: number + status: "added" | "deleted" | "modified" +} + +export type VcsFileDiff = { + file: string + patch?: string + additions: number + deletions: number + status?: "added" | "deleted" | "modified" +} + +export type VcsApplyError = { + name: "VcsApplyError" + data: { + message: string + reason: "non-git" | "not-clean" + } } -export type McpOAuthConfig = { - clientId?: string - clientSecret?: string - scope?: string - callbackPort?: number - redirectUri?: string +export type Command = { + name: string + description?: string + agent?: string + model?: string + source?: "command" | "mcp" | "skill" + template: string + subtask?: boolean + hints: Array } -export type McpRemoteConfig = { - /** - * Type of MCP server connection - */ - type: "remote" - /** - * URL of the remote MCP server - */ - url: string - enabled?: boolean - headers?: { - [key: string]: string +export type Agent = { + name: string + description?: string + mode: "subagent" | "primary" | "all" + native?: boolean + hidden?: boolean + topP?: number + temperature?: number + color?: string + permission: PermissionRuleset + model?: { + modelID: string + providerID: string } - /** - * OAuth authentication configuration for the MCP server. Set to false to disable OAuth auto-detection. - */ - oauth?: McpOAuthConfig | false - timeout?: number + variant?: string + prompt?: string + options: { + [key: string]: unknown + } + steps?: number } -/** - * @deprecated Always uses stretch layout. - */ -export type LayoutConfig = "auto" | "stretch" +export type LspStatus = { + id: string + name: string + root: string + status: "connected" | "error" +} -export type ImageAttachmentConfig = { - auto_resize?: boolean - max_width?: number - max_height?: number - max_base64_bytes?: number +export type FormatterStatus = { + name: string + extensions: Array + enabled: boolean } -export type AttachmentConfig = { - image?: ImageAttachmentConfig +export type McpStatusConnected = { + status: "connected" } -export type Config = { - $schema?: string - shell?: string - logLevel?: LogLevel - server?: ServerConfig - command?: { - [key: string]: { - template: string - description?: string - agent?: string - model?: string - subtask?: boolean - } +export type McpStatusDisabled = { + status: "disabled" +} + +export type McpStatusFailed = { + status: "failed" + error: string +} + +export type McpStatusNeedsAuth = { + status: "needs_auth" +} + +export type McpStatusNeedsClientRegistration = { + status: "needs_client_registration" + error: string +} + +export type McpStatus = + | McpStatusConnected + | McpStatusDisabled + | McpStatusFailed + | McpStatusNeedsAuth + | McpStatusNeedsClientRegistration + +export type McpUnsupportedOAuthError = { + error: string +} + +export type McpServerNotFoundError = { + _tag: "McpServerNotFoundError" + name: string + message: string +} + +export type Project = { + id: string + worktree: string + vcs?: "git" + name?: string + icon?: { + url?: string + override?: string + color?: string } - skills?: { - paths?: Array - urls?: Array + commands?: { + /** + * Startup script to run when creating a new workspace (worktree) + */ + start?: string } - reference?: ReferenceConfig - watcher?: { - ignore?: Array + time: { + created: number + updated: number + initialized?: number } - snapshot?: boolean - plugin?: Array< - | string - | [ - string, - { - [key: string]: unknown - }, - ] - > - share?: "manual" | "auto" | "disabled" - autoshare?: boolean + sandboxes: Array +} + +export type ProjectNotFoundError = { + _tag: "ProjectNotFoundError" + projectID: string + message: string +} + +export type PtyNotFoundError = { + _tag: "PtyNotFoundError" + ptyID: string + message: string +} + +export type PtyForbiddenError = { + _tag: "PtyForbiddenError" + message: string +} + +export type QuestionRequest = { + id: string + sessionID: string /** - * Automatically update to the latest version. Set to true to auto-update, false to disable, or 'notify' to show update notifications + * Questions to ask */ - autoupdate?: boolean | "notify" - disabled_providers?: Array - enabled_providers?: Array - model?: string - small_model?: string - default_agent?: string - username?: string - mode?: { - build?: AgentConfig - plan?: AgentConfig - [key: string]: AgentConfig | undefined - } - agent?: { - plan?: AgentConfig - build?: AgentConfig - general?: AgentConfig - explore?: AgentConfig - scout?: AgentConfig - title?: AgentConfig - summary?: AgentConfig - compaction?: AgentConfig - [key: string]: AgentConfig | undefined - } - provider?: { - [key: string]: ProviderConfig + 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 } - mcp?: { - [key: string]: - | McpLocalConfig - | McpRemoteConfig - | { - enabled: boolean - } + always: Array + tool?: { + messageID: string + callID: string } - /** - * Enable or configure formatters. Omit or set to false to disable, true to enable built-ins, or an object to enable built-ins with overrides. - */ - formatter?: - | boolean +} + +export type PermissionNotFoundError = { + _tag: "PermissionNotFoundError" + requestID: string + message: string +} + +export type ProviderAuthMethod = { + type: "oauth" | "api" + label: string + prompts?: Array< | { - [key: string]: { - disabled?: boolean - command?: Array - environment?: { - [key: string]: string - } - extensions?: Array + type: "text" + key: string + message: string + placeholder?: string + when?: { + key: string + op: "eq" | "neq" + value: string } } - /** - * Enable or configure LSP servers. Omit or set to false to disable, true to enable built-ins, or an object to enable built-ins with overrides. - */ - lsp?: - | boolean | { - [key: string]: - | { - disabled: true - } - | { - command: Array - extensions?: Array - disabled?: boolean - env?: { - [key: string]: string - } - initialization?: { - [key: string]: unknown - } - } + type: "select" + key: string + message: string + options: Array<{ + label: string + value: string + hint?: string + }> + when?: { + key: string + op: "eq" | "neq" + value: string + } } - instructions?: Array - layout?: LayoutConfig - permission?: PermissionConfig - tools?: { - [key: string]: boolean + > +} + +export type ProviderAuthAuthorization = { + url: string + method: "auto" | "code" + instructions: string +} + +export type ProviderAuthError1 = { + name: + | "BadRequest" + | "ProviderAuthOauthMissing" + | "ProviderAuthOauthCodeMissing" + | "ProviderAuthOauthCallbackFailed" + | "ProviderAuthValidationFailed" + data: { + providerID?: string + field?: string + message?: string + kind?: string } - attachment?: AttachmentConfig - enterprise?: { - url?: string +} + +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 } - tool_output?: { - max_lines?: number - max_bytes?: number + cost?: number + tokens?: { + input: number + output: number + reasoning: number + cache: { + read: number + write: number + } } - compaction?: { - auto?: boolean - prune?: boolean - tail_turns?: number - preserve_recent_tokens?: number - reserved?: number + share?: { + url: string } - experimental?: { - disable_paste_summary?: boolean - batch_tool?: boolean - openTelemetry?: boolean - primary_tools?: Array - continue_loop_on_deny?: boolean - mcp_timeout?: number - policies?: Array + 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 Model = { +export type Session2 = { id: string - providerID: string - api: { - id: string - url: string - npm: string + slug: string + projectID: string + workspaceID?: string + directory: string + path?: string + parentID?: string + summary?: { + additions: number + deletions: number + files: number + diffs?: Array } - name: string - family?: string - capabilities: { - temperature: boolean - reasoning: boolean - attachment: boolean - toolcall: boolean - input: { - text: boolean - audio: boolean - image: boolean - video: boolean - pdf: boolean - } - output: { - text: boolean - audio: boolean - image: boolean - video: boolean - pdf: boolean + cost?: number + tokens?: { + input: number + output: number + reasoning: number + cache: { + read: number + write: number } - interleaved: - | boolean - | { - field: "reasoning_content" | "reasoning_details" - } } - cost: { + 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: { + message: string + } +} + +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 } - tiers?: Array<{ - input: number - output: number - cache: { - read: number - write: number - } - tier: { - type: "context" - size: number - } - }> - experimentalOver200K?: { - input: number - output: number - cache: { - read: number - write: number - } - } - } - limit: { - context: number - input?: number - output: number - } - status: "alpha" | "beta" | "deprecated" | "active" - options: { - [key: string]: unknown } - headers: { - [key: string]: string + share?: { + url: string } - release_date: string - variants?: { - [key: string]: { - [key: string]: unknown - } + title: string + agent?: string + model?: { + id: string + providerID: string + variant?: string } -} - -export type Provider = { - id: string - name: string - source: "env" | "config" | "custom" | "api" - env: Array - key?: string - options: { + version: string + metadata?: { [key: string]: unknown } - models: { - [key: string]: Model + time: { + created: number + updated: number + compacting?: number + archived?: number } -} - -export type ConsoleState = { - consoleManagedProviders: Array - activeOrgName?: string - switchableOrgCount: number -} - -export type EffectHttpApiErrorInternalServerError = { - _tag: "InternalServerError" -} - -export type ToolListItem = { - id: string - description: string - parameters: unknown -} - -export type ToolList = Array - -export type ToolIds = Array - -export type WorktreeError = { - name: - | "WorktreeNotGitError" - | "WorktreeNameGenerationFailedError" - | "WorktreeCreateFailedError" - | "WorktreeStartCommandFailedError" - | "WorktreeRemoveFailedError" - | "WorktreeResetFailedError" - | "WorktreeListFailedError" - data: { - message: string + permission?: PermissionRuleset + revert?: { + messageID: string + partID?: string + snapshot?: string + diff?: string } } -export type WorktreeCreateInput = { - name?: string - /** - * Additional startup script to run after the project's start command - */ - startCommand?: string -} - -export type Worktree = { - name: string - branch?: string - directory: string -} - -export type WorktreeRemoveInput = { - directory: string -} - -export type WorktreeResetInput = { - directory: string -} - -export type ProjectSummary = { - id: string - name?: string - worktree: string -} - -export type GlobalSession = { +export type Session4 = { id: string slug: string projectID: string @@ -1535,271 +2554,164 @@ export type GlobalSession = { snapshot?: string diff?: string } - project: ProjectSummary | null -} - -export type McpResource = { - name: string - uri: string - description?: string - mimeType?: string - client: string } -export type Symbol = { - name: string - kind: number - location: { - uri: string - range: Range +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 } -} - -export type FileNode = { - name: string - path: string - absolute: string - type: "file" | "directory" - ignored: boolean -} - -export type FileContent = { - type: "text" | "binary" - content: string - diff?: string - patch?: { - oldFileName: string - newFileName: string - oldHeader?: string - newHeader?: string - hunks: Array<{ - oldStart: number - oldLines: number - newStart: number - newLines: number - lines: Array - }> - index?: string + cost?: number + tokens?: { + input: number + output: number + reasoning: number + cache: { + read: number + write: number + } } - encoding?: "base64" - mimeType?: string -} - -export type File = { - path: string - added: number - removed: number - status: "added" | "deleted" | "modified" -} - -export type Path = { - home: string - state: string - config: string - worktree: string - directory: string -} - -export type VcsInfo = { - branch?: string - default_branch?: string -} - -export type VcsFileStatus = { - file: string - additions: number - deletions: number - status: "added" | "deleted" | "modified" -} - -export type VcsFileDiff = { - file: string - patch?: string - additions: number - deletions: number - status?: "added" | "deleted" | "modified" -} - -export type VcsApplyError = { - name: "VcsApplyError" - data: { - message: string - reason: "non-git" | "not-clean" + 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 Command = { - name: string - description?: 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?: string - source?: "command" | "mcp" | "skill" - template: string - subtask?: boolean - hints: Array -} - -export type Agent = { - name: string - description?: string - mode: "subagent" | "primary" | "all" - native?: boolean - hidden?: boolean - topP?: number - temperature?: number - color?: string - permission: PermissionRuleset model?: { - modelID: string + id: string providerID: string + variant?: string } - variant?: string - prompt?: string - options: { + version: string + metadata?: { [key: string]: unknown } - steps?: number + time: { + created: number + updated: number + compacting?: number + archived?: number + } + permission?: PermissionRuleset + revert?: { + messageID: string + partID?: string + snapshot?: string + diff?: string + } } -export type LspStatus = { +export type Session7 = { id: string - name: string - root: string - status: "connected" | "error" -} - -export type FormatterStatus = { - name: string - extensions: Array - enabled: boolean -} - -export type McpStatusConnected = { - status: "connected" -} - -export type McpStatusDisabled = { - status: "disabled" -} - -export type McpStatusFailed = { - status: "failed" - error: string -} - -export type McpStatusNeedsAuth = { - status: "needs_auth" -} - -export type McpStatusNeedsClientRegistration = { - status: "needs_client_registration" - error: string -} - -export type McpStatus = - | McpStatusConnected - | McpStatusDisabled - | McpStatusFailed - | McpStatusNeedsAuth - | McpStatusNeedsClientRegistration - -export type McpUnsupportedOAuthError = { - error: string -} - -export type McpServerNotFoundError = { - _tag: "McpServerNotFoundError" - name: string - message: string -} - -export type ProjectNotFoundError = { - _tag: "ProjectNotFoundError" + slug: string projectID: string - message: string -} - -export type PtyNotFoundError = { - _tag: "PtyNotFoundError" - ptyID: string - message: string -} - -export type PtyForbiddenError = { - _tag: "PtyForbiddenError" - message: string -} - -export type QuestionNotFoundError = { - _tag: "QuestionNotFoundError" - requestID: string - message: string -} - -export type PermissionNotFoundError = { - _tag: "PermissionNotFoundError" - requestID: string - message: string -} - -export type ProviderAuthMethod = { - type: "oauth" | "api" - label: string - prompts?: Array< - | { - type: "text" - key: string - message: string - placeholder?: string - when?: { - key: string - op: "eq" | "neq" - value: string - } - } - | { - type: "select" - key: string - message: string - options: Array<{ - label: string - value: string - hint?: string - }> - when?: { - key: string - op: "eq" | "neq" - value: string - } - } - > -} - -export type ProviderAuthAuthorization = { - url: string - method: "auto" | "code" - instructions: string -} - -export type ProviderAuthError1 = { - name: - | "BadRequest" - | "ProviderAuthOauthMissing" - | "ProviderAuthOauthCodeMissing" - | "ProviderAuthOauthCallbackFailed" - | "ProviderAuthValidationFailed" - data: { - providerID?: string - field?: string - message?: string - kind?: string + workspaceID?: string + directory: string + path?: string + parentID?: string + summary?: { + additions: number + deletions: number + files: number + diffs?: Array } -} - -export type NotFoundError = { - name: "NotFoundError" - data: { - message: string + 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 } } @@ -1857,1029 +2769,903 @@ export type SessionBusyError = { message: string } -export type V2SessionsResponse = { - items: Array - cursor: { - previous?: string - next?: string - } -} - -export type InvalidCursorError = { - _tag: "InvalidCursorError" - message: string -} - -export type UnauthorizedError = { - _tag: "UnauthorizedError" - message: string -} - -export type SessionNotFoundError = { - _tag: "SessionNotFoundError" - sessionID: string - message: string -} - -export type ServiceUnavailableError = { - _tag: "ServiceUnavailableError" - message: string - service?: string -} - -export type UnknownError1 = { - _tag: "UnknownError" - message: string - ref?: string -} - -export type V2SessionMessagesResponse = { - items: Array - cursor: { - previous?: string - next?: string - } -} - -export type ProviderNotFoundError = { - _tag: "ProviderNotFoundError" - providerID: string - message: string -} - -export type EventTuiPromptAppend2 = { - type: "tui.prompt.append" - properties: { - text: string - } -} - -export type EventTuiCommandExecute2 = { - type: "tui.command.execute" - properties: { - command: - | "session.list" - | "session.new" - | "session.share" - | "session.interrupt" - | "session.compact" - | "session.page.up" - | "session.page.down" - | "session.line.up" - | "session.line.down" - | "session.half.page.up" - | "session.half.page.down" - | "session.first" - | "session.last" - | "prompt.clear" - | "prompt.submit" - | "agent.cycle" - | string - } -} - -export type EventTuiToastShow2 = { - type: "tui.toast.show" - properties: { - title?: string - message: string - variant: "info" | "success" | "warning" | "error" - duration?: number - } -} - -export type EventTuiSessionSelect2 = { - type: "tui.session.select" - properties: { - /** - * Session ID to navigate to - */ - sessionID: string - } -} - -export type Workspace = { +export type Session8 = { id: string - type: string - name: string - branch?: string | null - directory?: string | null - extra?: unknown | null + slug: string projectID: string - timeUsed: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" -} - -export type WorkspaceCreateError = { - name: "WorkspaceCreateError" - data: { - message: string - } -} - -export type WorkspaceWarpError = { - name: "WorkspaceWarpError" - data: { - message: string - } -} - -export type EffectHttpApiErrorForbidden = { - _tag: "Forbidden" -} - -export type SyncEventMessageUpdated = { - type: "sync" - name: "message.updated.1" - id: string - seq: number - aggregateID: "sessionID" - data: { - sessionID: string - info: Message - } -} - -export type SyncEventMessageRemoved = { - type: "sync" - name: "message.removed.1" - id: string - seq: number - aggregateID: "sessionID" - data: { - sessionID: string - messageID: string - } -} - -export type SyncEventMessagePartUpdated = { - type: "sync" - name: "message.part.updated.1" - id: string - seq: number - aggregateID: "sessionID" - data: { - sessionID: string - part: Part - time: number - } -} - -export type SyncEventMessagePartRemoved = { - type: "sync" - name: "message.part.removed.1" - id: string - seq: number - aggregateID: "sessionID" - data: { - sessionID: string - messageID: string - partID: string - } -} - -export type SyncEventSessionCreated = { - type: "sync" - name: "session.created.1" - id: string - seq: number - aggregateID: "sessionID" - data: { - sessionID: string - info: Session - } -} - -export type SyncEventSessionUpdated = { - type: "sync" - name: "session.updated.1" - id: string - seq: number - aggregateID: "sessionID" - data: { - sessionID: string - info: { - id?: string | null - slug?: string | null - projectID?: string | null - workspaceID?: string | null - directory?: string | null - path?: string | null - parentID?: string | null - summary?: { - additions: number - deletions: number - files: number - diffs?: Array - } | null - 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 + workspaceID?: string + directory: string + path?: string + parentID?: string + summary?: { + additions: number + deletions: number + files: number + diffs?: Array } -} - -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 + cost?: number + tokens?: { + input: number + output: number + reasoning: number + cache: { + read: number + write: number } } -} - -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 + share?: { + url: 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 + title: string + agent?: string + model?: { + id: string + providerID: string + variant?: 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 + version: string + metadata?: { + [key: string]: unknown } -} - -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 - } + time: { + created: number + updated: number + compacting?: number + archived?: number + } + permission?: PermissionRuleset + revert?: { + messageID: string + partID?: string snapshot?: string + diff?: string } } -export type SyncEventSessionNextStepEnded = { - type: "sync" - name: "session.next.step.ended.1" +export type Session9 = { 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 - } + 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 } - 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 + share?: { + url: string } -} - -export type SyncEventSessionNextTextStarted = { - type: "sync" - name: "session.next.text.started.1" - id: string - seq: number - aggregateID: "sessionID" - data: { - timestamp: number - sessionID: 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 SyncEventSessionNextTextDelta = { - type: "sync" - name: "session.next.text.delta.1" - id: string - seq: number - aggregateID: "sessionID" - data: { - timestamp: number - sessionID: string - delta: string +export type V2SessionsResponse = { + items: Array + cursor: { + previous?: string + next?: 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 InvalidCursorError = { + _tag: "InvalidCursorError" + message: 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 UnauthorizedError = { + _tag: "UnauthorizedError" + message: 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 SessionNotFoundError = { + _tag: "SessionNotFoundError" + sessionID: string + message: 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 ServiceUnavailableError = { + _tag: "ServiceUnavailableError" + message: string + service?: 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 UnknownError1 = { + _tag: "UnknownError" + message: string + ref?: 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 V2SessionMessagesResponse = { + items: Array + cursor: { + previous?: string + next?: 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 ProviderNotFoundError = { + _tag: "ProviderNotFoundError" + providerID: string + message: 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 EventTuiPromptAppend = { + type: "tui.prompt.append" + properties: { + text: string } } -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 EventTuiCommandExecute = { + 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 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 EventTuiToastShow = { + type: "tui.toast.show" + properties: { + title?: string + message: string + variant: "info" | "success" | "warning" | "error" + duration?: number } } -export type SyncEventSessionNextToolFailed = { - type: "sync" - name: "session.next.tool.failed.1" - id: string - seq: number - aggregateID: "sessionID" - data: { - timestamp: number +export type EventTuiSessionSelect = { + type: "tui.session.select" + properties: { + /** + * Session ID to navigate to + */ sessionID: string - callID: string - error: SessionErrorUnknown - provider: { - executed: boolean - metadata?: { - [key: string]: unknown - } - } } } -export type SyncEventSessionNextRetried = { - type: "sync" - name: "session.next.retried.1" +export type Workspace = { id: string - seq: number - aggregateID: "sessionID" - data: { - timestamp: number - sessionID: string - attempt: number - error: SessionNextRetryError - } + type: string + name: string + branch?: string | null + directory?: string | null + extra?: unknown | null + projectID: string + timeUsed: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" } -export type SyncEventSessionNextCompactionStarted = { - type: "sync" - name: "session.next.compaction.started.1" - id: string - seq: number - aggregateID: "sessionID" +export type WorkspaceCreateError = { + name: "WorkspaceCreateError" data: { - timestamp: number - sessionID: string - reason: "auto" | "manual" + message: string } } -export type SyncEventSessionNextCompactionDelta = { - type: "sync" - name: "session.next.compaction.delta.1" - id: string - seq: number - aggregateID: "sessionID" +export type WorkspaceWarpError = { + name: "WorkspaceWarpError" data: { - timestamp: number - sessionID: string - text: string + message: 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 EffectHttpApiErrorForbidden = { + _tag: "Forbidden" } -export type EventServerConnected = { +export type EventTuiPromptAppend2 = { id: string - type: "server.connected" + type: "tui.prompt.append" properties: { - [key: string]: unknown + text: string } } -export type EventGlobalDisposed = { +export type EventTuiCommandExecute2 = { id: string - type: "global.disposed" + 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 EventServerInstanceDisposed = { +export type EventTuiToastShow2 = { id: string - type: "server.instance.disposed" + type: "tui.toast.show" properties: { - directory: string + title?: string + message: string + variant: "info" | "success" | "warning" | "error" + duration?: number } } -export type EventFileEdited = { +export type EventTuiSessionSelect2 = { id: string - type: "file.edited" + type: "tui.session.select" properties: { - file: string + /** + * Session ID to navigate to + */ + sessionID: string } } -export type EventFileWatcherUpdated = { +export type ModelV2Info = { id: string - type: "file.watcher.updated" - properties: { - file: string - event: "add" | "change" | "unlink" + apiID: string + providerID: string + family?: string + name: string + endpoint: + | { + type: "unknown" + } + | { + type: "openai/responses" + url: string + websocket?: boolean + } + | { + type: "openai/completions" + url: string + reasoning?: + | { + type: "reasoning_content" + } + | { + type: "reasoning_details" + } + } + | { + type: "anthropic/messages" + url: string + } + | { + type: "aisdk" + package: string + url?: string + } + capabilities: { + tools: boolean + input: Array + output: Array + } + options: { + headers: { + [key: string]: string + } + body: { + [key: string]: unknown + } + aisdk: { + provider: { + [key: string]: unknown + } + request: { + [key: string]: unknown + } + } + variant?: string + } + variants: Array<{ + id: string + headers: { + [key: string]: string + } + body: { + [key: string]: unknown + } + aisdk: { + provider: { + [key: string]: unknown + } + request: { + [key: string]: unknown + } + } + }> + time: { + released: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + } + cost: Array<{ + tier?: { + type: "context" + size: number + } + input: number + output: number + cache: { + read: number + write: number + } + }> + status: "alpha" | "beta" | "deprecated" | "active" + enabled: boolean + limit: { + context: number + input?: number + output: number } } -export type EventLspClientDiagnostics = { - id: string - type: "lsp.client.diagnostics" - properties: { - serverID: string - path: string - } +export type PromptSource = { + start: number + end: number + text: string } -export type EventLspUpdated = { - id: string - type: "lsp.updated" - properties: { - [key: string]: unknown - } +export type PromptFileAttachment = { + uri: string + mime: string + name?: string + description?: string + source?: PromptSource } -export type EventMessagePartDelta = { - id: string - type: "message.part.delta" - properties: { - sessionID: string - messageID: string - partID: string - field: string - delta: string - } +export type PromptAgentAttachment = { + name: string + source?: PromptSource } -export type EventPermissionAsked = { - id: string - type: "permission.asked" - properties: PermissionRequest +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 EventPermissionReplied = { - id: string - type: "permission.replied" - properties: { - sessionID: string - requestID: string - reply: "once" | "always" | "reject" - } +export type ToolTextContent = { + type: "text" + text: string } -export type EventSessionDiff = { - id: string - type: "session.diff" - properties: { - sessionID: string - diff: Array - } +export type ToolFileContent = { + type: "file" + uri: string + mime: string + name?: string } -export type EventSessionError = { - id: string - type: "session.error" - properties: { - sessionID?: string - error?: - | ProviderAuthError - | UnknownError - | MessageOutputLengthError - | MessageAbortedError - | StructuredOutputError - | ContextOverflowError - | ApiError +export type SessionNextRetryError = { + message: string + statusCode?: number + isRetryable: boolean + responseHeaders?: { + [key: string]: string + } + responseBody?: string + metadata?: { + [key: string]: string } } -export type EventQuestionAsked = { - id: string - type: "question.asked" - properties: QuestionRequest -} +export type PermissionV2Action = "allow" | "deny" | "ask" -export type EventQuestionReplied = { - id: string - type: "question.replied" - properties: QuestionReplied +export type PermissionV2Rule = { + permission: string + pattern: string + action: PermissionV2Action } -export type EventQuestionRejected = { - id: string - type: "question.rejected" - properties: QuestionRejected +export type PermissionV2Ruleset = Array + +export type PolicyEffect = "allow" | "deny" + +export type ConfigV2ExperimentalPolicy = { + action: "provider.use" + effect: PolicyEffect + resource: string } -export type EventTodoUpdated = { +export type SessionInfo = { id: string - type: "todo.updated" - properties: { - sessionID: string - todos: Array + parentID?: string + projectID: string + workspaceID?: string + path?: string + agent?: string + model?: { + id: string + providerID: string + variant?: string } + cost: number + tokens: { + input: number + output: number + reasoning: number + cache: { + read: number + write: number + } + } + time: { + created: number + updated: number + archived?: number + } + title: string } -export type EventSessionStatus = { +export type SessionDelivery = "immediate" | "deferred" + +export type SessionMessageAgentSwitched = { id: string - type: "session.status" - properties: { - sessionID: string - status: SessionStatus + metadata?: { + [key: string]: unknown + } + time: { + created: number } + type: "agent-switched" + agent: string } -export type EventSessionIdle = { +export type SessionMessageModelSwitched = { id: string - type: "session.idle" - properties: { - sessionID: string + metadata?: { + [key: string]: unknown + } + time: { + created: number + } + type: "model-switched" + model: { + id: string + providerID: string + variant?: string } } -export type EventMcpToolsChanged = { +export type SessionMessageUser = { id: string - type: "mcp.tools.changed" - properties: { - server: string + metadata?: { + [key: string]: unknown + } + time: { + created: number } + text: string + files?: Array + agents?: Array + references?: Array + type: "user" } -export type EventMcpBrowserOpenFailed = { +export type SessionMessageSynthetic = { id: string - type: "mcp.browser.open.failed" - properties: { - mcpName: string - url: string + metadata?: { + [key: string]: unknown + } + time: { + created: number } + sessionID: string + text: string + type: "synthetic" } -export type EventCommandExecuted = { +export type SessionMessageShell = { id: string - type: "command.executed" - properties: { - name: string - sessionID: string - arguments: string - messageID: string + metadata?: { + [key: string]: unknown + } + time: { + created: number + completed?: number } + type: "shell" + callID: string + command: string + output: string } -export type EventProjectUpdated = { - id: string - type: "project.updated" - properties: Project +export type SessionMessageAssistantText = { + type: "text" + text: string } -export type EventSessionCompacted = { +export type SessionMessageAssistantReasoning = { + type: "reasoning" id: string - type: "session.compacted" - properties: { - sessionID: string - } + text: string } -export type EventVcsBranchUpdated = { - id: string - type: "vcs.branch.updated" - properties: { - branch?: string - } +export type SessionMessageToolStatePending = { + status: "pending" + input: string } -export type EventWorkspaceReady = { - id: string - type: "workspace.ready" - properties: { - name: string +export type SessionMessageToolStateRunning = { + status: "running" + input: { + [key: string]: unknown } -} - -export type EventWorkspaceFailed = { - id: string - type: "workspace.failed" - properties: { - message: string + structured: { + [key: string]: unknown } + content: Array } -export type EventWorkspaceStatus = { - id: string - type: "workspace.status" - properties: { - workspaceID: string - status: "connected" | "connecting" | "disconnected" | "error" +export type SessionMessageToolStateCompleted = { + status: "completed" + input: { + [key: string]: unknown } -} - -export type EventWorktreeReady = { - id: string - type: "worktree.ready" - properties: { - name: string - branch?: string + attachments?: Array + content: Array + structured: { + [key: string]: unknown } } -export type EventWorktreeFailed = { - id: string - type: "worktree.failed" - properties: { - message: string +export type SessionMessageToolStateError = { + status: "error" + input: { + [key: string]: unknown } -} - -export type EventPtyCreated = { - id: string - type: "pty.created" - properties: { - info: Pty + content: Array + structured: { + [key: string]: unknown } + error: SessionErrorUnknown } -export type EventPtyUpdated = { +export type SessionMessageAssistantTool = { + type: "tool" id: string - type: "pty.updated" - properties: { - info: Pty + name: string + provider?: { + executed: boolean + metadata?: { + [key: string]: unknown + } } -} - -export type EventPtyExited = { - id: string - type: "pty.exited" - properties: { - id: string - exitCode: number + state: + | SessionMessageToolStatePending + | SessionMessageToolStateRunning + | SessionMessageToolStateCompleted + | SessionMessageToolStateError + time: { + created: number + ran?: number + completed?: number + pruned?: number } } -export type EventPtyDeleted = { +export type SessionMessageAssistant = { id: string - type: "pty.deleted" - properties: { + metadata?: { + [key: string]: unknown + } + time: { + created: number + completed?: number + } + type: "assistant" + agent: string + model: { id: string + providerID: string + variant?: string } -} - -export type EventInstallationUpdated = { - id: string - type: "installation.updated" - properties: { - version: string + content: Array + snapshot?: { + start?: string + end?: string + } + finish?: string + cost?: number + tokens?: { + input: number + output: number + reasoning: number + cache: { + read: number + write: number + } } + error?: SessionErrorUnknown } -export type EventInstallationUpdateAvailable = { +export type SessionMessageCompaction = { + type: "compaction" + reason: "auto" | "manual" + summary: string + include?: string id: string - type: "installation.update-available" - properties: { - version: string + metadata?: { + [key: string]: unknown + } + time: { + created: number } } -export type EventMessageUpdated = { +export type SessionMessage = + | SessionMessageAgentSwitched + | SessionMessageModelSwitched + | SessionMessageUser + | SessionMessageSynthetic + | SessionMessageShell + | SessionMessageAssistant + | SessionMessageCompaction + +export type ProviderV2Info = { id: string - type: "message.updated" - properties: { - sessionID: string - info: Message + name: string + enabled: + | false + | { + via: "env" + name: string + } + | { + via: "account" + service: string + } + | { + via: "custom" + data: { + [key: string]: unknown + } + } + env: Array + endpoint: + | { + type: "unknown" + } + | { + type: "openai/responses" + url: string + websocket?: boolean + } + | { + type: "openai/completions" + url: string + reasoning?: + | { + type: "reasoning_content" + } + | { + type: "reasoning_details" + } + } + | { + type: "anthropic/messages" + url: string + } + | { + type: "aisdk" + package: string + url?: string + } + options: { + headers: { + [key: string]: string + } + body: { + [key: string]: unknown + } + aisdk: { + provider: { + [key: string]: unknown + } + request: { + [key: string]: unknown + } + } } } -export type EventMessageRemoved = { +export type EventModelsDevRefreshed = { id: string - type: "message.removed" + type: "models-dev.refreshed" properties: { - sessionID: string - messageID: string + [key: string]: unknown } } -export type EventMessagePartUpdated = { +export type EventPluginAdded = { id: string - type: "message.part.updated" + type: "plugin.added" properties: { - sessionID: string - part: Part - time: number + id: string } } -export type EventMessagePartRemoved = { +export type ModelV2Info1 = { id: string - type: "message.part.removed" - properties: { - sessionID: string - messageID: string - partID: string + apiID: string + providerID: string + family?: string + name: string + endpoint: + | { + type: "unknown" + } + | { + type: "openai/responses" + url: string + websocket?: boolean + } + | { + type: "openai/completions" + url: string + reasoning?: + | { + type: "reasoning_content" + } + | { + type: "reasoning_details" + } + } + | { + type: "anthropic/messages" + url: string + } + | { + type: "aisdk" + package: string + url?: string + } + capabilities: { + tools: boolean + input: Array + output: Array + } + options: { + headers: { + [key: string]: string + } + body: { + [key: string]: unknown + } + aisdk: { + provider: { + [key: string]: unknown + } + request: { + [key: string]: unknown + } + } + variant?: string + } + variants: Array<{ + id: string + headers: { + [key: string]: string + } + body: { + [key: string]: unknown + } + aisdk: { + provider: { + [key: string]: unknown + } + request: { + [key: string]: unknown + } + } + }> + time: { + released: number | "NaN" | "Infinity" | "-Infinity" } -} - -export type EventSessionCreated = { - id: string - type: "session.created" - properties: { - sessionID: string - info: Session + cost: Array<{ + tier?: { + type: "context" + size: number + } + input: number + output: number + cache: { + read: number + write: number + } + }> + status: "alpha" | "beta" | "deprecated" | "active" + enabled: boolean + limit: { + context: number + input?: number + output: number } } -export type EventSessionUpdated = { +export type EventCatalogModelUpdated = { id: string - type: "session.updated" + type: "catalog.model.updated" properties: { - sessionID: string - info: Session + model: ModelV2Info1 } } -export type EventSessionDeleted = { +export type EventFileEdited = { id: string - type: "session.deleted" + type: "file.edited" properties: { - sessionID: string - info: Session + file: string } } @@ -2907,37 +3693,6 @@ export type EventSessionNextModelSwitched = { } } -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" @@ -3017,11 +3772,6 @@ export type EventSessionNextStepEnded = { } } -export type SessionErrorUnknown = { - type: "unknown" - message: string -} - export type EventSessionNextStepFailed = { id: string type: "session.next.step.failed" @@ -3106,752 +3856,572 @@ export type EventSessionNextToolInputStarted = { export type EventSessionNextToolInputDelta = { id: string - type: "session.next.tool.input.delta" + 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: { - timestamp: number sessionID: string - callID: string - delta: string + messageID: string } } -export type EventSessionNextToolInputEnded = { +export type EventMessagePartUpdated = { id: string - type: "session.next.tool.input.ended" + type: "message.part.updated" properties: { - timestamp: number sessionID: string - callID: string - text: string + part: Part + time: number } } -export type EventSessionNextToolCalled = { +export type EventMessagePartRemoved = { id: string - type: "session.next.tool.called" + type: "message.part.removed" properties: { - timestamp: number sessionID: string - callID: string - tool: string - input: { - [key: string]: unknown - } - provider: { - executed: boolean - metadata?: { - [key: string]: unknown - } - } + messageID: string + partID: string } } -export type ToolTextContent = { - type: "text" - text: string -} - -export type ToolFileContent = { - type: "file" - uri: string - mime: string - name?: string -} - -export type EventSessionNextToolProgress = { +export type EventMessagePartDelta = { id: string - type: "session.next.tool.progress" + type: "message.part.delta" properties: { - timestamp: number sessionID: string - callID: string - structured: { - [key: string]: unknown - } - content: Array + messageID: string + partID: string + field: string + delta: string } } -export type EventSessionNextToolSuccess = { +export type EventPermissionAsked = { id: string - type: "session.next.tool.success" + type: "permission.asked" properties: { - timestamp: number + id: string sessionID: string - callID: string - structured: { + permission: string + patterns: Array + metadata: { [key: string]: unknown } - content: Array - provider: { - executed: boolean - metadata?: { - [key: string]: unknown - } + always: Array + tool?: { + messageID: string + callID: string } } } -export type EventSessionNextToolFailed = { +export type EventPermissionReplied = { id: string - type: "session.next.tool.failed" + type: "permission.replied" 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 + requestID: string + reply: "once" | "always" | "reject" } } -export type EventSessionNextRetried = { +export type EventSessionDiff = { id: string - type: "session.next.retried" + type: "session.diff" properties: { - timestamp: number sessionID: string - attempt: number - error: SessionNextRetryError + diff: Array } } -export type EventSessionNextCompactionStarted = { +export type EventSessionError = { id: string - type: "session.next.compaction.started" + type: "session.error" properties: { - timestamp: number - sessionID: string - reason: "auto" | "manual" + sessionID?: string + error?: + | ProviderAuthError + | UnknownError + | MessageOutputLengthError + | MessageAbortedError + | StructuredOutputError + | ContextOverflowError + | ApiError } } -export type EventSessionNextCompactionDelta = { +export type EventQuestionAsked = { id: string - type: "session.next.compaction.delta" + type: "question.asked" properties: { - timestamp: number + id: string sessionID: string - text: string + /** + * Questions to ask + */ + questions: Array + tool?: QuestionTool } } -export type EventSessionNextCompactionEnded = { +export type EventQuestionReplied = { id: string - type: "session.next.compaction.ended" + type: "question.replied" properties: { - timestamp: number sessionID: string - text: string - include?: string + requestID: string + answers: Array } } -export type EventPluginAdded = { +export type EventQuestionRejected = { id: string - type: "plugin.added" + type: "question.rejected" properties: { - id: string + sessionID: string + requestID: string } } -export type ModelV2Info = { +export type EventTodoUpdated = { id: string - apiID: string - providerID: string - family?: string - name: string - endpoint: - | { - type: "unknown" - } - | { - type: "openai/responses" - url: string - websocket?: boolean - } - | { - type: "openai/completions" - url: string - reasoning?: - | { - type: "reasoning_content" - } - | { - type: "reasoning_details" - } - } - | { - type: "anthropic/messages" - url: string - } - | { - type: "aisdk" - package: string - url?: string - } - capabilities: { - tools: boolean - input: Array - output: Array - } - options: { - headers: { - [key: string]: string - } - body: { - [key: string]: unknown - } - aisdk: { - provider: { - [key: string]: unknown - } - request: { - [key: string]: unknown - } - } - variant?: string - } - variants: Array<{ - id: string - headers: { - [key: string]: string - } - body: { - [key: string]: unknown - } - aisdk: { - provider: { - [key: string]: unknown - } - request: { - [key: string]: unknown - } - } - }> - time: { - released: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - } - cost: Array<{ - tier?: { - type: "context" - size: number - } - input: number - output: number - cache: { - read: number - write: number - } - }> - status: "alpha" | "beta" | "deprecated" | "active" - enabled: boolean - limit: { - context: number - input?: number - output: number + type: "todo.updated" + properties: { + sessionID: string + todos: Array } } -export type EventCatalogModelUpdated = { +export type EventSessionStatus = { id: string - type: "catalog.model.updated" + type: "session.status" properties: { - model: ModelV2Info + sessionID: string + status: SessionStatus } } -export type EventModelsDevRefreshed = { +export type EventSessionIdle = { id: string - type: "models-dev.refreshed" + type: "session.idle" properties: { - [key: string]: unknown + sessionID: string } } -export type AccountV2oAuthCredential = { - type: "oauth" - refresh: string - access: string - expires: number -} - -export type AccountV2ApiKeyCredential = { - type: "api" - key: string - metadata?: { - [key: string]: string +export type EventSessionCompacted = { + id: string + type: "session.compacted" + properties: { + sessionID: string } } -export type AccountV2Credential = AccountV2oAuthCredential | AccountV2ApiKeyCredential - -export type AccountV2Info = { +export type EventLspUpdated = { id: string - serviceID: string - description: string - credential: AccountV2Credential + type: "lsp.updated" + properties: { + [key: string]: unknown + } } -export type EventAccountAdded = { +export type EventMcpToolsChanged = { id: string - type: "account.added" + type: "mcp.tools.changed" properties: { - account: AccountV2Info + server: string } } -export type EventAccountRemoved = { +export type EventMcpBrowserOpenFailed = { id: string - type: "account.removed" + type: "mcp.browser.open.failed" properties: { - account: AccountV2Info + mcpName: string + url: string } } -export type EventAccountSwitched = { +export type EventCommandExecuted = { id: string - type: "account.switched" + type: "command.executed" properties: { - serviceID: string - from?: string - to?: string + name: string + sessionID: string + arguments: string + messageID: string } } -export type PolicyEffect = "allow" | "deny" - -export type ConfigV2ExperimentalPolicy = { - action: "provider.use" - effect: PolicyEffect - resource: string -} - -export type SessionInfo = { +export type EventProjectUpdated = { id: string - parentID?: string - projectID: string - workspaceID?: string - path?: string - agent?: string - model?: { + type: "project.updated" + properties: { id: string - providerID: string - variant?: string - } - cost: number - tokens: { - input: number - output: number - reasoning: number - cache: { - read: number - write: number + 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 } - time: { - created: number - updated: number - archived?: number - } - title: string } -export type SessionDelivery = "immediate" | "deferred" - -export type SessionMessageAgentSwitched = { +export type EventVcsBranchUpdated = { id: string - metadata?: { - [key: string]: unknown - } - time: { - created: number + type: "vcs.branch.updated" + properties: { + branch?: string } - type: "agent-switched" - agent: string } -export type SessionMessageModelSwitched = { +export type EventWorkspaceReady = { id: string - metadata?: { - [key: string]: unknown - } - time: { - created: number - } - type: "model-switched" - model: { - id: string - providerID: string - variant?: string + type: "workspace.ready" + properties: { + name: string } } -export type SessionMessageUser = { +export type EventWorkspaceFailed = { id: string - metadata?: { - [key: string]: unknown - } - time: { - created: number + type: "workspace.failed" + properties: { + message: string } - text: string - files?: Array - agents?: Array - references?: Array - type: "user" } -export type SessionMessageSynthetic = { +export type EventWorkspaceStatus = { id: string - metadata?: { - [key: string]: unknown - } - time: { - created: number + type: "workspace.status" + properties: { + workspaceID: string + status: "connected" | "connecting" | "disconnected" | "error" } - sessionID: string - text: string - type: "synthetic" } -export type SessionMessageShell = { +export type EventWorktreeReady = { id: string - metadata?: { - [key: string]: unknown - } - time: { - created: number - completed?: number + type: "worktree.ready" + properties: { + name: string + branch?: string } - type: "shell" - callID: string - command: string - output: string -} - -export type SessionMessageAssistantText = { - type: "text" - text: string } -export type SessionMessageAssistantReasoning = { - type: "reasoning" +export type EventWorktreeFailed = { id: string - text: string -} - -export type SessionMessageToolStatePending = { - status: "pending" - input: string + type: "worktree.failed" + properties: { + message: string + } } -export type SessionMessageToolStateRunning = { - status: "running" - input: { - [key: string]: unknown - } - structured: { - [key: string]: unknown +export type EventPtyCreated = { + id: string + type: "pty.created" + properties: { + info: Pty } - content: Array } -export type SessionMessageToolStateCompleted = { - status: "completed" - input: { - [key: string]: unknown - } - attachments?: Array - content: Array - structured: { - [key: string]: unknown +export type EventPtyUpdated = { + id: string + type: "pty.updated" + properties: { + info: Pty } } -export type SessionMessageToolStateError = { - status: "error" - input: { - [key: string]: unknown - } - content: Array - structured: { - [key: string]: unknown +export type EventPtyExited = { + id: string + type: "pty.exited" + properties: { + id: string + exitCode: number } - error: SessionErrorUnknown } -export type SessionMessageAssistantTool = { - type: "tool" +export type EventPtyDeleted = { id: string - name: string - provider?: { - executed: boolean - metadata?: { - [key: string]: unknown - } - } - state: - | SessionMessageToolStatePending - | SessionMessageToolStateRunning - | SessionMessageToolStateCompleted - | SessionMessageToolStateError - time: { - created: number - ran?: number - completed?: number - pruned?: number + type: "pty.deleted" + properties: { + id: string } } -export type SessionMessageAssistant = { +export type EventInstallationUpdated = { id: string - metadata?: { - [key: string]: unknown - } - time: { - created: number - completed?: number - } - type: "assistant" - agent: string - model: { - id: string - providerID: string - variant?: string + type: "installation.updated" + properties: { + version: string } - content: Array - snapshot?: { - start?: string - end?: string +} + +export type EventInstallationUpdateAvailable = { + id: string + type: "installation.update-available" + properties: { + version: string } - finish?: string - cost?: number - tokens?: { - input: number - output: number - reasoning: number - cache: { - read: number - write: number - } +} + +export type EventServerConnected = { + id: string + type: "server.connected" + properties: { + [key: string]: unknown } - error?: SessionErrorUnknown } -export type SessionMessageCompaction = { - type: "compaction" - reason: "auto" | "manual" - summary: string - include?: string +export type EventGlobalDisposed = { id: string - metadata?: { + type: "global.disposed" + properties: { [key: string]: unknown } - time: { - created: number +} + +export type AuthOAuthCredential = { + type: "oauth" + refresh: string + access: string + expires: number +} + +export type AuthApiKeyCredential = { + type: "api" + key: string + metadata?: { + [key: string]: string } } -export type SessionMessage = - | SessionMessageAgentSwitched - | SessionMessageModelSwitched - | SessionMessageUser - | SessionMessageSynthetic - | SessionMessageShell - | SessionMessageAssistant - | SessionMessageCompaction +export type AuthCredential = AuthOAuthCredential | AuthApiKeyCredential -export type ProviderV2Info = { +export type AuthInfo = { id: string - name: string - enabled: - | false - | { - via: "env" - name: string - } - | { - via: "account" - service: string - } - | { - via: "custom" - data: { - [key: string]: unknown - } - } - env: Array - endpoint: - | { - type: "unknown" - } - | { - type: "openai/responses" - url: string - websocket?: boolean - } - | { - type: "openai/completions" - url: string - reasoning?: - | { - type: "reasoning_content" - } - | { - type: "reasoning_details" - } - } - | { - type: "anthropic/messages" - url: string - } - | { - type: "aisdk" - package: string - url?: string - } - options: { - headers: { - [key: string]: string - } - body: { - [key: string]: unknown - } - aisdk: { - provider: { - [key: string]: unknown - } - request: { - [key: string]: unknown - } - } - } + serviceID: string + description: string + credential: AuthCredential } -export type EventTuiToastShow1 = { +export type EventAccountAdded = { id: string - type: "tui.toast.show" + type: "account.added" properties: { - title?: string - message: string - variant: "info" | "success" | "warning" | "error" - duration?: number + account: AuthInfo } } -export type ModelV2Info1 = { +export type EventAccountRemoved = { id: string - apiID: string - providerID: string - family?: string - name: string - endpoint: - | { - type: "unknown" - } - | { - type: "openai/responses" - url: string - websocket?: boolean - } - | { - type: "openai/completions" - url: string - reasoning?: - | { - type: "reasoning_content" - } - | { - type: "reasoning_details" - } - } - | { - type: "anthropic/messages" - url: string - } - | { - type: "aisdk" - package: string - url?: string - } - capabilities: { - tools: boolean - input: Array - output: Array - } - options: { - headers: { - [key: string]: string - } - body: { - [key: string]: unknown - } - aisdk: { - provider: { - [key: string]: unknown - } - request: { - [key: string]: unknown - } - } - variant?: string - } - variants: Array<{ - id: string - headers: { - [key: string]: string - } - body: { - [key: string]: unknown - } - aisdk: { - provider: { - [key: string]: unknown - } - request: { - [key: string]: unknown - } - } - }> - time: { - released: number | "NaN" | "Infinity" | "-Infinity" + type: "account.removed" + properties: { + account: AuthInfo } - cost: Array<{ - tier?: { - type: "context" - size: number - } - input: number - output: number - cache: { - read: number - write: number - } - }> - status: "alpha" | "beta" | "deprecated" | "active" - enabled: boolean - limit: { - context: number - input?: number - output: number +} + +export type EventAccountSwitched = { + id: string + type: "account.switched" + properties: { + serviceID: string + from?: string + to?: string } } @@ -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 ad4eb350e..06b923bd7 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,121 +10489,106 @@ "Event": { "anyOf": [ { - "$ref": "#/components/schemas/Event.tui.prompt.append" - }, - { - "$ref": "#/components/schemas/Event.tui.command.execute" - }, - { - "$ref": "#/components/schemas/EventTuiToastShow1" - }, - { - "$ref": "#/components/schemas/Event.tui.session.select" - }, - { - "$ref": "#/components/schemas/EventServerConnected" + "$ref": "#/components/schemas/EventModels-devRefreshed" }, { - "$ref": "#/components/schemas/EventGlobalDisposed" + "$ref": "#/components/schemas/EventPluginAdded" }, { - "$ref": "#/components/schemas/EventServerInstanceDisposed" + "$ref": "#/components/schemas/EventCatalogModelUpdated" }, { "$ref": "#/components/schemas/EventFileEdited" }, { - "$ref": "#/components/schemas/EventFileWatcherUpdated" - }, - { - "$ref": "#/components/schemas/EventLspClientDiagnostics" + "$ref": "#/components/schemas/EventSessionNextAgentSwitched" }, { - "$ref": "#/components/schemas/EventLspUpdated" + "$ref": "#/components/schemas/EventSessionNextModelSwitched" }, { - "$ref": "#/components/schemas/EventMessagePartDelta" + "$ref": "#/components/schemas/EventSessionNextPrompted" }, { - "$ref": "#/components/schemas/EventPermissionAsked" + "$ref": "#/components/schemas/EventSessionNextSynthetic" }, { - "$ref": "#/components/schemas/EventPermissionReplied" + "$ref": "#/components/schemas/EventSessionNextShellStarted" }, { - "$ref": "#/components/schemas/EventSessionDiff" + "$ref": "#/components/schemas/EventSessionNextShellEnded" }, { - "$ref": "#/components/schemas/EventSessionError" + "$ref": "#/components/schemas/EventSessionNextStepStarted" }, { - "$ref": "#/components/schemas/EventQuestionAsked" + "$ref": "#/components/schemas/EventSessionNextStepEnded" }, { - "$ref": "#/components/schemas/EventQuestionReplied" + "$ref": "#/components/schemas/EventSessionNextStepFailed" }, { - "$ref": "#/components/schemas/EventQuestionRejected" + "$ref": "#/components/schemas/EventSessionNextTextStarted" }, { - "$ref": "#/components/schemas/EventTodoUpdated" + "$ref": "#/components/schemas/EventSessionNextTextDelta" }, { - "$ref": "#/components/schemas/EventSessionStatus" + "$ref": "#/components/schemas/EventSessionNextTextEnded" }, { - "$ref": "#/components/schemas/EventSessionIdle" + "$ref": "#/components/schemas/EventSessionNextReasoningStarted" }, { - "$ref": "#/components/schemas/EventMcpToolsChanged" + "$ref": "#/components/schemas/EventSessionNextReasoningDelta" }, { - "$ref": "#/components/schemas/EventMcpBrowserOpenFailed" + "$ref": "#/components/schemas/EventSessionNextReasoningEnded" }, { - "$ref": "#/components/schemas/EventCommandExecuted" + "$ref": "#/components/schemas/EventSessionNextToolInputStarted" }, { - "$ref": "#/components/schemas/EventProjectUpdated" + "$ref": "#/components/schemas/EventSessionNextToolInputDelta" }, { - "$ref": "#/components/schemas/EventSessionCompacted" + "$ref": "#/components/schemas/EventSessionNextToolInputEnded" }, { - "$ref": "#/components/schemas/EventVcsBranchUpdated" + "$ref": "#/components/schemas/EventSessionNextToolCalled" }, { - "$ref": "#/components/schemas/EventWorkspaceReady" + "$ref": "#/components/schemas/EventSessionNextToolProgress" }, { - "$ref": "#/components/schemas/EventWorkspaceFailed" + "$ref": "#/components/schemas/EventSessionNextToolSuccess" }, { - "$ref": "#/components/schemas/EventWorkspaceStatus" + "$ref": "#/components/schemas/EventSessionNextToolFailed" }, { - "$ref": "#/components/schemas/EventWorktreeReady" + "$ref": "#/components/schemas/EventSessionNextRetried" }, { - "$ref": "#/components/schemas/EventWorktreeFailed" + "$ref": "#/components/schemas/EventSessionNextCompactionStarted" }, { - "$ref": "#/components/schemas/EventPtyCreated" + "$ref": "#/components/schemas/EventSessionNextCompactionDelta" }, { - "$ref": "#/components/schemas/EventPtyUpdated" + "$ref": "#/components/schemas/EventSessionNextCompactionEnded" }, { - "$ref": "#/components/schemas/EventPtyExited" + "$ref": "#/components/schemas/EventFileWatcherUpdated" }, { - "$ref": "#/components/schemas/EventPtyDeleted" + "$ref": "#/components/schemas/EventSessionCreated" }, { - "$ref": "#/components/schemas/EventInstallationUpdated" + "$ref": "#/components/schemas/EventSessionUpdated" }, { - "$ref": "#/components/schemas/EventInstallationUpdate-available" + "$ref": "#/components/schemas/EventSessionDeleted" }, { "$ref": "#/components/schemas/EventMessageUpdated" @@ -10618,178 +10603,109 @@ "$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/EventMessagePartDelta" }, { - "$ref": "#/components/schemas/EventSessionNextToolFailed" + "$ref": "#/components/schemas/EventPermissionAsked" }, { - "$ref": "#/components/schemas/EventSessionNextRetried" + "$ref": "#/components/schemas/EventPermissionReplied" }, { - "$ref": "#/components/schemas/EventSessionNextCompactionStarted" + "$ref": "#/components/schemas/EventSessionDiff" }, { - "$ref": "#/components/schemas/EventSessionNextCompactionDelta" + "$ref": "#/components/schemas/EventSessionError" }, { - "$ref": "#/components/schemas/EventSessionNextCompactionEnded" + "$ref": "#/components/schemas/EventQuestionAsked" }, { - "$ref": "#/components/schemas/EventPluginAdded" + "$ref": "#/components/schemas/EventQuestionReplied" }, { - "$ref": "#/components/schemas/EventCatalogModelUpdated" + "$ref": "#/components/schemas/EventQuestionRejected" }, { - "$ref": "#/components/schemas/EventSessionNextAgentSwitched" + "$ref": "#/components/schemas/EventTodoUpdated" }, { - "$ref": "#/components/schemas/EventSessionNextModelSwitched" + "$ref": "#/components/schemas/EventSessionStatus" }, { - "$ref": "#/components/schemas/EventSessionNextPrompted" + "$ref": "#/components/schemas/EventSessionIdle" }, { - "$ref": "#/components/schemas/EventSessionNextSynthetic" + "$ref": "#/components/schemas/EventSessionCompacted" }, { - "$ref": "#/components/schemas/EventSessionNextShellStarted" + "$ref": "#/components/schemas/EventLspUpdated" }, { - "$ref": "#/components/schemas/EventSessionNextShellEnded" + "$ref": "#/components/schemas/Event.tui.prompt.append" }, { - "$ref": "#/components/schemas/EventSessionNextStepStarted" + "$ref": "#/components/schemas/Event.tui.command.execute" }, { - "$ref": "#/components/schemas/EventSessionNextStepEnded" + "$ref": "#/components/schemas/Event.tui.toast.show" }, { - "$ref": "#/components/schemas/EventSessionNextStepFailed" + "$ref": "#/components/schemas/Event.tui.session.select" }, { - "$ref": "#/components/schemas/EventSessionNextTextStarted" + "$ref": "#/components/schemas/EventMcpToolsChanged" }, { - "$ref": "#/components/schemas/EventSessionNextTextDelta" + "$ref": "#/components/schemas/EventMcpBrowserOpenFailed" }, { - "$ref": "#/components/schemas/EventSessionNextTextEnded" + "$ref": "#/components/schemas/EventCommandExecuted" }, { - "$ref": "#/components/schemas/EventSessionNextReasoningStarted" + "$ref": "#/components/schemas/EventProjectUpdated" }, { - "$ref": "#/components/schemas/EventSessionNextReasoningDelta" + "$ref": "#/components/schemas/EventVcsBranchUpdated" }, { - "$ref": "#/components/schemas/EventSessionNextReasoningEnded" + "$ref": "#/components/schemas/EventWorkspaceReady" }, { - "$ref": "#/components/schemas/EventSessionNextToolInputStarted" + "$ref": "#/components/schemas/EventWorkspaceFailed" }, { - "$ref": "#/components/schemas/EventSessionNextToolInputDelta" + "$ref": "#/components/schemas/EventWorkspaceStatus" }, { - "$ref": "#/components/schemas/EventSessionNextToolInputEnded" + "$ref": "#/components/schemas/EventWorktreeReady" }, { - "$ref": "#/components/schemas/EventSessionNextToolCalled" + "$ref": "#/components/schemas/EventWorktreeFailed" }, { - "$ref": "#/components/schemas/EventSessionNextToolProgress" + "$ref": "#/components/schemas/EventPtyCreated" }, { - "$ref": "#/components/schemas/EventSessionNextToolSuccess" + "$ref": "#/components/schemas/EventPtyUpdated" }, { - "$ref": "#/components/schemas/EventSessionNextToolFailed" + "$ref": "#/components/schemas/EventPtyExited" }, { - "$ref": "#/components/schemas/EventSessionNextRetried" + "$ref": "#/components/schemas/EventPtyDeleted" }, { - "$ref": "#/components/schemas/EventSessionNextCompactionStarted" + "$ref": "#/components/schemas/EventInstallationUpdated" }, { - "$ref": "#/components/schemas/EventSessionNextCompactionDelta" + "$ref": "#/components/schemas/EventInstallationUpdate-available" }, { - "$ref": "#/components/schemas/EventSessionNextCompactionEnded" + "$ref": "#/components/schemas/EventServerConnected" }, { - "$ref": "#/components/schemas/EventModels-devRefreshed" + "$ref": "#/components/schemas/EventGlobalDisposed" }, { "$ref": "#/components/schemas/EventAccountAdded" @@ -10910,212 +10826,350 @@ "required": ["_tag", "message"], "additionalProperties": false }, - "Event.tui.prompt.append": { + "Prompt": { "type": "object", "properties": { - "id": { + "text": { "type": "string" }, - "type": { - "type": "string", - "enum": ["tui.prompt.append"] + "files": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PromptFileAttachment" + } }, - "properties": { - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"], - "additionalProperties": false + "agents": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PromptAgentAttachment" + } + }, + "references": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PromptReferenceAttachment" + } } }, - "required": ["id", "type", "properties"], + "required": ["text"], "additionalProperties": false }, - "Event.tui.command.execute": { + "SnapshotFileDiff": { "type": "object", "properties": { - "id": { + "file": { "type": "string" }, - "type": { - "type": "string", - "enum": ["tui.command.execute"] + "patch": { + "type": "string" }, - "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 + "additions": { + "type": "number" + }, + "deletions": { + "type": "number" + }, + "status": { + "type": "string", + "enum": ["added", "deleted", "modified"] } }, - "required": ["id", "type", "properties"], + "required": ["additions", "deletions"], "additionalProperties": false }, - "Event.tui.toast.show": { + "Session": { "type": "object", "properties": { "id": { + "type": "string", + "pattern": "^ses" + }, + "slug": { "type": "string" }, - "type": { + "projectID": { + "type": "string" + }, + "workspaceID": { "type": "string", - "enum": ["tui.toast.show"] + "pattern": "^wrk" }, - "properties": { + "directory": { + "type": "string" + }, + "path": { + "type": "string" + }, + "parentID": { + "type": "string", + "pattern": "^ses" + }, + "summary": { "type": "object", "properties": { - "title": { - "type": "string" + "additions": { + "type": "number" }, - "message": { - "type": "string" + "deletions": { + "type": "number" }, - "variant": { - "type": "string", - "enum": ["info", "success", "warning", "error"] + "files": { + "type": "number" }, - "duration": { - "type": "integer", - "exclusiveMinimum": 0 + "diffs": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SnapshotFileDiff" + } } }, - "required": ["message", "variant"], + "required": ["additions", "deletions", "files"], "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"] + "cost": { + "type": "number" }, - "properties": { + "tokens": { "type": "object", "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses", - "description": "Session ID to navigate to" + "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": ["sessionID"], + "required": ["input", "output", "reasoning", "cache"], "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "PermissionRequest": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^per" }, - "sessionID": { - "type": "string", - "pattern": "^ses" + "share": { + "type": "object", + "properties": { + "url": { + "type": "string" + } + }, + "required": ["url"], + "additionalProperties": false }, - "permission": { + "title": { "type": "string" }, - "patterns": { - "type": "array", - "items": { - "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" }, - "always": { - "type": "array", - "items": { - "type": "string" - } + "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 }, - "tool": { + "permission": { + "$ref": "#/components/schemas/PermissionV2Ruleset" + }, + "revert": { "type": "object", "properties": { "messageID": { "type": "string", "pattern": "^msg" }, - "callID": { + "partID": { + "type": "string", + "pattern": "^prt" + }, + "snapshot": { + "type": "string" + }, + "diff": { "type": "string" } }, - "required": ["messageID", "callID"], + "required": ["messageID"], "additionalProperties": false } }, - "required": ["id", "sessionID", "permission", "patterns", "metadata", "always"], + "required": ["id", "slug", "projectID", "directory", "title", "version", "time"], "additionalProperties": false }, - "SnapshotFileDiff": { + "OutputFormatText": { "type": "object", "properties": { - "file": { - "type": "string" - }, - "patch": { - "type": "string" - }, - "additions": { - "type": "number" - }, - "deletions": { - "type": "number" - }, - "status": { + "type": { "type": "string", - "enum": ["added", "deleted", "modified"] + "enum": ["text"] } }, - "required": ["additions", "deletions"], + "required": ["type"], "additionalProperties": false }, - "ProviderAuthError": { - "type": "object", + "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": { "name": { "type": "string", @@ -11290,605 +11344,617 @@ "required": ["name", "data"], "additionalProperties": false }, - "QuestionOption": { + "AssistantMessage": { "type": "object", "properties": { - "label": { + "id": { "type": "string", - "description": "Display text (1-5 words, concise)" + "pattern": "^msg" }, - "description": { - "type": "string", - "description": "Explanation of choice" - } - }, - "required": ["label", "description"], - "additionalProperties": false - }, - "QuestionInfo": { - "type": "object", - "properties": { - "question": { + "sessionID": { "type": "string", - "description": "Complete question" + "pattern": "^ses" }, - "header": { + "role": { "type": "string", - "description": "Very short label (max 30 chars)" + "enum": ["assistant"] }, - "options": { - "type": "array", - "items": { - "$ref": "#/components/schemas/QuestionOption" + "time": { + "type": "object", + "properties": { + "created": { + "type": "integer", + "minimum": 0 + }, + "completed": { + "type": "integer", + "minimum": 0 + } }, - "description": "Available choices" + "required": ["created"], + "additionalProperties": false }, - "multiple": { - "type": "boolean" + "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" + } + ] }, - "custom": { - "type": "boolean" - } - }, - "required": ["question", "header", "options"], - "additionalProperties": false - }, - "QuestionTool": { - "type": "object", - "properties": { - "messageID": { + "parentID": { "type": "string", "pattern": "^msg" }, - "callID": { + "modelID": { "type": "string" - } - }, - "required": ["messageID", "callID"], - "additionalProperties": false - }, - "QuestionRequest": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^que" }, - "sessionID": { - "type": "string", - "pattern": "^ses" + "providerID": { + "type": "string" }, - "questions": { - "type": "array", - "items": { - "$ref": "#/components/schemas/QuestionInfo" - }, - "description": "Questions to ask" + "mode": { + "type": "string" }, - "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" + "agent": { + "type": "string" }, - "requestID": { - "type": "string", - "pattern": "^que" + "path": { + "type": "object", + "properties": { + "cwd": { + "type": "string" + }, + "root": { + "type": "string" + } + }, + "required": ["cwd", "root"], + "additionalProperties": false }, - "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" + "summary": { + "type": "boolean" }, - "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 + "cost": { + "type": "number" }, - { + "tokens": { "type": "object", "properties": { - "type": { - "type": "string", - "enum": ["retry"] + "total": { + "type": "number" }, - "attempt": { - "type": "integer", - "minimum": 0 + "input": { + "type": "number" }, - "message": { - "type": "string" + "output": { + "type": "number" }, - "action": { + "reasoning": { + "type": "number" + }, + "cache": { "type": "object", "properties": { - "reason": { - "type": "string" - }, - "provider": { - "type": "string" - }, - "title": { - "type": "string" - }, - "message": { - "type": "string" - }, - "label": { - "type": "string" + "read": { + "type": "number" }, - "link": { - "type": "string" + "write": { + "type": "number" } }, - "required": ["reason", "provider", "title", "message", "label"], + "required": ["read", "write"], "additionalProperties": false - }, - "next": { - "type": "integer", - "minimum": 0 } }, - "required": ["type", "attempt", "message", "next"], + "required": ["input", "output", "reasoning", "cache"], "additionalProperties": false }, + "structured": {}, + "variant": { + "type": "string" + }, + "finish": { + "type": "string" + } + }, + "required": [ + "id", + "sessionID", + "role", + "time", + "parentID", + "modelID", + "providerID", + "mode", + "agent", + "path", + "cost", + "tokens" + ], + "additionalProperties": false + }, + "Message": { + "anyOf": [ + { + "$ref": "#/components/schemas/UserMessage" + }, { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["busy"] - } - }, - "required": ["type"], - "additionalProperties": false + "$ref": "#/components/schemas/AssistantMessage" } ] }, - "Project": { + "TextPart": { "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^prt" }, - "worktree": { - "type": "string" + "sessionID": { + "type": "string", + "pattern": "^ses" }, - "vcs": { + "messageID": { "type": "string", - "enum": ["git"] + "pattern": "^msg" }, - "name": { + "type": { + "type": "string", + "enum": ["text"] + }, + "text": { "type": "string" }, - "icon": { - "type": "object", - "properties": { - "url": { - "type": "string" - }, - "override": { - "type": "string" - }, - "color": { - "type": "string" - } - }, - "additionalProperties": false + "synthetic": { + "type": "boolean" }, - "commands": { - "type": "object", - "properties": { - "start": { - "type": "string", - "description": "Startup script to run when creating a new workspace (worktree)" - } - }, - "additionalProperties": false + "ignored": { + "type": "boolean" }, "time": { "type": "object", "properties": { - "created": { - "type": "integer", - "minimum": 0 - }, - "updated": { + "start": { "type": "integer", "minimum": 0 }, - "initialized": { + "end": { "type": "integer", "minimum": 0 } }, - "required": ["created", "updated"], + "required": ["start"], "additionalProperties": false }, - "sandboxes": { - "type": "array", - "items": { - "type": "string" - } + "metadata": { + "type": "object" } }, - "required": ["id", "worktree", "time", "sandboxes"], + "required": ["id", "sessionID", "messageID", "type", "text"], "additionalProperties": false }, - "Pty": { + "SubtaskPart": { "type": "object", "properties": { "id": { "type": "string", - "pattern": "^pty" + "pattern": "^prt" }, - "title": { - "type": "string" + "sessionID": { + "type": "string", + "pattern": "^ses" }, - "command": { + "messageID": { + "type": "string", + "pattern": "^msg" + }, + "type": { + "type": "string", + "enum": ["subtask"] + }, + "prompt": { "type": "string" }, - "args": { - "type": "array", - "items": { - "type": "string" - } + "description": { + "type": "string" }, - "cwd": { + "agent": { "type": "string" }, - "status": { - "type": "string", - "enum": ["running", "exited"] + "model": { + "type": "object", + "properties": { + "providerID": { + "type": "string" + }, + "modelID": { + "type": "string" + } + }, + "required": ["providerID", "modelID"], + "additionalProperties": false }, - "pid": { - "type": "integer", - "minimum": 0 + "command": { + "type": "string" } }, - "required": ["id", "title", "command", "args", "cwd", "status", "pid"], + "required": ["id", "sessionID", "messageID", "type", "prompt", "description", "agent"], "additionalProperties": false }, - "OutputFormatText": { + "ReasoningPart": { "type": "object", "properties": { + "id": { + "type": "string", + "pattern": "^prt" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "messageID": { + "type": "string", + "pattern": "^msg" + }, "type": { "type": "string", - "enum": ["text"] + "enum": ["reasoning"] + }, + "text": { + "type": "string" + }, + "metadata": { + "type": "object" + }, + "time": { + "type": "object", + "properties": { + "start": { + "type": "integer", + "minimum": 0 + }, + "end": { + "type": "integer", + "minimum": 0 + } + }, + "required": ["start"], + "additionalProperties": false } }, - "required": ["type"], + "required": ["id", "sessionID", "messageID", "type", "text", "time"], "additionalProperties": false }, - "JSONSchema": { - "type": "object" - }, - "OutputFormatJsonSchema": { + "FilePartSourceText": { "type": "object", "properties": { - "type": { - "type": "string", - "enum": ["json_schema"] + "value": { + "type": "string" }, - "schema": { - "$ref": "#/components/schemas/JSONSchema" + "start": { + "type": "number" }, - "retryCount": { - "type": "integer", - "minimum": 0 + "end": { + "type": "number" } }, - "required": ["type", "schema"], + "required": ["value", "start", "end"], "additionalProperties": false }, - "OutputFormat": { - "anyOf": [ - { - "$ref": "#/components/schemas/OutputFormatText" - }, - { - "$ref": "#/components/schemas/OutputFormatJsonSchema" - } - ] - }, - "UserMessage": { + "FileSource": { "type": "object", "properties": { - "id": { - "type": "string", - "pattern": "^msg" - }, - "sessionID": { - "type": "string", - "pattern": "^ses" + "text": { + "$ref": "#/components/schemas/FilePartSourceText" }, - "role": { + "type": { "type": "string", - "enum": ["user"] + "enum": ["file"] }, - "time": { + "path": { + "type": "string" + } + }, + "required": ["text", "type", "path"], + "additionalProperties": false + }, + "Range": { + "type": "object", + "properties": { + "start": { "type": "object", "properties": { - "created": { + "line": { + "type": "integer", + "minimum": 0 + }, + "character": { "type": "integer", "minimum": 0 } }, - "required": ["created"], + "required": ["line", "character"], "additionalProperties": false }, - "format": { - "$ref": "#/components/schemas/OutputFormat" - }, - "summary": { + "end": { "type": "object", "properties": { - "title": { - "type": "string" - }, - "body": { - "type": "string" + "line": { + "type": "integer", + "minimum": 0 }, - "diffs": { - "type": "array", - "items": { - "$ref": "#/components/schemas/SnapshotFileDiff" - } + "character": { + "type": "integer", + "minimum": 0 } }, - "required": ["diffs"], + "required": ["line", "character"], "additionalProperties": false + } + }, + "required": ["start", "end"], + "additionalProperties": false + }, + "SymbolSource": { + "type": "object", + "properties": { + "text": { + "$ref": "#/components/schemas/FilePartSourceText" }, - "agent": { + "type": { + "type": "string", + "enum": ["symbol"] + }, + "path": { "type": "string" }, - "model": { - "type": "object", - "properties": { - "providerID": { - "type": "string" - }, - "modelID": { - "type": "string" - }, - "variant": { - "type": "string" - } - }, - "required": ["providerID", "modelID"], - "additionalProperties": false + "range": { + "$ref": "#/components/schemas/Range" }, - "system": { + "name": { "type": "string" }, - "tools": { - "type": "object", - "additionalProperties": { - "type": "boolean" - } + "kind": { + "type": "integer", + "minimum": 0 } }, - "required": ["id", "sessionID", "role", "time", "agent", "model"], + "required": ["text", "type", "path", "range", "name", "kind"], "additionalProperties": false }, - "AssistantMessage": { + "ResourceSource": { + "type": "object", + "properties": { + "text": { + "$ref": "#/components/schemas/FilePartSourceText" + }, + "type": { + "type": "string", + "enum": ["resource"] + }, + "clientName": { + "type": "string" + }, + "uri": { + "type": "string" + } + }, + "required": ["text", "type", "clientName", "uri"], + "additionalProperties": false + }, + "FilePartSource": { + "anyOf": [ + { + "$ref": "#/components/schemas/FileSource" + }, + { + "$ref": "#/components/schemas/SymbolSource" + }, + { + "$ref": "#/components/schemas/ResourceSource" + } + ] + }, + "FilePart": { "type": "object", "properties": { "id": { "type": "string", - "pattern": "^msg" + "pattern": "^prt" }, "sessionID": { "type": "string", "pattern": "^ses" }, - "role": { + "messageID": { "type": "string", - "enum": ["assistant"] + "pattern": "^msg" }, - "time": { - "type": "object", - "properties": { - "created": { - "type": "integer", - "minimum": 0 - }, - "completed": { - "type": "integer", - "minimum": 0 - } - }, - "required": ["created"], - "additionalProperties": false + "type": { + "type": "string", + "enum": ["file"] }, - "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" - } - ] - }, - "parentID": { - "type": "string", - "pattern": "^msg" + "mime": { + "type": "string" }, - "modelID": { + "filename": { "type": "string" }, - "providerID": { + "url": { "type": "string" }, - "mode": { + "source": { + "$ref": "#/components/schemas/FilePartSource" + } + }, + "required": ["id", "sessionID", "messageID", "type", "mime", "url"], + "additionalProperties": false + }, + "ToolStatePending": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": ["pending"] + }, + "input": { + "type": "object" + }, + "raw": { "type": "string" + } + }, + "required": ["status", "input", "raw"], + "additionalProperties": false + }, + "ToolStateRunning": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": ["running"] }, - "agent": { + "input": { + "type": "object" + }, + "title": { "type": "string" }, - "path": { + "metadata": { + "type": "object" + }, + "time": { "type": "object", "properties": { - "cwd": { - "type": "string" - }, - "root": { - "type": "string" + "start": { + "type": "integer", + "minimum": 0 } }, - "required": ["cwd", "root"], + "required": ["start"], "additionalProperties": false + } + }, + "required": ["status", "input", "time"], + "additionalProperties": false + }, + "ToolStateCompleted": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": ["completed"] }, - "summary": { - "type": "boolean" + "input": { + "type": "object" }, - "cost": { - "type": "number" + "output": { + "type": "string" }, - "tokens": { + "title": { + "type": "string" + }, + "metadata": { + "type": "object" + }, + "time": { "type": "object", "properties": { - "total": { - "type": "number" - }, - "input": { - "type": "number" - }, - "output": { - "type": "number" + "start": { + "type": "integer", + "minimum": 0 }, - "reasoning": { - "type": "number" + "end": { + "type": "integer", + "minimum": 0 }, - "cache": { - "type": "object", - "properties": { - "read": { - "type": "number" - }, - "write": { - "type": "number" - } - }, - "required": ["read", "write"], - "additionalProperties": false + "compacted": { + "type": "integer", + "minimum": 0 } }, - "required": ["input", "output", "reasoning", "cache"], + "required": ["start", "end"], "additionalProperties": false }, - "structured": {}, - "variant": { - "type": "string" + "attachments": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FilePart" + } + } + }, + "required": ["status", "input", "output", "title", "metadata", "time"], + "additionalProperties": false + }, + "ToolStateError": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": ["error"] }, - "finish": { + "input": { + "type": "object" + }, + "error": { "type": "string" + }, + "metadata": { + "type": "object" + }, + "time": { + "type": "object", + "properties": { + "start": { + "type": "integer", + "minimum": 0 + }, + "end": { + "type": "integer", + "minimum": 0 + } + }, + "required": ["start", "end"], + "additionalProperties": false } }, - "required": [ - "id", - "sessionID", - "role", - "time", - "parentID", - "modelID", - "providerID", - "mode", - "agent", - "path", - "cost", - "tokens" - ], + "required": ["status", "input", "error", "time"], "additionalProperties": false }, - "Message": { + "ToolState": { "anyOf": [ { - "$ref": "#/components/schemas/UserMessage" + "$ref": "#/components/schemas/ToolStatePending" }, { - "$ref": "#/components/schemas/AssistantMessage" + "$ref": "#/components/schemas/ToolStateRunning" + }, + { + "$ref": "#/components/schemas/ToolStateCompleted" + }, + { + "$ref": "#/components/schemas/ToolStateError" } ] }, - "TextPart": { + "ToolPart": { "type": "object", "properties": { "id": { @@ -11905,40 +11971,25 @@ }, "type": { "type": "string", - "enum": ["text"] + "enum": ["tool"] }, - "text": { + "callID": { "type": "string" }, - "synthetic": { - "type": "boolean" - }, - "ignored": { - "type": "boolean" + "tool": { + "type": "string" }, - "time": { - "type": "object", - "properties": { - "start": { - "type": "integer", - "minimum": 0 - }, - "end": { - "type": "integer", - "minimum": 0 - } - }, - "required": ["start"], - "additionalProperties": false + "state": { + "$ref": "#/components/schemas/ToolState" }, "metadata": { "type": "object" } }, - "required": ["id", "sessionID", "messageID", "type", "text"], + "required": ["id", "sessionID", "messageID", "type", "callID", "tool", "state"], "additionalProperties": false }, - "SubtaskPart": { + "StepStartPart": { "type": "object", "properties": { "id": { @@ -11955,38 +12006,16 @@ }, "type": { "type": "string", - "enum": ["subtask"] - }, - "prompt": { - "type": "string" - }, - "description": { - "type": "string" - }, - "agent": { - "type": "string" - }, - "model": { - "type": "object", - "properties": { - "providerID": { - "type": "string" - }, - "modelID": { - "type": "string" - } - }, - "required": ["providerID", "modelID"], - "additionalProperties": false + "enum": ["step-start"] }, - "command": { + "snapshot": { "type": "string" } }, - "required": ["id", "sessionID", "messageID", "type", "prompt", "description", "agent"], + "required": ["id", "sessionID", "messageID", "type"], "additionalProperties": false }, - "ReasoningPart": { + "StepFinishPart": { "type": "object", "properties": { "id": { @@ -12003,164 +12032,112 @@ }, "type": { "type": "string", - "enum": ["reasoning"] + "enum": ["step-finish"] }, - "text": { + "reason": { "type": "string" }, - "metadata": { - "type": "object" - }, - "time": { - "type": "object", - "properties": { - "start": { - "type": "integer", - "minimum": 0 - }, - "end": { - "type": "integer", - "minimum": 0 - } - }, - "required": ["start"], - "additionalProperties": false - } - }, - "required": ["id", "sessionID", "messageID", "type", "text", "time"], - "additionalProperties": false - }, - "FilePartSourceText": { - "type": "object", - "properties": { - "value": { + "snapshot": { "type": "string" }, - "start": { - "type": "number" - }, - "end": { + "cost": { "type": "number" - } - }, - "required": ["value", "start", "end"], - "additionalProperties": false - }, - "FileSource": { - "type": "object", - "properties": { - "text": { - "$ref": "#/components/schemas/FilePartSourceText" - }, - "type": { - "type": "string", - "enum": ["file"] }, - "path": { - "type": "string" - } - }, - "required": ["text", "type", "path"], - "additionalProperties": false - }, - "Range": { - "type": "object", - "properties": { - "start": { + "tokens": { "type": "object", "properties": { - "line": { - "type": "integer", - "minimum": 0 + "total": { + "type": "number" }, - "character": { - "type": "integer", - "minimum": 0 - } - }, - "required": ["line", "character"], - "additionalProperties": false - }, - "end": { - "type": "object", - "properties": { - "line": { - "type": "integer", - "minimum": 0 + "input": { + "type": "number" }, - "character": { - "type": "integer", - "minimum": 0 + "output": { + "type": "number" + }, + "reasoning": { + "type": "number" + }, + "cache": { + "type": "object", + "properties": { + "read": { + "type": "number" + }, + "write": { + "type": "number" + } + }, + "required": ["read", "write"], + "additionalProperties": false } }, - "required": ["line", "character"], + "required": ["input", "output", "reasoning", "cache"], "additionalProperties": false } }, - "required": ["start", "end"], + "required": ["id", "sessionID", "messageID", "type", "reason", "cost", "tokens"], "additionalProperties": false }, - "SymbolSource": { + "SnapshotPart": { "type": "object", "properties": { - "text": { - "$ref": "#/components/schemas/FilePartSourceText" + "id": { + "type": "string", + "pattern": "^prt" }, - "type": { + "sessionID": { "type": "string", - "enum": ["symbol"] + "pattern": "^ses" }, - "path": { - "type": "string" + "messageID": { + "type": "string", + "pattern": "^msg" }, - "range": { - "$ref": "#/components/schemas/Range" + "type": { + "type": "string", + "enum": ["snapshot"] }, - "name": { + "snapshot": { "type": "string" - }, - "kind": { - "type": "integer", - "minimum": 0 } }, - "required": ["text", "type", "path", "range", "name", "kind"], + "required": ["id", "sessionID", "messageID", "type", "snapshot"], "additionalProperties": false }, - "ResourceSource": { + "PatchPart": { "type": "object", "properties": { - "text": { - "$ref": "#/components/schemas/FilePartSourceText" + "id": { + "type": "string", + "pattern": "^prt" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "messageID": { + "type": "string", + "pattern": "^msg" }, "type": { "type": "string", - "enum": ["resource"] + "enum": ["patch"] }, - "clientName": { + "hash": { "type": "string" }, - "uri": { - "type": "string" + "files": { + "type": "array", + "items": { + "type": "string" + } } }, - "required": ["text", "type", "clientName", "uri"], + "required": ["id", "sessionID", "messageID", "type", "hash", "files"], "additionalProperties": false }, - "FilePartSource": { - "anyOf": [ - { - "$ref": "#/components/schemas/FileSource" - }, - { - "$ref": "#/components/schemas/SymbolSource" - }, - { - "$ref": "#/components/schemas/ResourceSource" - } - ] - }, - "FilePart": { + "AgentPart": { "type": "object", "properties": { "id": { @@ -12177,1171 +12154,2746 @@ }, "type": { "type": "string", - "enum": ["file"] - }, - "mime": { - "type": "string" - }, - "filename": { - "type": "string" + "enum": ["agent"] }, - "url": { + "name": { "type": "string" }, "source": { - "$ref": "#/components/schemas/FilePartSource" + "type": "object", + "properties": { + "value": { + "type": "string" + }, + "start": { + "type": "integer", + "minimum": 0 + }, + "end": { + "type": "integer", + "minimum": 0 + } + }, + "required": ["value", "start", "end"], + "additionalProperties": false } }, - "required": ["id", "sessionID", "messageID", "type", "mime", "url"], + "required": ["id", "sessionID", "messageID", "type", "name"], "additionalProperties": false }, - "ToolStatePending": { + "RetryPart": { "type": "object", "properties": { - "status": { + "id": { "type": "string", - "enum": ["pending"] + "pattern": "^prt" }, - "input": { - "type": "object" + "sessionID": { + "type": "string", + "pattern": "^ses" }, - "raw": { - "type": "string" - } - }, - "required": ["status", "input", "raw"], - "additionalProperties": false - }, - "ToolStateRunning": { - "type": "object", - "properties": { - "status": { + "messageID": { "type": "string", - "enum": ["running"] + "pattern": "^msg" }, - "input": { - "type": "object" + "type": { + "type": "string", + "enum": ["retry"] }, - "title": { - "type": "string" + "attempt": { + "type": "integer", + "minimum": 0 }, - "metadata": { - "type": "object" + "error": { + "$ref": "#/components/schemas/APIError" }, "time": { "type": "object", "properties": { - "start": { + "created": { "type": "integer", "minimum": 0 } }, - "required": ["start"], + "required": ["created"], "additionalProperties": false } }, - "required": ["status", "input", "time"], + "required": ["id", "sessionID", "messageID", "type", "attempt", "error", "time"], "additionalProperties": false }, - "ToolStateCompleted": { + "CompactionPart": { "type": "object", "properties": { - "status": { + "id": { "type": "string", - "enum": ["completed"] + "pattern": "^prt" }, - "input": { - "type": "object" + "sessionID": { + "type": "string", + "pattern": "^ses" }, - "output": { - "type": "string" + "messageID": { + "type": "string", + "pattern": "^msg" }, - "title": { - "type": "string" + "type": { + "type": "string", + "enum": ["compaction"] }, - "metadata": { - "type": "object" + "auto": { + "type": "boolean" }, - "time": { - "type": "object", - "properties": { - "start": { - "type": "integer", - "minimum": 0 - }, - "end": { - "type": "integer", - "minimum": 0 - }, - "compacted": { - "type": "integer", - "minimum": 0 - } - }, - "required": ["start", "end"], - "additionalProperties": false + "overflow": { + "type": "boolean" }, - "attachments": { - "type": "array", - "items": { - "$ref": "#/components/schemas/FilePart" - } + "tail_start_id": { + "type": "string", + "pattern": "^msg" } }, - "required": ["status", "input", "output", "title", "metadata", "time"], + "required": ["id", "sessionID", "messageID", "type", "auto"], "additionalProperties": false }, - "ToolStateError": { - "type": "object", - "properties": { - "status": { - "type": "string", - "enum": ["error"] + "Part": { + "anyOf": [ + { + "$ref": "#/components/schemas/TextPart" }, - "input": { - "type": "object" + { + "$ref": "#/components/schemas/SubtaskPart" }, - "error": { - "type": "string" + { + "$ref": "#/components/schemas/ReasoningPart" }, - "metadata": { - "type": "object" + { + "$ref": "#/components/schemas/FilePart" }, - "time": { - "type": "object", - "properties": { - "start": { - "type": "integer", - "minimum": 0 - }, - "end": { - "type": "integer", - "minimum": 0 - } - }, - "required": ["start", "end"], - "additionalProperties": false - } - }, - "required": ["status", "input", "error", "time"], - "additionalProperties": false - }, - "ToolState": { - "anyOf": [ { - "$ref": "#/components/schemas/ToolStatePending" + "$ref": "#/components/schemas/ToolPart" }, { - "$ref": "#/components/schemas/ToolStateRunning" + "$ref": "#/components/schemas/StepStartPart" }, { - "$ref": "#/components/schemas/ToolStateCompleted" + "$ref": "#/components/schemas/StepFinishPart" }, { - "$ref": "#/components/schemas/ToolStateError" + "$ref": "#/components/schemas/SnapshotPart" + }, + { + "$ref": "#/components/schemas/PatchPart" + }, + { + "$ref": "#/components/schemas/AgentPart" + }, + { + "$ref": "#/components/schemas/RetryPart" + }, + { + "$ref": "#/components/schemas/CompactionPart" } ] }, - "ToolPart": { + "QuestionOption": { "type": "object", "properties": { - "id": { + "label": { "type": "string", - "pattern": "^prt" + "description": "Display text (1-5 words, concise)" }, - "sessionID": { + "description": { "type": "string", - "pattern": "^ses" - }, - "messageID": { + "description": "Explanation of choice" + } + }, + "required": ["label", "description"], + "additionalProperties": false + }, + "QuestionInfo": { + "type": "object", + "properties": { + "question": { "type": "string", - "pattern": "^msg" + "description": "Complete question" }, - "type": { + "header": { "type": "string", - "enum": ["tool"] - }, - "callID": { - "type": "string" + "description": "Very short label (max 30 chars)" }, - "tool": { - "type": "string" + "options": { + "type": "array", + "items": { + "$ref": "#/components/schemas/QuestionOption" + }, + "description": "Available choices" }, - "state": { - "$ref": "#/components/schemas/ToolState" + "multiple": { + "type": "boolean" }, - "metadata": { - "type": "object" + "custom": { + "type": "boolean" } }, - "required": ["id", "sessionID", "messageID", "type", "callID", "tool", "state"], + "required": ["question", "header", "options"], "additionalProperties": false }, - "StepStartPart": { + "QuestionTool": { "type": "object", "properties": { - "id": { - "type": "string", - "pattern": "^prt" - }, - "sessionID": { - "type": "string", - "pattern": "^ses" - }, "messageID": { "type": "string", "pattern": "^msg" }, - "type": { - "type": "string", - "enum": ["step-start"] - }, - "snapshot": { + "callID": { "type": "string" } }, - "required": ["id", "sessionID", "messageID", "type"], + "required": ["messageID", "callID"], "additionalProperties": false }, - "StepFinishPart": { + "QuestionAnswer": { + "type": "array", + "items": { + "type": "string" + } + }, + "Todo": { "type": "object", "properties": { - "id": { - "type": "string", - "pattern": "^prt" - }, - "sessionID": { + "content": { "type": "string", - "pattern": "^ses" + "description": "Brief description of the task" }, - "messageID": { + "status": { "type": "string", - "pattern": "^msg" + "description": "Current status of the task: pending, in_progress, completed, cancelled" }, - "type": { + "priority": { "type": "string", - "enum": ["step-finish"] - }, - "reason": { - "type": "string" - }, - "snapshot": { - "type": "string" - }, - "cost": { - "type": "number" + "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 }, - "tokens": { + { "type": "object", "properties": { - "total": { - "type": "number" - }, - "input": { - "type": "number" + "type": { + "type": "string", + "enum": ["retry"] }, - "output": { - "type": "number" + "attempt": { + "type": "integer", + "minimum": 0 }, - "reasoning": { - "type": "number" + "message": { + "type": "string" }, - "cache": { + "action": { "type": "object", "properties": { - "read": { - "type": "number" + "reason": { + "type": "string" }, - "write": { - "type": "number" - } + "provider": { + "type": "string" + }, + "title": { + "type": "string" + }, + "message": { + "type": "string" + }, + "label": { + "type": "string" + }, + "link": { + "type": "string" + } }, - "required": ["read", "write"], + "required": ["reason", "provider", "title", "message", "label"], "additionalProperties": false + }, + "next": { + "type": "integer", + "minimum": 0 } }, - "required": ["input", "output", "reasoning", "cache"], + "required": ["type", "attempt", "message", "next"], "additionalProperties": false - } - }, - "required": ["id", "sessionID", "messageID", "type", "reason", "cost", "tokens"], - "additionalProperties": false - }, - "SnapshotPart": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^prt" - }, - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "messageID": { - "type": "string", - "pattern": "^msg" - }, - "type": { - "type": "string", - "enum": ["snapshot"] }, - "snapshot": { - "type": "string" + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["busy"] + } + }, + "required": ["type"], + "additionalProperties": false } - }, - "required": ["id", "sessionID", "messageID", "type", "snapshot"], - "additionalProperties": false + ] }, - "PatchPart": { + "Pty": { "type": "object", "properties": { "id": { "type": "string", - "pattern": "^prt" - }, - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "messageID": { - "type": "string", - "pattern": "^msg" + "pattern": "^pty" }, - "type": { - "type": "string", - "enum": ["patch"] + "title": { + "type": "string" }, - "hash": { + "command": { "type": "string" }, - "files": { + "args": { "type": "array", "items": { "type": "string" } - } - }, - "required": ["id", "sessionID", "messageID", "type", "hash", "files"], - "additionalProperties": false - }, - "AgentPart": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^prt" - }, - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "messageID": { - "type": "string", - "pattern": "^msg" - }, - "type": { - "type": "string", - "enum": ["agent"] }, - "name": { + "cwd": { "type": "string" }, - "source": { - "type": "object", - "properties": { - "value": { - "type": "string" - }, - "start": { - "type": "integer", - "minimum": 0 - }, - "end": { - "type": "integer", - "minimum": 0 - } - }, - "required": ["value", "start", "end"], - "additionalProperties": false - } - }, - "required": ["id", "sessionID", "messageID", "type", "name"], - "additionalProperties": false - }, - "RetryPart": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^prt" - }, - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "messageID": { - "type": "string", - "pattern": "^msg" - }, - "type": { + "status": { "type": "string", - "enum": ["retry"] + "enum": ["running", "exited"] }, - "attempt": { + "pid": { "type": "integer", "minimum": 0 - }, - "error": { - "$ref": "#/components/schemas/APIError" - }, - "time": { - "type": "object", - "properties": { - "created": { - "type": "integer", - "minimum": 0 - } - }, - "required": ["created"], - "additionalProperties": false } }, - "required": ["id", "sessionID", "messageID", "type", "attempt", "error", "time"], + "required": ["id", "title", "command", "args", "cwd", "status", "pid"], "additionalProperties": false }, - "CompactionPart": { + "GlobalEvent": { "type": "object", "properties": { - "id": { - "type": "string", - "pattern": "^prt" - }, - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "messageID": { - "type": "string", - "pattern": "^msg" - }, - "type": { - "type": "string", - "enum": ["compaction"] - }, - "auto": { - "type": "boolean" - }, - "overflow": { - "type": "boolean" - }, - "tail_start_id": { - "type": "string", - "pattern": "^msg" - } - }, - "required": ["id", "sessionID", "messageID", "type", "auto"], - "additionalProperties": false - }, - "Part": { - "anyOf": [ - { - "$ref": "#/components/schemas/TextPart" - }, - { - "$ref": "#/components/schemas/SubtaskPart" - }, - { - "$ref": "#/components/schemas/ReasoningPart" - }, - { - "$ref": "#/components/schemas/FilePart" - }, - { - "$ref": "#/components/schemas/ToolPart" - }, - { - "$ref": "#/components/schemas/StepStartPart" - }, - { - "$ref": "#/components/schemas/StepFinishPart" - }, - { - "$ref": "#/components/schemas/SnapshotPart" - }, - { - "$ref": "#/components/schemas/PatchPart" - }, - { - "$ref": "#/components/schemas/AgentPart" - }, - { - "$ref": "#/components/schemas/RetryPart" + "directory": { + "type": "string" }, - { - "$ref": "#/components/schemas/CompactionPart" - } - ] - }, - "PermissionAction": { - "type": "string", - "enum": ["allow", "deny", "ask"] - }, - "PermissionRule": { - "type": "object", - "properties": { - "permission": { + "project": { "type": "string" }, - "pattern": { + "workspace": { "type": "string" }, - "action": { - "$ref": "#/components/schemas/PermissionAction" - } - }, - "required": ["permission", "pattern", "action"], - "additionalProperties": false - }, - "PermissionRuleset": { - "type": "array", - "items": { - "$ref": "#/components/schemas/PermissionRule" - } - }, - "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" + "payload": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["models-dev.refreshed"] + }, + "properties": { + "type": "object", + "properties": {} + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false }, - "files": { - "type": "number" + { + "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 }, - "diffs": { - "type": "array", - "items": { - "$ref": "#/components/schemas/SnapshotFileDiff" - } - } - }, - "required": ["additions", "deletions", "files"], - "additionalProperties": false - }, - "cost": { - "type": "number" - }, - "tokens": { - "type": "object", - "properties": { - "input": { - "type": "number" + { + "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 }, - "output": { - "type": "number" + { + "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 }, - "reasoning": { - "type": "number" + { + "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 }, - "cache": { + { "type": "object", "properties": { - "read": { - "type": "number" + "id": { + "type": "string" }, - "write": { - "type": "number" + "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": ["read", "write"], + "required": ["id", "type", "properties"], "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 - }, - "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"], - "additionalProperties": false - }, - "GlobalEvent": { - "type": "object", - "properties": { - "directory": { - "type": "string" - }, - "project": { - "type": "string" - }, - "workspace": { - "type": "string" - }, - "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" + { + "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 }, { - "$ref": "#/components/schemas/EventSessionIdle" + "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 }, { - "$ref": "#/components/schemas/EventMcpToolsChanged" + "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 }, { - "$ref": "#/components/schemas/EventMcpBrowserOpenFailed" + "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 }, { - "$ref": "#/components/schemas/EventCommandExecuted" + "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 }, { - "$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" + "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 }, { - "$ref": "#/components/schemas/EventWorktreeFailed" + "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 }, { - "$ref": "#/components/schemas/EventPtyCreated" + "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 }, { - "$ref": "#/components/schemas/EventPtyUpdated" + "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 }, { - "$ref": "#/components/schemas/EventPtyExited" + "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 }, { - "$ref": "#/components/schemas/EventPtyDeleted" + "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 }, { - "$ref": "#/components/schemas/EventInstallationUpdated" + "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 }, { - "$ref": "#/components/schemas/EventInstallationUpdate-available" + "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 }, { - "$ref": "#/components/schemas/EventMessageUpdated" + "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 }, { - "$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" + "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 }, { - "$ref": "#/components/schemas/EventSessionNextShellEnded" + "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 }, { - "$ref": "#/components/schemas/EventSessionNextStepStarted" + "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 }, { - "$ref": "#/components/schemas/EventSessionNextStepEnded" + "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 }, { - "$ref": "#/components/schemas/EventSessionNextStepFailed" + "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 }, { - "$ref": "#/components/schemas/EventSessionNextTextStarted" + "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 }, { - "$ref": "#/components/schemas/EventSessionNextTextDelta" + "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 }, { - "$ref": "#/components/schemas/EventSessionNextTextEnded" + "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 }, { - "$ref": "#/components/schemas/EventSessionNextReasoningStarted" - }, - { - "$ref": "#/components/schemas/EventSessionNextReasoningDelta" - }, - { - "$ref": "#/components/schemas/EventSessionNextReasoningEnded" - }, - { - "$ref": "#/components/schemas/EventSessionNextToolInputStarted" - }, - { - "$ref": "#/components/schemas/EventSessionNextToolInputDelta" + "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 }, { - "$ref": "#/components/schemas/EventSessionNextToolInputEnded" + "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 }, { - "$ref": "#/components/schemas/EventSessionNextToolCalled" + "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 }, { - "$ref": "#/components/schemas/EventSessionNextToolProgress" + "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 }, { - "$ref": "#/components/schemas/EventSessionNextToolSuccess" + "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 }, { - "$ref": "#/components/schemas/EventSessionNextToolFailed" + "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 }, { - "$ref": "#/components/schemas/EventSessionNextRetried" + "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 }, { - "$ref": "#/components/schemas/EventSessionNextCompactionStarted" + "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 }, { - "$ref": "#/components/schemas/EventSessionNextCompactionDelta" + "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 }, { - "$ref": "#/components/schemas/EventSessionNextCompactionEnded" + "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 }, { - "$ref": "#/components/schemas/EventPluginAdded" + "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 }, { - "$ref": "#/components/schemas/EventCatalogModelUpdated" - }, - { - "$ref": "#/components/schemas/EventSessionNextAgentSwitched" - }, - { - "$ref": "#/components/schemas/EventSessionNextModelSwitched" - }, - { - "$ref": "#/components/schemas/EventSessionNextPrompted" - }, - { - "$ref": "#/components/schemas/EventSessionNextSynthetic" - }, - { - "$ref": "#/components/schemas/EventSessionNextShellStarted" + "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 }, { - "$ref": "#/components/schemas/EventSessionNextShellEnded" + "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 }, { - "$ref": "#/components/schemas/EventSessionNextStepStarted" + "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 }, { - "$ref": "#/components/schemas/EventSessionNextStepEnded" + "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 }, { - "$ref": "#/components/schemas/EventSessionNextStepFailed" + "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 }, { - "$ref": "#/components/schemas/EventSessionNextTextStarted" + "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 }, { - "$ref": "#/components/schemas/EventSessionNextTextDelta" + "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 }, { - "$ref": "#/components/schemas/EventSessionNextTextEnded" + "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 }, { - "$ref": "#/components/schemas/EventSessionNextReasoningStarted" + "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 }, { - "$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" + "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 }, { - "$ref": "#/components/schemas/SyncEventSessionNextShellStarted" + "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 }, { - "$ref": "#/components/schemas/SyncEventSessionNextShellEnded" + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["lsp.updated"] + }, + "properties": { + "type": "object", + "properties": {} + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false }, { - "$ref": "#/components/schemas/SyncEventSessionNextStepStarted" + "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 }, { - "$ref": "#/components/schemas/SyncEventSessionNextStepEnded" + "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 }, { - "$ref": "#/components/schemas/SyncEventSessionNextStepFailed" + "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 }, { - "$ref": "#/components/schemas/SyncEventSessionNextTextStarted" + "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 }, { - "$ref": "#/components/schemas/SyncEventSessionNextTextDelta" + "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 }, { - "$ref": "#/components/schemas/SyncEventSessionNextTextEnded" + "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 }, { - "$ref": "#/components/schemas/SyncEventSessionNextReasoningStarted" + "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 }, { - "$ref": "#/components/schemas/SyncEventSessionNextReasoningDelta" - }, - { - "$ref": "#/components/schemas/SyncEventSessionNextReasoningEnded" - }, - { - "$ref": "#/components/schemas/SyncEventSessionNextToolInputStarted" + "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 }, { - "$ref": "#/components/schemas/SyncEventSessionNextToolInputDelta" + "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 }, { - "$ref": "#/components/schemas/SyncEventSessionNextToolInputEnded" + "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 }, { - "$ref": "#/components/schemas/SyncEventSessionNextToolCalled" + "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 }, { - "$ref": "#/components/schemas/SyncEventSessionNextToolProgress" + "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 }, { - "$ref": "#/components/schemas/SyncEventSessionNextToolSuccess" + "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 }, { - "$ref": "#/components/schemas/SyncEventSessionNextToolFailed" + "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 }, { - "$ref": "#/components/schemas/SyncEventSessionNextRetried" + "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 }, { - "$ref": "#/components/schemas/SyncEventSessionNextCompactionStarted" + "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 }, { - "$ref": "#/components/schemas/SyncEventSessionNextCompactionDelta" + "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 }, { - "$ref": "#/components/schemas/SyncEventSessionNextCompactionEnded" - } - ] - } - }, - "required": ["directory", "payload"], - "additionalProperties": false - }, - "LogLevel": { - "type": "string", - "enum": ["DEBUG", "INFO", "WARN", "ERROR"], - "description": "Log level" - }, + "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 + } + ] + } + }, + "required": ["directory", "payload"], + "additionalProperties": false + }, + "LogLevel": { + "type": "string", + "enum": ["DEBUG", "INFO", "WARN", "ERROR"], + "description": "Log level" + }, "ServerConfig": { "type": "object", "properties": { @@ -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,29 +17195,37 @@ "required": ["_tag", "message"], "additionalProperties": false }, - "QuestionNotFoundError": { + "QuestionRequest": { "type": "object", "properties": { - "_tag": { + "id": { "type": "string", - "enum": ["QuestionNotFoundError"] + "pattern": "^que" }, - "requestID": { - "type": "string" + "sessionID": { + "type": "string", + "pattern": "^ses" }, - "message": { - "type": "string" + "questions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/QuestionInfo" + }, + "description": "Questions to ask" + }, + "tool": { + "$ref": "#/components/schemas/QuestionTool" } }, - "required": ["_tag", "requestID", "message"], + "required": ["id", "sessionID", "questions"], "additionalProperties": false }, - "PermissionNotFoundError": { + "QuestionNotFoundError": { "type": "object", "properties": { "_tag": { "type": "string", - "enum": ["PermissionNotFoundError"] + "enum": ["QuestionNotFoundError"] }, "requestID": { "type": "string" @@ -15581,36 +17237,100 @@ "required": ["_tag", "requestID", "message"], "additionalProperties": false }, - "ProviderAuthMethod": { + "PermissionRequest": { "type": "object", "properties": { - "type": { + "id": { "type": "string", - "enum": ["oauth", "api"] + "pattern": "^per" }, - "label": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "permission": { "type": "string" }, - "prompts": { + "patterns": { "type": "array", "items": { - "anyOf": [ - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["text"] - }, - "key": { - "type": "string" - }, - "message": { - "type": "string" - }, - "placeholder": { - "type": "string" - }, + "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": { + "_tag": { + "type": "string", + "enum": ["PermissionNotFoundError"] + }, + "requestID": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": ["_tag", "requestID", "message"], + "additionalProperties": false + }, + "ProviderAuthMethod": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["oauth", "api"] + }, + "label": { + "type": "string" + }, + "prompts": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["text"] + }, + "key": { + "type": "string" + }, + "message": { + "type": "string" + }, + "placeholder": { + "type": "string" + }, "when": { "type": "object", "properties": { @@ -15744,145 +17464,98 @@ "required": ["name", "data"], "additionalProperties": false }, - "NotFoundError": { - "type": "object", - "required": ["name", "data"], - "properties": { - "name": { - "type": "string", - "enum": ["NotFoundError"] - }, - "data": { - "type": "object", - "required": ["message"], - "properties": { - "message": { - "type": "string" - } - } - } - } - }, - "TextPartInput": { + "Session1": { "type": "object", "properties": { "id": { "type": "string", - "pattern": "^prt" + "pattern": "^ses" }, - "type": { + "slug": { + "type": "string" + }, + "projectID": { + "type": "string" + }, + "workspaceID": { "type": "string", - "enum": ["text"] + "pattern": "^wrk" }, - "text": { + "directory": { "type": "string" }, - "synthetic": { - "type": "boolean" + "path": { + "type": "string" }, - "ignored": { - "type": "boolean" + "parentID": { + "type": "string", + "pattern": "^ses" }, - "time": { + "summary": { "type": "object", "properties": { - "start": { - "type": "integer", - "minimum": 0 + "additions": { + "type": "number" }, - "end": { - "type": "integer", - "minimum": 0 + "deletions": { + "type": "number" + }, + "files": { + "type": "number" + }, + "diffs": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SnapshotFileDiff" + } } }, - "required": ["start"], + "required": ["additions", "deletions", "files"], "additionalProperties": false }, - "metadata": { - "type": "object" - } - }, - "required": ["type", "text"], - "additionalProperties": false - }, - "FilePartInput": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^prt" - }, - "type": { - "type": "string", - "enum": ["file"] - }, - "mime": { - "type": "string" - }, - "filename": { - "type": "string" - }, - "url": { - "type": "string" - }, - "source": { - "$ref": "#/components/schemas/FilePartSource" - } - }, - "required": ["type", "mime", "url"], - "additionalProperties": false - }, - "AgentPartInput": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^prt" - }, - "type": { - "type": "string", - "enum": ["agent"] - }, - "name": { - "type": "string" + "cost": { + "type": "number" }, - "source": { + "tokens": { "type": "object", "properties": { - "value": { - "type": "string" + "input": { + "type": "number" }, - "start": { - "type": "integer", - "minimum": 0 + "output": { + "type": "number" }, - "end": { - "type": "integer", - "minimum": 0 + "reasoning": { + "type": "number" + }, + "cache": { + "type": "object", + "properties": { + "read": { + "type": "number" + }, + "write": { + "type": "number" + } + }, + "required": ["read", "write"], + "additionalProperties": false } }, - "required": ["value", "start", "end"], + "required": ["input", "output", "reasoning", "cache"], "additionalProperties": false - } - }, - "required": ["type", "name"], - "additionalProperties": false - }, - "SubtaskPartInput": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^prt" - }, - "type": { - "type": "string", - "enum": ["subtask"] }, - "prompt": { - "type": "string" + "share": { + "type": "object", + "properties": { + "url": { + "type": "string" + } + }, + "required": ["url"], + "additionalProperties": false }, - "description": { + "title": { "type": "string" }, "agent": { @@ -15891,3276 +17564,3551 @@ "model": { "type": "object", "properties": { + "id": { + "type": "string" + }, "providerID": { "type": "string" }, - "modelID": { + "variant": { "type": "string" } }, - "required": ["providerID", "modelID"], + "required": ["id", "providerID"], "additionalProperties": false }, - "command": { + "version": { "type": "string" - } - }, - "required": ["type", "prompt", "description", "agent"], - "additionalProperties": false - }, - "SessionBusyError": { - "type": "object", - "properties": { - "_tag": { - "type": "string", - "enum": ["SessionBusyError"] }, - "sessionID": { - "type": "string" + "metadata": { + "type": "object" }, - "message": { - "type": "string" - } - }, - "required": ["_tag", "sessionID", "message"], - "additionalProperties": false - }, - "V2SessionsResponse": { - "type": "object", - "properties": { - "items": { - "type": "array", - "items": { - "$ref": "#/components/schemas/SessionInfo" - } + "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 }, - "cursor": { + "permission": { + "$ref": "#/components/schemas/PermissionRuleset" + }, + "revert": { "type": "object", "properties": { - "previous": { + "messageID": { + "type": "string", + "pattern": "^msg" + }, + "partID": { + "type": "string", + "pattern": "^prt" + }, + "snapshot": { "type": "string" }, - "next": { + "diff": { "type": "string" } }, + "required": ["messageID"], "additionalProperties": false } }, - "required": ["items", "cursor"], - "additionalProperties": false - }, - "InvalidCursorError": { - "type": "object", - "properties": { - "_tag": { - "type": "string", - "enum": ["InvalidCursorError"] - }, - "message": { - "type": "string" - } - }, - "required": ["_tag", "message"], + "required": ["id", "slug", "projectID", "directory", "title", "version", "time"], "additionalProperties": false }, - "UnauthorizedError": { + "Session2": { "type": "object", "properties": { - "_tag": { + "id": { "type": "string", - "enum": ["UnauthorizedError"] + "pattern": "^ses" }, - "message": { + "slug": { "type": "string" - } - }, - "required": ["_tag", "message"], - "additionalProperties": false - }, - "SessionNotFoundError": { - "type": "object", - "properties": { - "_tag": { - "type": "string", - "enum": ["SessionNotFoundError"] }, - "sessionID": { + "projectID": { "type": "string" }, - "message": { - "type": "string" - } - }, - "required": ["_tag", "sessionID", "message"], - "additionalProperties": false - }, - "ServiceUnavailableError": { - "type": "object", - "properties": { - "_tag": { + "workspaceID": { "type": "string", - "enum": ["ServiceUnavailableError"] + "pattern": "^wrk" }, - "message": { + "directory": { "type": "string" }, - "service": { + "path": { "type": "string" - } - }, - "required": ["_tag", "message"], - "additionalProperties": false - }, - "UnknownError1": { - "type": "object", - "properties": { - "_tag": { + }, + "parentID": { "type": "string", - "enum": ["UnknownError"] + "pattern": "^ses" }, - "message": { - "type": "string" + "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 }, - "ref": { - "type": "string" - } - }, - "required": ["_tag", "message"], - "additionalProperties": false - }, - "V2SessionMessagesResponse": { - "type": "object", - "properties": { - "items": { - "type": "array", - "items": { - "$ref": "#/components/schemas/SessionMessage" - } + "cost": { + "type": "number" }, - "cursor": { + "tokens": { "type": "object", "properties": { - "previous": { - "type": "string" + "input": { + "type": "number" }, - "next": { + "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 - } - }, - "required": ["items", "cursor"], - "additionalProperties": false - }, - "ProviderNotFoundError": { - "type": "object", - "properties": { - "_tag": { - "type": "string", - "enum": ["ProviderNotFoundError"] }, - "providerID": { + "title": { "type": "string" }, - "message": { + "agent": { "type": "string" - } - }, - "required": ["_tag", "providerID", "message"], - "additionalProperties": false - }, - "EventTuiPromptAppend": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["tui.prompt.append"] }, - "properties": { + "model": { "type": "object", "properties": { - "text": { + "id": { + "type": "string" + }, + "providerID": { + "type": "string" + }, + "variant": { "type": "string" } }, - "required": ["text"], + "required": ["id", "providerID"], "additionalProperties": false - } - }, - "required": ["type", "properties"], - "additionalProperties": false - }, - "EventTuiCommandExecute": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["tui.command.execute"] }, - "properties": { + "version": { + "type": "string" + }, + "metadata": { + "type": "object" + }, + "time": { "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" - } - ] + "created": { + "type": "integer", + "minimum": 0 + }, + "updated": { + "type": "integer", + "minimum": 0 + }, + "compacting": { + "type": "integer", + "minimum": 0 + }, + "archived": { + "type": "number" } }, - "required": ["command"], + "required": ["created", "updated"], "additionalProperties": false - } - }, - "required": ["type", "properties"], - "additionalProperties": false - }, - "EventTuiToastShow": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["tui.toast.show"] }, - "properties": { + "permission": { + "$ref": "#/components/schemas/PermissionRuleset" + }, + "revert": { "type": "object", "properties": { - "title": { - "type": "string" - }, - "message": { - "type": "string" + "messageID": { + "type": "string", + "pattern": "^msg" }, - "variant": { + "partID": { "type": "string", - "enum": ["info", "success", "warning", "error"] + "pattern": "^prt" }, - "duration": { - "type": "integer", - "exclusiveMinimum": 0 + "snapshot": { + "type": "string" + }, + "diff": { + "type": "string" } }, - "required": ["message", "variant"], + "required": ["messageID"], "additionalProperties": false } }, - "required": ["type", "properties"], + "required": ["id", "slug", "projectID", "directory", "title", "version", "time"], "additionalProperties": false }, - "EventTuiSessionSelect": { + "NotFoundError": { "type": "object", + "required": ["name", "data"], "properties": { - "type": { + "name": { "type": "string", - "enum": ["tui.session.select"] + "enum": ["NotFoundError"] }, - "properties": { + "data": { "type": "object", + "required": ["message"], "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses", - "description": "Session ID to navigate to" + "message": { + "type": "string" } - }, - "required": ["sessionID"], - "additionalProperties": false + } } - }, - "required": ["type", "properties"], - "additionalProperties": false + } }, - "Workspace": { + "Session3": { "type": "object", "properties": { "id": { "type": "string", - "pattern": "^wrk" + "pattern": "^ses" }, - "type": { + "slug": { "type": "string" }, - "name": { + "projectID": { "type": "string" }, - "branch": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] + "workspaceID": { + "type": "string", + "pattern": "^wrk" }, "directory": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "extra": { - "anyOf": [ - {}, - { - "type": "null" - } - ] + "type": "string" }, - "projectID": { + "path": { "type": "string" }, - "timeUsed": { - "anyOf": [ - { + "parentID": { + "type": "string", + "pattern": "^ses" + }, + "summary": { + "type": "object", + "properties": { + "additions": { "type": "number" }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] + "deletions": { + "type": "number" }, - { - "type": "string", - "enum": ["-Infinity"] + "files": { + "type": "number" }, - { - "type": "string", - "enum": ["Infinity", "-Infinity", "NaN"] + "diffs": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SnapshotFileDiff" + } } - ] - } - }, - "required": ["id", "type", "name", "projectID", "timeUsed"], - "additionalProperties": false - }, - "WorkspaceCreateError": { - "type": "object", - "properties": { - "name": { - "type": "string", - "enum": ["WorkspaceCreateError"] + }, + "required": ["additions", "deletions", "files"], + "additionalProperties": false }, - "data": { + "cost": { + "type": "number" + }, + "tokens": { "type": "object", "properties": { - "message": { - "type": "string" + "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": ["message"], + "required": ["input", "output", "reasoning", "cache"], "additionalProperties": false - } - }, - "required": ["name", "data"], - "additionalProperties": false - }, - "WorkspaceWarpError": { - "type": "object", - "properties": { - "name": { - "type": "string", - "enum": ["WorkspaceWarpError"] }, - "data": { + "share": { "type": "object", "properties": { - "message": { + "url": { "type": "string" } }, - "required": ["message"], + "required": ["url"], "additionalProperties": false - } - }, - "required": ["name", "data"], - "additionalProperties": false - }, - "effect_HttpApiError_Forbidden": { - "type": "object", - "properties": { - "_tag": { - "type": "string", - "enum": ["Forbidden"] - } - }, - "required": ["_tag"], - "additionalProperties": false - }, - "SyncEventMessageUpdated": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["sync"] }, - "name": { - "type": "string", - "enum": ["message.updated.1"] - }, - "id": { + "title": { "type": "string" }, - "seq": { - "type": "number" - }, - "aggregateID": { - "type": "string", - "enum": ["sessionID"] + "agent": { + "type": "string" }, - "data": { + "model": { "type": "object", "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses" + "id": { + "type": "string" }, - "info": { - "$ref": "#/components/schemas/Message" + "providerID": { + "type": "string" + }, + "variant": { + "type": "string" } }, - "required": ["sessionID", "info"], + "required": ["id", "providerID"], "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": { + "version": { "type": "string" }, - "seq": { - "type": "number" - }, - "aggregateID": { - "type": "string", - "enum": ["sessionID"] + "metadata": { + "type": "object" }, - "data": { + "time": { "type": "object", "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses" + "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": ["sessionID", "messageID"], + "required": ["messageID"], "additionalProperties": false } }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "required": ["id", "slug", "projectID", "directory", "title", "version", "time"], "additionalProperties": false }, - "SyncEventMessagePartUpdated": { + "Session4": { "type": "object", "properties": { - "type": { + "id": { "type": "string", - "enum": ["sync"] + "pattern": "^ses" }, - "name": { + "slug": { + "type": "string" + }, + "projectID": { + "type": "string" + }, + "workspaceID": { "type": "string", - "enum": ["message.part.updated.1"] + "pattern": "^wrk" }, - "id": { + "directory": { "type": "string" }, - "seq": { - "type": "number" + "path": { + "type": "string" }, - "aggregateID": { + "parentID": { "type": "string", - "enum": ["sessionID"] + "pattern": "^ses" }, - "data": { + "summary": { "type": "object", "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses" + "additions": { + "type": "number" }, - "part": { - "$ref": "#/components/schemas/Part" + "deletions": { + "type": "number" }, - "time": { - "type": "integer", - "minimum": 0 + "files": { + "type": "number" + }, + "diffs": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SnapshotFileDiff" + } } }, - "required": ["sessionID", "part", "time"], + "required": ["additions", "deletions", "files"], "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": { + "cost": { "type": "number" }, - "aggregateID": { - "type": "string", - "enum": ["sessionID"] - }, - "data": { + "tokens": { "type": "object", "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses" + "input": { + "type": "number" }, - "messageID": { - "type": "string", - "pattern": "^msg" + "output": { + "type": "number" }, - "partID": { - "type": "string", - "pattern": "^prt" + "reasoning": { + "type": "number" + }, + "cache": { + "type": "object", + "properties": { + "read": { + "type": "number" + }, + "write": { + "type": "number" + } + }, + "required": ["read", "write"], + "additionalProperties": false } }, - "required": ["sessionID", "messageID", "partID"], + "required": ["input", "output", "reasoning", "cache"], "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"] + "share": { + "type": "object", + "properties": { + "url": { + "type": "string" + } + }, + "required": ["url"], + "additionalProperties": false }, - "id": { + "title": { "type": "string" }, - "seq": { - "type": "number" - }, - "aggregateID": { - "type": "string", - "enum": ["sessionID"] + "agent": { + "type": "string" }, - "data": { + "model": { "type": "object", "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses" + "id": { + "type": "string" }, - "info": { - "$ref": "#/components/schemas/Session" + "providerID": { + "type": "string" + }, + "variant": { + "type": "string" } }, - "required": ["sessionID", "info"], + "required": ["id", "providerID"], "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": { + "version": { "type": "string" }, - "seq": { - "type": "number" + "metadata": { + "type": "object" }, - "aggregateID": { - "type": "string", - "enum": ["sessionID"] + "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 }, - "data": { + "permission": { + "$ref": "#/components/schemas/PermissionRuleset" + }, + "revert": { "type": "object", "properties": { - "sessionID": { + "messageID": { "type": "string", - "pattern": "^ses" + "pattern": "^msg" }, - "info": { + "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": { - "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" - } - ] + "read": { + "type": "number" }, - "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 + "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" }, - "permission": { - "anyOf": [ - { - "$ref": "#/components/schemas/PermissionRuleset" - }, - { - "type": "null" - } - ] + "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" }, - "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" - } - ] + "write": { + "type": "number" } }, + "required": ["read", "write"], "additionalProperties": false } }, - "required": ["sessionID", "info"], + "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": { + "id": { + "type": "string", + "pattern": "^prt" + }, + "type": { + "type": "string", + "enum": ["text"] + }, + "text": { + "type": "string" + }, + "synthetic": { + "type": "boolean" + }, + "ignored": { + "type": "boolean" + }, + "time": { + "type": "object", + "properties": { + "start": { + "type": "integer", + "minimum": 0 + }, + "end": { + "type": "integer", + "minimum": 0 + } + }, + "required": ["start"], "additionalProperties": false + }, + "metadata": { + "type": "object" + } + }, + "required": ["type", "text"], + "additionalProperties": false + }, + "FilePartInput": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^prt" + }, + "type": { + "type": "string", + "enum": ["file"] + }, + "mime": { + "type": "string" + }, + "filename": { + "type": "string" + }, + "url": { + "type": "string" + }, + "source": { + "$ref": "#/components/schemas/FilePartSource" } }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "required": ["type", "mime", "url"], "additionalProperties": false }, - "SyncEventSessionDeleted": { + "AgentPartInput": { "type": "object", "properties": { - "type": { + "id": { "type": "string", - "enum": ["sync"] + "pattern": "^prt" }, - "name": { + "type": { "type": "string", - "enum": ["session.deleted.1"] + "enum": ["agent"] }, - "id": { + "name": { "type": "string" }, - "seq": { - "type": "number" - }, - "aggregateID": { - "type": "string", - "enum": ["sessionID"] - }, - "data": { + "source": { "type": "object", "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses" + "value": { + "type": "string" }, - "info": { - "$ref": "#/components/schemas/Session" + "start": { + "type": "integer", + "minimum": 0 + }, + "end": { + "type": "integer", + "minimum": 0 } }, - "required": ["sessionID", "info"], + "required": ["value", "start", "end"], "additionalProperties": false } }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "required": ["type", "name"], "additionalProperties": false }, - "SyncEventSessionNextAgentSwitched": { + "SubtaskPartInput": { "type": "object", "properties": { - "type": { + "id": { "type": "string", - "enum": ["sync"] + "pattern": "^prt" }, - "name": { + "type": { "type": "string", - "enum": ["session.next.agent.switched.1"] + "enum": ["subtask"] }, - "id": { + "prompt": { "type": "string" }, - "seq": { - "type": "number" + "description": { + "type": "string" }, - "aggregateID": { - "type": "string", - "enum": ["sessionID"] + "agent": { + "type": "string" }, - "data": { + "model": { "type": "object", "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses" + "providerID": { + "type": "string" }, - "agent": { + "modelID": { "type": "string" } }, - "required": ["timestamp", "sessionID", "agent"], + "required": ["providerID", "modelID"], "additionalProperties": false + }, + "command": { + "type": "string" } }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "required": ["type", "prompt", "description", "agent"], "additionalProperties": false }, - "SyncEventSessionNextModelSwitched": { + "SessionBusyError": { "type": "object", "properties": { - "type": { + "_tag": { "type": "string", - "enum": ["sync"] + "enum": ["SessionBusyError"] }, - "name": { - "type": "string", - "enum": ["session.next.model.switched.1"] + "sessionID": { + "type": "string" }, + "message": { + "type": "string" + } + }, + "required": ["_tag", "sessionID", "message"], + "additionalProperties": false + }, + "Session8": { + "type": "object", + "properties": { "id": { + "type": "string", + "pattern": "^ses" + }, + "slug": { "type": "string" }, - "seq": { - "type": "number" + "projectID": { + "type": "string" }, - "aggregateID": { + "workspaceID": { "type": "string", - "enum": ["sessionID"] + "pattern": "^wrk" }, - "data": { + "directory": { + "type": "string" + }, + "path": { + "type": "string" + }, + "parentID": { + "type": "string", + "pattern": "^ses" + }, + "summary": { "type": "object", "properties": { - "timestamp": { + "additions": { "type": "number" }, - "sessionID": { - "type": "string", - "pattern": "^ses" + "deletions": { + "type": "number" }, - "model": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "providerID": { - "type": "string" - }, - "variant": { - "type": "string" - } - }, - "required": ["id", "providerID"], - "additionalProperties": false + "files": { + "type": "number" + }, + "diffs": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SnapshotFileDiff" + } } }, - "required": ["timestamp", "sessionID", "model"], + "required": ["additions", "deletions", "files"], "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": { + "cost": { "type": "number" }, - "aggregateID": { - "type": "string", - "enum": ["sessionID"] - }, - "data": { + "tokens": { "type": "object", "properties": { - "timestamp": { + "input": { "type": "number" }, - "sessionID": { - "type": "string", - "pattern": "^ses" + "output": { + "type": "number" }, - "prompt": { - "$ref": "#/components/schemas/Prompt" + "reasoning": { + "type": "number" + }, + "cache": { + "type": "object", + "properties": { + "read": { + "type": "number" + }, + "write": { + "type": "number" + } + }, + "required": ["read", "write"], + "additionalProperties": false } }, - "required": ["timestamp", "sessionID", "prompt"], + "required": ["input", "output", "reasoning", "cache"], "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"] + "share": { + "type": "object", + "properties": { + "url": { + "type": "string" + } + }, + "required": ["url"], + "additionalProperties": false }, - "id": { + "title": { "type": "string" }, - "seq": { - "type": "number" - }, - "aggregateID": { - "type": "string", - "enum": ["sessionID"] + "agent": { + "type": "string" }, - "data": { + "model": { "type": "object", "properties": { - "timestamp": { - "type": "number" + "id": { + "type": "string" }, - "sessionID": { - "type": "string", - "pattern": "^ses" + "providerID": { + "type": "string" }, - "text": { + "variant": { "type": "string" } }, - "required": ["timestamp", "sessionID", "text"], + "required": ["id", "providerID"], "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": { + "version": { "type": "string" }, - "seq": { - "type": "number" + "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 }, - "aggregateID": { - "type": "string", - "enum": ["sessionID"] + "permission": { + "$ref": "#/components/schemas/PermissionRuleset" }, - "data": { + "revert": { "type": "object", "properties": { - "timestamp": { - "type": "number" + "messageID": { + "type": "string", + "pattern": "^msg" }, - "sessionID": { + "partID": { "type": "string", - "pattern": "^ses" + "pattern": "^prt" }, - "callID": { + "snapshot": { "type": "string" }, - "command": { + "diff": { "type": "string" } }, - "required": ["timestamp", "sessionID", "callID", "command"], + "required": ["messageID"], "additionalProperties": false } }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "required": ["id", "slug", "projectID", "directory", "title", "version", "time"], "additionalProperties": false }, - "SyncEventSessionNextShellEnded": { + "Session9": { "type": "object", "properties": { - "type": { + "id": { "type": "string", - "enum": ["sync"] + "pattern": "^ses" }, - "name": { + "slug": { + "type": "string" + }, + "projectID": { + "type": "string" + }, + "workspaceID": { "type": "string", - "enum": ["session.next.shell.ended.1"] + "pattern": "^wrk" }, - "id": { + "directory": { "type": "string" }, - "seq": { - "type": "number" + "path": { + "type": "string" }, - "aggregateID": { + "parentID": { "type": "string", - "enum": ["sessionID"] + "pattern": "^ses" }, - "data": { + "summary": { "type": "object", "properties": { - "timestamp": { + "additions": { "type": "number" }, - "sessionID": { - "type": "string", - "pattern": "^ses" + "deletions": { + "type": "number" }, - "callID": { - "type": "string" + "files": { + "type": "number" }, - "output": { - "type": "string" + "diffs": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SnapshotFileDiff" + } } }, - "required": ["timestamp", "sessionID", "callID", "output"], + "required": ["additions", "deletions", "files"], "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": { + "cost": { "type": "number" }, - "aggregateID": { - "type": "string", - "enum": ["sessionID"] - }, - "data": { + "tokens": { "type": "object", "properties": { - "timestamp": { + "input": { "type": "number" }, - "sessionID": { - "type": "string", - "pattern": "^ses" + "output": { + "type": "number" }, - "agent": { - "type": "string" + "reasoning": { + "type": "number" }, - "model": { + "cache": { "type": "object", "properties": { - "id": { - "type": "string" - }, - "providerID": { - "type": "string" + "read": { + "type": "number" }, - "variant": { - "type": "string" + "write": { + "type": "number" } }, - "required": ["id", "providerID"], + "required": ["read", "write"], "additionalProperties": false - }, - "snapshot": { - "type": "string" } }, - "required": ["timestamp", "sessionID", "agent", "model"], + "required": ["input", "output", "reasoning", "cache"], "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"] + "share": { + "type": "object", + "properties": { + "url": { + "type": "string" + } + }, + "required": ["url"], + "additionalProperties": false }, - "id": { + "title": { "type": "string" }, - "seq": { - "type": "number" - }, - "aggregateID": { - "type": "string", - "enum": ["sessionID"] + "agent": { + "type": "string" }, - "data": { + "model": { "type": "object", "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "finish": { + "id": { "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 + "providerID": { + "type": "string" }, - "snapshot": { + "variant": { "type": "string" } }, - "required": ["timestamp", "sessionID", "finish", "cost", "tokens"], + "required": ["id", "providerID"], "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": { + "version": { "type": "string" }, - "seq": { - "type": "number" - }, - "aggregateID": { - "type": "string", - "enum": ["sessionID"] + "metadata": { + "type": "object" }, - "data": { + "time": { "type": "object", "properties": { - "timestamp": { - "type": "number" + "created": { + "type": "integer", + "minimum": 0 }, - "sessionID": { - "type": "string", - "pattern": "^ses" + "updated": { + "type": "integer", + "minimum": 0 }, - "error": { - "$ref": "#/components/schemas/SessionErrorUnknown" + "compacting": { + "type": "integer", + "minimum": 0 + }, + "archived": { + "type": "number" } }, - "required": ["timestamp", "sessionID", "error"], + "required": ["created", "updated"], "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"] + "permission": { + "$ref": "#/components/schemas/PermissionRuleset" }, - "data": { + "revert": { "type": "object", "properties": { - "timestamp": { - "type": "number" + "messageID": { + "type": "string", + "pattern": "^msg" }, - "sessionID": { + "partID": { "type": "string", - "pattern": "^ses" + "pattern": "^prt" + }, + "snapshot": { + "type": "string" + }, + "diff": { + "type": "string" } }, - "required": ["timestamp", "sessionID"], + "required": ["messageID"], "additionalProperties": false } }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "required": ["id", "slug", "projectID", "directory", "title", "version", "time"], "additionalProperties": false }, - "SyncEventSessionNextTextDelta": { + "V2SessionsResponse": { "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"] + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SessionInfo" + } }, - "data": { + "cursor": { "type": "object", "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses" + "previous": { + "type": "string" }, - "delta": { + "next": { "type": "string" } }, - "required": ["timestamp", "sessionID", "delta"], "additionalProperties": false } }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "required": ["items", "cursor"], "additionalProperties": false }, - "SyncEventSessionNextTextEnded": { + "InvalidCursorError": { "type": "object", "properties": { - "type": { - "type": "string", - "enum": ["sync"] - }, - "name": { + "_tag": { "type": "string", - "enum": ["session.next.text.ended.1"] + "enum": ["InvalidCursorError"] }, - "id": { + "message": { "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"], + "required": ["_tag", "message"], "additionalProperties": false }, - "SyncEventSessionNextReasoningStarted": { + "UnauthorizedError": { "type": "object", "properties": { - "type": { + "_tag": { "type": "string", - "enum": ["sync"] + "enum": ["UnauthorizedError"] }, - "name": { + "message": { + "type": "string" + } + }, + "required": ["_tag", "message"], + "additionalProperties": false + }, + "SessionNotFoundError": { + "type": "object", + "properties": { + "_tag": { "type": "string", - "enum": ["session.next.reasoning.started.1"] + "enum": ["SessionNotFoundError"] }, - "id": { + "sessionID": { "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 + "message": { + "type": "string" } }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "required": ["_tag", "sessionID", "message"], "additionalProperties": false }, - "SyncEventSessionNextReasoningDelta": { + "ServiceUnavailableError": { "type": "object", "properties": { - "type": { + "_tag": { "type": "string", - "enum": ["sync"] + "enum": ["ServiceUnavailableError"] }, - "name": { - "type": "string", - "enum": ["session.next.reasoning.delta.1"] + "message": { + "type": "string" }, - "id": { + "service": { "type": "string" + } + }, + "required": ["_tag", "message"], + "additionalProperties": false + }, + "UnknownError1": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": ["UnknownError"] }, - "seq": { - "type": "number" + "message": { + "type": "string" }, - "aggregateID": { - "type": "string", - "enum": ["sessionID"] + "ref": { + "type": "string" + } + }, + "required": ["_tag", "message"], + "additionalProperties": false + }, + "V2SessionMessagesResponse": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SessionMessage" + } }, - "data": { + "cursor": { "type": "object", "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "reasoningID": { + "previous": { "type": "string" }, - "delta": { + "next": { "type": "string" } }, - "required": ["timestamp", "sessionID", "reasoningID", "delta"], "additionalProperties": false } }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "required": ["items", "cursor"], "additionalProperties": false }, - "SyncEventSessionNextReasoningEnded": { + "ProviderNotFoundError": { "type": "object", "properties": { - "type": { - "type": "string", - "enum": ["sync"] - }, - "name": { + "_tag": { "type": "string", - "enum": ["session.next.reasoning.ended.1"] + "enum": ["ProviderNotFoundError"] }, - "id": { + "providerID": { "type": "string" }, - "seq": { - "type": "number" - }, - "aggregateID": { + "message": { + "type": "string" + } + }, + "required": ["_tag", "providerID", "message"], + "additionalProperties": false + }, + "EventTuiPromptAppend": { + "type": "object", + "properties": { + "type": { "type": "string", - "enum": ["sessionID"] + "enum": ["tui.prompt.append"] }, - "data": { + "properties": { "type": "object", "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "reasoningID": { - "type": "string" - }, "text": { "type": "string" } }, - "required": ["timestamp", "sessionID", "reasoningID", "text"], + "required": ["text"], "additionalProperties": false } }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "required": ["type", "properties"], "additionalProperties": false }, - "SyncEventSessionNextToolInputStarted": { + "EventTuiCommandExecute": { "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"] + "enum": ["tui.command.execute"] }, - "data": { + "properties": { "type": "object", "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "callID": { - "type": "string" - }, - "name": { - "type": "string" + "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": ["timestamp", "sessionID", "callID", "name"], + "required": ["command"], "additionalProperties": false } }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "required": ["type", "properties"], "additionalProperties": false }, - "SyncEventSessionNextToolInputDelta": { + "EventTuiToastShow": { "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"] + "enum": ["tui.toast.show"] }, - "data": { + "properties": { "type": "object", "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "callID": { + "title": { "type": "string" }, - "delta": { + "message": { "type": "string" + }, + "variant": { + "type": "string", + "enum": ["info", "success", "warning", "error"] + }, + "duration": { + "type": "integer", + "exclusiveMinimum": 0 } }, - "required": ["timestamp", "sessionID", "callID", "delta"], + "required": ["message", "variant"], "additionalProperties": false } }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "required": ["type", "properties"], "additionalProperties": false }, - "SyncEventSessionNextToolInputEnded": { + "EventTuiSessionSelect": { "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"] + "enum": ["tui.session.select"] }, - "data": { + "properties": { "type": "object", "properties": { - "timestamp": { - "type": "number" - }, "sessionID": { "type": "string", - "pattern": "^ses" - }, - "callID": { - "type": "string" - }, - "text": { - "type": "string" + "pattern": "^ses", + "description": "Session ID to navigate to" } }, - "required": ["timestamp", "sessionID", "callID", "text"], + "required": ["sessionID"], "additionalProperties": false } }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "required": ["type", "properties"], "additionalProperties": false }, - "SyncEventSessionNextToolCalled": { + "Workspace": { "type": "object", "properties": { - "type": { + "id": { "type": "string", - "enum": ["sync"] + "pattern": "^wrk" }, - "name": { - "type": "string", - "enum": ["session.next.tool.called.1"] + "type": { + "type": "string" }, - "id": { + "name": { "type": "string" }, - "seq": { - "type": "number" + "branch": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] }, - "aggregateID": { - "type": "string", - "enum": ["sessionID"] + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] }, - "data": { - "type": "object", - "properties": { - "timestamp": { + "extra": { + "anyOf": [ + {}, + { + "type": "null" + } + ] + }, + "projectID": { + "type": "string" + }, + "timeUsed": { + "anyOf": [ + { "type": "number" }, - "sessionID": { + { "type": "string", - "pattern": "^ses" - }, - "callID": { - "type": "string" + "enum": ["NaN"] }, - "tool": { - "type": "string" + { + "type": "string", + "enum": ["Infinity"] }, - "input": { - "type": "object" + { + "type": "string", + "enum": ["-Infinity"] }, - "provider": { - "type": "object", - "properties": { - "executed": { - "type": "boolean" - }, - "metadata": { - "type": "object" - } - }, - "required": ["executed"], - "additionalProperties": false + { + "type": "string", + "enum": ["Infinity", "-Infinity", "NaN"] } - }, - "required": ["timestamp", "sessionID", "callID", "tool", "input", "provider"], - "additionalProperties": false + ] } }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "required": ["id", "type", "name", "projectID", "timeUsed"], "additionalProperties": false }, - "SyncEventSessionNextToolProgress": { + "WorkspaceCreateError": { "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"] + "enum": ["WorkspaceCreateError"] }, "data": { "type": "object", "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "callID": { + "message": { "type": "string" - }, - "structured": { - "type": "object" - }, - "content": { - "type": "array", - "items": { - "anyOf": [ - { - "$ref": "#/components/schemas/ToolTextContent" - }, - { - "$ref": "#/components/schemas/ToolFileContent" - } - ] - } } }, - "required": ["timestamp", "sessionID", "callID", "structured", "content"], + "required": ["message"], "additionalProperties": false } }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "required": ["name", "data"], "additionalProperties": false }, - "SyncEventSessionNextToolSuccess": { + "WorkspaceWarpError": { "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"] + "enum": ["WorkspaceWarpError"] }, "data": { "type": "object", "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "callID": { + "message": { "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"], + "required": ["message"], "additionalProperties": false } }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "required": ["name", "data"], + "additionalProperties": false + }, + "effect_HttpApiError_Forbidden": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": ["Forbidden"] + } + }, + "required": ["_tag"], "additionalProperties": false }, - "SyncEventSessionNextToolFailed": { + "Event.tui.prompt.append": { "type": "object", "properties": { - "type": { - "type": "string", - "enum": ["sync"] + "id": { + "type": "string" }, - "name": { + "type": { "type": "string", - "enum": ["session.next.tool.failed.1"] + "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" }, - "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" - }, - "error": { - "$ref": "#/components/schemas/SessionErrorUnknown" - }, - "provider": { - "type": "object", - "properties": { - "executed": { - "type": "boolean" + "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" + ] }, - "metadata": { - "type": "object" + { + "type": "string" } - }, - "required": ["executed"], - "additionalProperties": false + ] } }, - "required": ["timestamp", "sessionID", "callID", "error", "provider"], + "required": ["command"], "additionalProperties": false } }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "required": ["id", "type", "properties"], "additionalProperties": false }, - "SyncEventSessionNextRetried": { + "Event.tui.toast.show": { "type": "object", "properties": { - "type": { - "type": "string", - "enum": ["sync"] - }, - "name": { - "type": "string", - "enum": ["session.next.retried.1"] - }, "id": { "type": "string" }, - "seq": { - "type": "number" - }, - "aggregateID": { + "type": { "type": "string", - "enum": ["sessionID"] + "enum": ["tui.toast.show"] }, - "data": { + "properties": { "type": "object", "properties": { - "timestamp": { - "type": "number" + "title": { + "type": "string" }, - "sessionID": { - "type": "string", - "pattern": "^ses" + "message": { + "type": "string" }, - "attempt": { - "type": "number" + "variant": { + "type": "string", + "enum": ["info", "success", "warning", "error"] }, - "error": { - "$ref": "#/components/schemas/SessionNextRetry_error" + "duration": { + "type": "integer", + "exclusiveMinimum": 0 } }, - "required": ["timestamp", "sessionID", "attempt", "error"], + "required": ["message", "variant"], "additionalProperties": false } }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "required": ["id", "type", "properties"], "additionalProperties": false }, - "SyncEventSessionNextCompactionStarted": { + "Event.tui.session.select": { "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": { "type": "string", - "enum": ["sessionID"] + "enum": ["tui.session.select"] }, - "data": { + "properties": { "type": "object", "properties": { - "timestamp": { - "type": "number" - }, "sessionID": { "type": "string", - "pattern": "^ses" - }, - "reason": { - "type": "string", - "enum": ["auto", "manual"] + "pattern": "^ses", + "description": "Session ID to navigate to" } }, - "required": ["timestamp", "sessionID", "reason"], + "required": ["sessionID"], "additionalProperties": false } }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "required": ["id", "type", "properties"], "additionalProperties": false }, - "SyncEventSessionNextCompactionDelta": { + "ModelV2Info": { "type": "object", "properties": { - "type": { - "type": "string", - "enum": ["sync"] + "id": { + "type": "string" }, - "name": { - "type": "string", - "enum": ["session.next.compaction.delta.1"] + "apiID": { + "type": "string" }, - "id": { + "providerID": { "type": "string" }, - "seq": { - "type": "number" + "family": { + "type": "string" }, - "aggregateID": { - "type": "string", - "enum": ["sessionID"] + "name": { + "type": "string" + }, + "endpoint": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["unknown"] + } + }, + "required": ["type"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["openai/responses"] + }, + "url": { + "type": "string" + }, + "websocket": { + "type": "boolean" + } + }, + "required": ["type", "url"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["openai/completions"] + }, + "url": { + "type": "string" + }, + "reasoning": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["reasoning_content"] + } + }, + "required": ["type"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["reasoning_details"] + } + }, + "required": ["type"], + "additionalProperties": false + } + ] + } + }, + "required": ["type", "url"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["anthropic/messages"] + }, + "url": { + "type": "string" + } + }, + "required": ["type", "url"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["aisdk"] + }, + "package": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": ["type", "package"], + "additionalProperties": false + } + ] }, - "data": { + "capabilities": { "type": "object", "properties": { - "timestamp": { - "type": "number" + "tools": { + "type": "boolean" }, - "sessionID": { - "type": "string", - "pattern": "^ses" + "input": { + "type": "array", + "items": { + "type": "string" + } }, - "text": { - "type": "string" + "output": { + "type": "array", + "items": { + "type": "string" + } } }, - "required": ["timestamp", "sessionID", "text"], + "required": ["tools", "input", "output"], "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": { + "options": { "type": "object", "properties": { - "timestamp": { - "type": "number" + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + } }, - "sessionID": { - "type": "string", - "pattern": "^ses" + "body": { + "type": "object" }, - "text": { - "type": "string" + "aisdk": { + "type": "object", + "properties": { + "provider": { + "type": "object" + }, + "request": { + "type": "object" + } + }, + "required": ["provider", "request"], + "additionalProperties": false }, - "include": { + "variant": { "type": "string" } }, - "required": ["timestamp", "sessionID", "text"], + "required": ["headers", "body", "aisdk"], "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"] + "variants": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "body": { + "type": "object" + }, + "aisdk": { + "type": "object", + "properties": { + "provider": { + "type": "object" + }, + "request": { + "type": "object" + } + }, + "required": ["provider", "request"], + "additionalProperties": false + } + }, + "required": ["id", "headers", "body", "aisdk"], + "additionalProperties": false + } }, - "properties": { + "time": { "type": "object", "properties": { - "directory": { - "type": "string" + "released": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + }, + { + "type": "string", + "enum": ["Infinity", "-Infinity", "NaN"] + } + ] } }, - "required": ["directory"], + "required": ["released"], "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" + "cost": { + "type": "array", + "items": { + "type": "object", + "properties": { + "tier": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["context"] + }, + "size": { + "type": "integer" + } + }, + "required": ["type", "size"], + "additionalProperties": false + }, + "input": { + "type": "number" + }, + "output": { + "type": "number" + }, + "cache": { + "type": "object", + "properties": { + "read": { + "type": "number" + }, + "write": { + "type": "number" + } + }, + "required": ["read", "write"], + "additionalProperties": false + } + }, + "required": ["input", "output", "cache"], + "additionalProperties": false + } }, - "type": { + "status": { "type": "string", - "enum": ["file.watcher.updated"] + "enum": ["alpha", "beta", "deprecated", "active"] }, - "properties": { + "enabled": { + "type": "boolean" + }, + "limit": { "type": "object", "properties": { - "file": { - "type": "string" + "context": { + "type": "integer" }, - "event": { - "type": "string", - "enum": ["add", "change", "unlink"] + "input": { + "type": "integer" + }, + "output": { + "type": "integer" } }, - "required": ["file", "event"], + "required": ["context", "output"], "additionalProperties": false } }, - "required": ["id", "type", "properties"], + "required": [ + "id", + "apiID", + "providerID", + "name", + "endpoint", + "capabilities", + "options", + "variants", + "time", + "cost", + "status", + "enabled", + "limit" + ], "additionalProperties": false }, - "EventLspClientDiagnostics": { + "PromptSource": { "type": "object", "properties": { - "id": { - "type": "string" + "start": { + "type": "number" }, - "type": { - "type": "string", - "enum": ["lsp.client.diagnostics"] + "end": { + "type": "number" }, - "properties": { - "type": "object", - "properties": { - "serverID": { - "type": "string" - }, - "path": { - "type": "string" - } - }, - "required": ["serverID", "path"], - "additionalProperties": false + "text": { + "type": "string" } }, - "required": ["id", "type", "properties"], + "required": ["start", "end", "text"], "additionalProperties": false }, - "EventLspUpdated": { + "PromptFileAttachment": { "type": "object", "properties": { - "id": { + "uri": { "type": "string" }, - "type": { - "type": "string", - "enum": ["lsp.updated"] + "mime": { + "type": "string" }, - "properties": { - "type": "object", - "properties": {} - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "EventMessagePartDelta": { - "type": "object", - "properties": { - "id": { + "name": { "type": "string" }, - "type": { - "type": "string", - "enum": ["message.part.delta"] + "description": { + "type": "string" }, - "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 + "source": { + "$ref": "#/components/schemas/PromptSource" } }, - "required": ["id", "type", "properties"], + "required": ["uri", "mime"], "additionalProperties": false }, - "EventPermissionAsked": { + "PromptAgentAttachment": { "type": "object", "properties": { - "id": { + "name": { "type": "string" }, - "type": { - "type": "string", - "enum": ["permission.asked"] - }, - "properties": { - "$ref": "#/components/schemas/PermissionRequest" + "source": { + "$ref": "#/components/schemas/PromptSource" } }, - "required": ["id", "type", "properties"], + "required": ["name"], "additionalProperties": false }, - "EventPermissionReplied": { + "PromptReferenceAttachment": { "type": "object", "properties": { - "id": { + "name": { "type": "string" }, - "type": { + "kind": { "type": "string", - "enum": ["permission.replied"] + "enum": ["local", "git", "invalid"] }, - "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": { + "uri": { "type": "string" }, - "type": { - "type": "string", - "enum": ["session.diff"] + "repository": { + "type": "string" }, - "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": { + "branch": { "type": "string" }, - "type": { - "type": "string", - "enum": ["session.error"] + "target": { + "type": "string" }, - "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 + "targetUri": { + "type": "string" + }, + "problem": { + "type": "string" + }, + "source": { + "$ref": "#/components/schemas/PromptSource" } }, - "required": ["id", "type", "properties"], + "required": ["name", "kind"], "additionalProperties": false }, - "EventQuestionAsked": { + "SessionErrorUnknown": { "type": "object", "properties": { - "id": { - "type": "string" - }, "type": { "type": "string", - "enum": ["question.asked"] + "enum": ["unknown"] }, - "properties": { - "$ref": "#/components/schemas/QuestionRequest" + "message": { + "type": "string" } }, - "required": ["id", "type", "properties"], + "required": ["type", "message"], "additionalProperties": false }, - "EventQuestionReplied": { + "ToolTextContent": { "type": "object", "properties": { - "id": { - "type": "string" - }, "type": { "type": "string", - "enum": ["question.replied"] + "enum": ["text"] }, - "properties": { - "$ref": "#/components/schemas/QuestionReplied" + "text": { + "type": "string" } }, - "required": ["id", "type", "properties"], + "required": ["type", "text"], "additionalProperties": false }, - "EventQuestionRejected": { + "ToolFileContent": { "type": "object", "properties": { - "id": { - "type": "string" - }, "type": { "type": "string", - "enum": ["question.rejected"] + "enum": ["file"] }, - "properties": { - "$ref": "#/components/schemas/QuestionRejected" + "uri": { + "type": "string" + }, + "mime": { + "type": "string" + }, + "name": { + "type": "string" } }, - "required": ["id", "type", "properties"], + "required": ["type", "uri", "mime"], "additionalProperties": false }, - "EventTodoUpdated": { + "SessionNextRetry_error": { "type": "object", "properties": { - "id": { + "message": { "type": "string" }, - "type": { - "type": "string", - "enum": ["todo.updated"] + "statusCode": { + "type": "number" }, - "properties": { + "isRetryable": { + "type": "boolean" + }, + "responseHeaders": { "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "todos": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Todo" - } - } - }, - "required": ["sessionID", "todos"], - "additionalProperties": false + "additionalProperties": { + "type": "string" + } + }, + "responseBody": { + "type": "string" + }, + "metadata": { + "type": "object", + "additionalProperties": { + "type": "string" + } } }, - "required": ["id", "type", "properties"], + "required": ["message", "isRetryable"], "additionalProperties": false }, - "EventSessionStatus": { + "PermissionV2Action": { + "type": "string", + "enum": ["allow", "deny", "ask"] + }, + "PermissionV2Rule": { "type": "object", "properties": { - "id": { + "permission": { "type": "string" }, - "type": { - "type": "string", - "enum": ["session.status"] + "pattern": { + "type": "string" }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "status": { - "$ref": "#/components/schemas/SessionStatus" - } - }, - "required": ["sessionID", "status"], - "additionalProperties": false + "action": { + "$ref": "#/components/schemas/PermissionV2Action" } }, - "required": ["id", "type", "properties"], + "required": ["permission", "pattern", "action"], "additionalProperties": false }, - "EventSessionIdle": { + "PermissionV2Ruleset": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PermissionV2Rule" + } + }, + "PolicyEffect": { + "type": "string", + "enum": ["allow", "deny"] + }, + "ConfigV2ExperimentalPolicy": { "type": "object", "properties": { - "id": { - "type": "string" - }, - "type": { + "action": { "type": "string", - "enum": ["session.idle"] + "enum": ["provider.use"] }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses" - } - }, - "required": ["sessionID"], - "additionalProperties": false + "effect": { + "$ref": "#/components/schemas/PolicyEffect" + }, + "resource": { + "type": "string" } }, - "required": ["id", "type", "properties"], + "required": ["action", "effect", "resource"], "additionalProperties": false }, - "EventMcpToolsChanged": { + "SessionInfo": { "type": "object", "properties": { "id": { + "type": "string", + "pattern": "^ses" + }, + "parentID": { + "type": "string", + "pattern": "^ses" + }, + "projectID": { "type": "string" }, - "type": { + "workspaceID": { "type": "string", - "enum": ["mcp.tools.changed"] + "pattern": "^wrk" }, - "properties": { + "path": { + "type": "string" + }, + "agent": { + "type": "string" + }, + "model": { "type": "object", "properties": { - "server": { + "id": { + "type": "string" + }, + "providerID": { + "type": "string" + }, + "variant": { "type": "string" } }, - "required": ["server"], + "required": ["id", "providerID"], + "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 + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "number" + }, + "updated": { + "type": "number" + }, + "archived": { + "type": "number" + } + }, + "required": ["created", "updated"], "additionalProperties": false + }, + "title": { + "type": "string" } }, - "required": ["id", "type", "properties"], + "required": ["id", "projectID", "cost", "tokens", "time", "title"], "additionalProperties": false }, - "EventMcpBrowserOpenFailed": { + "SessionDelivery": { + "type": "string", + "enum": ["immediate", "deferred"] + }, + "SessionMessageAgentSwitched": { "type": "object", "properties": { "id": { "type": "string" }, - "type": { - "type": "string", - "enum": ["mcp.browser.open.failed"] + "metadata": { + "type": "object" }, - "properties": { + "time": { "type": "object", "properties": { - "mcpName": { - "type": "string" - }, - "url": { - "type": "string" + "created": { + "type": "number" } }, - "required": ["mcpName", "url"], + "required": ["created"], "additionalProperties": false + }, + "type": { + "type": "string", + "enum": ["agent-switched"] + }, + "agent": { + "type": "string" } }, - "required": ["id", "type", "properties"], + "required": ["id", "time", "type", "agent"], "additionalProperties": false }, - "EventCommandExecuted": { + "SessionMessageModelSwitched": { "type": "object", "properties": { "id": { "type": "string" }, + "metadata": { + "type": "object" + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "number" + } + }, + "required": ["created"], + "additionalProperties": false + }, "type": { "type": "string", - "enum": ["command.executed"] + "enum": ["model-switched"] }, - "properties": { + "model": { "type": "object", "properties": { - "name": { + "id": { "type": "string" }, - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "arguments": { + "providerID": { "type": "string" }, - "messageID": { - "type": "string", - "pattern": "^msg" + "variant": { + "type": "string" } }, - "required": ["name", "sessionID", "arguments", "messageID"], + "required": ["id", "providerID"], "additionalProperties": false } }, - "required": ["id", "type", "properties"], + "required": ["id", "time", "type", "model"], "additionalProperties": false }, - "EventProjectUpdated": { + "SessionMessageUser": { "type": "object", "properties": { "id": { "type": "string" }, + "metadata": { + "type": "object" + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "number" + } + }, + "required": ["created"], + "additionalProperties": false + }, + "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" + } + }, "type": { "type": "string", - "enum": ["project.updated"] - }, - "properties": { - "$ref": "#/components/schemas/Project" + "enum": ["user"] } }, - "required": ["id", "type", "properties"], + "required": ["id", "time", "text", "type"], "additionalProperties": false }, - "EventSessionCompacted": { + "SessionMessageSynthetic": { "type": "object", "properties": { "id": { "type": "string" }, - "type": { - "type": "string", - "enum": ["session.compacted"] + "metadata": { + "type": "object" }, - "properties": { + "time": { "type": "object", "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses" + "created": { + "type": "number" } }, - "required": ["sessionID"], + "required": ["created"], "additionalProperties": false + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "text": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["synthetic"] } }, - "required": ["id", "type", "properties"], + "required": ["id", "time", "sessionID", "text", "type"], "additionalProperties": false }, - "EventVcsBranchUpdated": { + "SessionMessageShell": { "type": "object", "properties": { "id": { "type": "string" }, - "type": { - "type": "string", - "enum": ["vcs.branch.updated"] + "metadata": { + "type": "object" }, - "properties": { + "time": { "type": "object", "properties": { - "branch": { - "type": "string" + "created": { + "type": "number" + }, + "completed": { + "type": "number" } }, + "required": ["created"], "additionalProperties": false + }, + "type": { + "type": "string", + "enum": ["shell"] + }, + "callID": { + "type": "string" + }, + "command": { + "type": "string" + }, + "output": { + "type": "string" } }, - "required": ["id", "type", "properties"], + "required": ["id", "time", "type", "callID", "command", "output"], "additionalProperties": false }, - "EventWorkspaceReady": { + "SessionMessageAssistantText": { "type": "object", "properties": { - "id": { - "type": "string" - }, "type": { "type": "string", - "enum": ["workspace.ready"] + "enum": ["text"] }, - "properties": { - "type": "object", - "properties": { - "name": { - "type": "string" - } - }, - "required": ["name"], - "additionalProperties": false + "text": { + "type": "string" } }, - "required": ["id", "type", "properties"], + "required": ["type", "text"], "additionalProperties": false }, - "EventWorkspaceFailed": { + "SessionMessageAssistantReasoning": { "type": "object", "properties": { - "id": { - "type": "string" - }, "type": { "type": "string", - "enum": ["workspace.failed"] + "enum": ["reasoning"] }, - "properties": { - "type": "object", - "properties": { - "message": { - "type": "string" - } - }, - "required": ["message"], - "additionalProperties": false + "id": { + "type": "string" + }, + "text": { + "type": "string" } }, - "required": ["id", "type", "properties"], + "required": ["type", "id", "text"], "additionalProperties": false }, - "EventWorkspaceStatus": { + "SessionMessageToolStatePending": { "type": "object", "properties": { - "id": { - "type": "string" - }, - "type": { + "status": { "type": "string", - "enum": ["workspace.status"] + "enum": ["pending"] }, - "properties": { - "type": "object", - "properties": { - "workspaceID": { - "type": "string", - "pattern": "^wrk" - }, - "status": { - "type": "string", - "enum": ["connected", "connecting", "disconnected", "error"] - } - }, - "required": ["workspaceID", "status"], - "additionalProperties": false + "input": { + "type": "string" } }, - "required": ["id", "type", "properties"], + "required": ["status", "input"], "additionalProperties": false }, - "EventWorktreeReady": { + "SessionMessageToolStateRunning": { "type": "object", "properties": { - "id": { - "type": "string" - }, - "type": { + "status": { "type": "string", - "enum": ["worktree.ready"] + "enum": ["running"] }, - "properties": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "branch": { - "type": "string" - } - }, - "required": ["name"], - "additionalProperties": false + "input": { + "type": "object" + }, + "structured": { + "type": "object" + }, + "content": { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/components/schemas/ToolTextContent" + }, + { + "$ref": "#/components/schemas/ToolFileContent" + } + ] + } } }, - "required": ["id", "type", "properties"], + "required": ["status", "input", "structured", "content"], "additionalProperties": false }, - "EventWorktreeFailed": { + "SessionMessageToolStateCompleted": { "type": "object", "properties": { - "id": { - "type": "string" - }, - "type": { + "status": { "type": "string", - "enum": ["worktree.failed"] + "enum": ["completed"] }, - "properties": { - "type": "object", - "properties": { - "message": { - "type": "string" - } - }, - "required": ["message"], - "additionalProperties": false + "input": { + "type": "object" + }, + "attachments": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PromptFileAttachment" + } + }, + "content": { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/components/schemas/ToolTextContent" + }, + { + "$ref": "#/components/schemas/ToolFileContent" + } + ] + } + }, + "structured": { + "type": "object" } }, - "required": ["id", "type", "properties"], + "required": ["status", "input", "content", "structured"], "additionalProperties": false }, - "EventPtyCreated": { + "SessionMessageToolStateError": { "type": "object", "properties": { - "id": { - "type": "string" - }, - "type": { + "status": { "type": "string", - "enum": ["pty.created"] + "enum": ["error"] }, - "properties": { - "type": "object", - "properties": { - "info": { - "$ref": "#/components/schemas/Pty" - } - }, - "required": ["info"], - "additionalProperties": false + "input": { + "type": "object" + }, + "content": { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/components/schemas/ToolTextContent" + }, + { + "$ref": "#/components/schemas/ToolFileContent" + } + ] + } + }, + "structured": { + "type": "object" + }, + "error": { + "$ref": "#/components/schemas/SessionErrorUnknown" } }, - "required": ["id", "type", "properties"], + "required": ["status", "input", "content", "structured", "error"], "additionalProperties": false }, - "EventPtyUpdated": { + "SessionMessageAssistantTool": { "type": "object", "properties": { + "type": { + "type": "string", + "enum": ["tool"] + }, "id": { "type": "string" }, - "type": { - "type": "string", - "enum": ["pty.updated"] + "name": { + "type": "string" }, - "properties": { + "provider": { "type": "object", "properties": { - "info": { - "$ref": "#/components/schemas/Pty" + "executed": { + "type": "boolean" + }, + "metadata": { + "type": "object" } }, - "required": ["info"], + "required": ["executed"], "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "EventPtyExited": { - "type": "object", - "properties": { - "id": { - "type": "string" }, - "type": { - "type": "string", - "enum": ["pty.exited"] + "state": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionMessageToolStatePending" + }, + { + "$ref": "#/components/schemas/SessionMessageToolStateRunning" + }, + { + "$ref": "#/components/schemas/SessionMessageToolStateCompleted" + }, + { + "$ref": "#/components/schemas/SessionMessageToolStateError" + } + ] }, - "properties": { + "time": { "type": "object", "properties": { - "id": { - "type": "string", - "pattern": "^pty" + "created": { + "type": "number" }, - "exitCode": { - "type": "integer", - "minimum": 0 + "ran": { + "type": "number" + }, + "completed": { + "type": "number" + }, + "pruned": { + "type": "number" } }, - "required": ["id", "exitCode"], + "required": ["created"], "additionalProperties": false } }, - "required": ["id", "type", "properties"], + "required": ["type", "id", "name", "state", "time"], "additionalProperties": false }, - "EventPtyDeleted": { + "SessionMessageAssistant": { "type": "object", "properties": { "id": { "type": "string" }, - "type": { - "type": "string", - "enum": ["pty.deleted"] + "metadata": { + "type": "object" }, - "properties": { + "time": { "type": "object", "properties": { - "id": { - "type": "string", - "pattern": "^pty" + "created": { + "type": "number" + }, + "completed": { + "type": "number" } }, - "required": ["id"], + "required": ["created"], "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "EventInstallationUpdated": { - "type": "object", - "properties": { - "id": { - "type": "string" }, "type": { "type": "string", - "enum": ["installation.updated"] + "enum": ["assistant"] }, - "properties": { + "agent": { + "type": "string" + }, + "model": { "type": "object", "properties": { - "version": { + "id": { + "type": "string" + }, + "providerID": { + "type": "string" + }, + "variant": { "type": "string" } }, - "required": ["version"], + "required": ["id", "providerID"], "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "EventInstallationUpdate-available": { - "type": "object", - "properties": { - "id": { - "type": "string" }, - "type": { - "type": "string", - "enum": ["installation.update-available"] + "content": { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionMessageAssistantText" + }, + { + "$ref": "#/components/schemas/SessionMessageAssistantReasoning" + }, + { + "$ref": "#/components/schemas/SessionMessageAssistantTool" + } + ] + } }, - "properties": { + "snapshot": { "type": "object", "properties": { - "version": { + "start": { + "type": "string" + }, + "end": { "type": "string" } }, - "required": ["version"], "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "EventMessageUpdated": { - "type": "object", - "properties": { - "id": { + }, + "finish": { "type": "string" }, - "type": { - "type": "string", - "enum": ["message.updated"] + "cost": { + "type": "number" }, - "properties": { + "tokens": { "type": "object", "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses" + "input": { + "type": "number" }, - "info": { - "$ref": "#/components/schemas/Message" + "output": { + "type": "number" + }, + "reasoning": { + "type": "number" + }, + "cache": { + "type": "object", + "properties": { + "read": { + "type": "number" + }, + "write": { + "type": "number" + } + }, + "required": ["read", "write"], + "additionalProperties": false } }, - "required": ["sessionID", "info"], + "required": ["input", "output", "reasoning", "cache"], "additionalProperties": false + }, + "error": { + "$ref": "#/components/schemas/SessionErrorUnknown" } }, - "required": ["id", "type", "properties"], + "required": ["id", "time", "type", "agent", "model", "content"], "additionalProperties": false }, - "EventMessageRemoved": { + "SessionMessageCompaction": { "type": "object", "properties": { + "type": { + "type": "string", + "enum": ["compaction"] + }, + "reason": { + "type": "string", + "enum": ["auto", "manual"] + }, + "summary": { + "type": "string" + }, + "include": { + "type": "string" + }, "id": { "type": "string" }, - "type": { - "type": "string", - "enum": ["message.removed"] + "metadata": { + "type": "object" }, - "properties": { + "time": { "type": "object", "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "messageID": { - "type": "string", - "pattern": "^msg" + "created": { + "type": "number" } }, - "required": ["sessionID", "messageID"], + "required": ["created"], "additionalProperties": false } }, - "required": ["id", "type", "properties"], + "required": ["type", "reason", "summary", "id", "time"], "additionalProperties": false }, - "EventMessagePartUpdated": { + "SessionMessage": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionMessageAgentSwitched" + }, + { + "$ref": "#/components/schemas/SessionMessageModelSwitched" + }, + { + "$ref": "#/components/schemas/SessionMessageUser" + }, + { + "$ref": "#/components/schemas/SessionMessageSynthetic" + }, + { + "$ref": "#/components/schemas/SessionMessageShell" + }, + { + "$ref": "#/components/schemas/SessionMessageAssistant" + }, + { + "$ref": "#/components/schemas/SessionMessageCompaction" + } + ] + }, + "ProviderV2Info": { "type": "object", "properties": { "id": { "type": "string" }, - "type": { - "type": "string", - "enum": ["message.part.updated"] + "name": { + "type": "string" + }, + "enabled": { + "anyOf": [ + { + "type": "boolean", + "enum": [false] + }, + { + "type": "object", + "properties": { + "via": { + "type": "string", + "enum": ["env"] + }, + "name": { + "type": "string" + } + }, + "required": ["via", "name"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "via": { + "type": "string", + "enum": ["account"] + }, + "service": { + "type": "string" + } + }, + "required": ["via", "service"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "via": { + "type": "string", + "enum": ["custom"] + }, + "data": { + "type": "object" + } + }, + "required": ["via", "data"], + "additionalProperties": false + } + ] + }, + "env": { + "type": "array", + "items": { + "type": "string" + } + }, + "endpoint": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["unknown"] + } + }, + "required": ["type"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["openai/responses"] + }, + "url": { + "type": "string" + }, + "websocket": { + "type": "boolean" + } + }, + "required": ["type", "url"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["openai/completions"] + }, + "url": { + "type": "string" + }, + "reasoning": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["reasoning_content"] + } + }, + "required": ["type"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["reasoning_details"] + } + }, + "required": ["type"], + "additionalProperties": false + } + ] + } + }, + "required": ["type", "url"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["anthropic/messages"] + }, + "url": { + "type": "string" + } + }, + "required": ["type", "url"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["aisdk"] + }, + "package": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": ["type", "package"], + "additionalProperties": false + } + ] }, - "properties": { + "options": { "type": "object", "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses" + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + } }, - "part": { - "$ref": "#/components/schemas/Part" + "body": { + "type": "object" }, - "time": { - "type": "integer", - "minimum": 0 + "aisdk": { + "type": "object", + "properties": { + "provider": { + "type": "object" + }, + "request": { + "type": "object" + } + }, + "required": ["provider", "request"], + "additionalProperties": false } }, - "required": ["sessionID", "part", "time"], + "required": ["headers", "body", "aisdk"], "additionalProperties": false } }, - "required": ["id", "type", "properties"], + "required": ["id", "name", "enabled", "env", "endpoint", "options"], "additionalProperties": false }, - "EventMessagePartRemoved": { + "EventModels-devRefreshed": { "type": "object", "properties": { "id": { @@ -19168,32 +21116,17 @@ }, "type": { "type": "string", - "enum": ["message.part.removed"] + "enum": ["models-dev.refreshed"] }, "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 + "properties": {} } }, "required": ["id", "type", "properties"], "additionalProperties": false }, - "EventSessionCreated": { + "EventPluginAdded": { "type": "object", "properties": { "id": { @@ -19201,275 +21134,343 @@ }, "type": { "type": "string", - "enum": ["session.created"] + "enum": ["plugin.added"] }, "properties": { "type": "object", "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "info": { - "$ref": "#/components/schemas/Session" + "id": { + "type": "string" } }, - "required": ["sessionID", "info"], + "required": ["id"], "additionalProperties": false } }, "required": ["id", "type", "properties"], "additionalProperties": false }, - "EventSessionUpdated": { + "ModelV2Info1": { "type": "object", "properties": { "id": { "type": "string" }, - "type": { - "type": "string", - "enum": ["session.updated"] + "apiID": { + "type": "string" }, - "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": { + "providerID": { "type": "string" }, - "type": { - "type": "string", - "enum": ["session.deleted"] + "family": { + "type": "string" }, - "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": { + "name": { "type": "string" }, - "type": { - "type": "string", - "enum": ["session.next.agent.switched"] + "endpoint": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["unknown"] + } + }, + "required": ["type"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["openai/responses"] + }, + "url": { + "type": "string" + }, + "websocket": { + "type": "boolean" + } + }, + "required": ["type", "url"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["openai/completions"] + }, + "url": { + "type": "string" + }, + "reasoning": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["reasoning_content"] + } + }, + "required": ["type"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["reasoning_details"] + } + }, + "required": ["type"], + "additionalProperties": false + } + ] + } + }, + "required": ["type", "url"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["anthropic/messages"] + }, + "url": { + "type": "string" + } + }, + "required": ["type", "url"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["aisdk"] + }, + "package": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": ["type", "package"], + "additionalProperties": false + } + ] }, - "properties": { + "capabilities": { "type": "object", "properties": { - "timestamp": { - "type": "number" + "tools": { + "type": "boolean" }, - "sessionID": { - "type": "string", - "pattern": "^ses" + "input": { + "type": "array", + "items": { + "type": "string" + } }, - "agent": { - "type": "string" + "output": { + "type": "array", + "items": { + "type": "string" + } } }, - "required": ["timestamp", "sessionID", "agent"], + "required": ["tools", "input", "output"], "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "EventSessionNextModelSwitched": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["session.next.model.switched"] }, - "properties": { + "options": { "type": "object", "properties": { - "timestamp": { - "type": "number" + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + } }, - "sessionID": { - "type": "string", - "pattern": "^ses" + "body": { + "type": "object" }, - "model": { + "aisdk": { "type": "object", "properties": { - "id": { - "type": "string" - }, - "providerID": { - "type": "string" + "provider": { + "type": "object" }, - "variant": { - "type": "string" + "request": { + "type": "object" } }, - "required": ["id", "providerID"], + "required": ["provider", "request"], "additionalProperties": false + }, + "variant": { + "type": "string" } }, - "required": ["timestamp", "sessionID", "model"], + "required": ["headers", "body", "aisdk"], "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" + "variants": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "body": { + "type": "object" + }, + "aisdk": { + "type": "object", + "properties": { + "provider": { + "type": "object" + }, + "request": { + "type": "object" + } + }, + "required": ["provider", "request"], + "additionalProperties": false + } + }, + "required": ["id", "headers", "body", "aisdk"], + "additionalProperties": false + } }, - "source": { - "$ref": "#/components/schemas/PromptSource" - } - }, - "required": ["uri", "mime"], - "additionalProperties": false - }, - "PromptAgentAttachment": { - "type": "object", - "properties": { - "name": { - "type": "string" + "time": { + "type": "object", + "properties": { + "released": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + } + ] + } + }, + "required": ["released"], + "additionalProperties": false }, - "source": { - "$ref": "#/components/schemas/PromptSource" - } - }, - "required": ["name"], - "additionalProperties": false - }, - "PromptReferenceAttachment": { - "type": "object", - "properties": { - "name": { - "type": "string" + "cost": { + "type": "array", + "items": { + "type": "object", + "properties": { + "tier": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["context"] + }, + "size": { + "type": "integer" + } + }, + "required": ["type", "size"], + "additionalProperties": false + }, + "input": { + "type": "number" + }, + "output": { + "type": "number" + }, + "cache": { + "type": "object", + "properties": { + "read": { + "type": "number" + }, + "write": { + "type": "number" + } + }, + "required": ["read", "write"], + "additionalProperties": false + } + }, + "required": ["input", "output", "cache"], + "additionalProperties": false + } }, - "kind": { + "status": { "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" + "enum": ["alpha", "beta", "deprecated", "active"] }, - "type": { - "type": "string", - "enum": ["session.next.prompted"] + "enabled": { + "type": "boolean" }, - "properties": { + "limit": { "type": "object", "properties": { - "timestamp": { - "type": "number" + "context": { + "type": "integer" }, - "sessionID": { - "type": "string", - "pattern": "^ses" + "input": { + "type": "integer" }, - "prompt": { - "$ref": "#/components/schemas/Prompt" + "output": { + "type": "integer" } }, - "required": ["timestamp", "sessionID", "prompt"], + "required": ["context", "output"], "additionalProperties": false } }, - "required": ["id", "type", "properties"], + "required": [ + "id", + "apiID", + "providerID", + "name", + "endpoint", + "capabilities", + "options", + "variants", + "time", + "cost", + "status", + "enabled", + "limit" + ], "additionalProperties": false }, - "EventSessionNextSynthetic": { + "EventCatalogModelUpdated": { "type": "object", "properties": { "id": { @@ -19477,30 +21478,23 @@ }, "type": { "type": "string", - "enum": ["session.next.synthetic"] + "enum": ["catalog.model.updated"] }, "properties": { "type": "object", "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "text": { - "type": "string" + "model": { + "$ref": "#/components/schemas/ModelV2Info1" } }, - "required": ["timestamp", "sessionID", "text"], + "required": ["model"], "additionalProperties": false } }, "required": ["id", "type", "properties"], "additionalProperties": false }, - "EventSessionNextShellStarted": { + "EventFileEdited": { "type": "object", "properties": { "id": { @@ -19508,33 +21502,23 @@ }, "type": { "type": "string", - "enum": ["session.next.shell.started"] + "enum": ["file.edited"] }, "properties": { "type": "object", "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "callID": { - "type": "string" - }, - "command": { + "file": { "type": "string" } }, - "required": ["timestamp", "sessionID", "callID", "command"], + "required": ["file"], "additionalProperties": false } }, "required": ["id", "type", "properties"], "additionalProperties": false }, - "EventSessionNextShellEnded": { + "EventSessionNextAgentSwitched": { "type": "object", "properties": { "id": { @@ -19542,7 +21526,7 @@ }, "type": { "type": "string", - "enum": ["session.next.shell.ended"] + "enum": ["session.next.agent.switched"] }, "properties": { "type": "object", @@ -19554,21 +21538,18 @@ "type": "string", "pattern": "^ses" }, - "callID": { - "type": "string" - }, - "output": { + "agent": { "type": "string" } }, - "required": ["timestamp", "sessionID", "callID", "output"], + "required": ["timestamp", "sessionID", "agent"], "additionalProperties": false } }, "required": ["id", "type", "properties"], "additionalProperties": false }, - "EventSessionNextStepStarted": { + "EventSessionNextModelSwitched": { "type": "object", "properties": { "id": { @@ -19576,7 +21557,7 @@ }, "type": { "type": "string", - "enum": ["session.next.step.started"] + "enum": ["session.next.model.switched"] }, "properties": { "type": "object", @@ -19588,9 +21569,6 @@ "type": "string", "pattern": "^ses" }, - "agent": { - "type": "string" - }, "model": { "type": "object", "properties": { @@ -19606,19 +21584,16 @@ }, "required": ["id", "providerID"], "additionalProperties": false - }, - "snapshot": { - "type": "string" } }, - "required": ["timestamp", "sessionID", "agent", "model"], + "required": ["timestamp", "sessionID", "model"], "additionalProperties": false } }, "required": ["id", "type", "properties"], "additionalProperties": false }, - "EventSessionNextStepEnded": { + "EventSessionNextPrompted": { "type": "object", "properties": { "id": { @@ -19626,7 +21601,7 @@ }, "type": { "type": "string", - "enum": ["session.next.step.ended"] + "enum": ["session.next.prompted"] }, "properties": { "type": "object", @@ -19638,67 +21613,18 @@ "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" + "prompt": { + "$ref": "#/components/schemas/Prompt" } }, - "required": ["timestamp", "sessionID", "finish", "cost", "tokens"], + "required": ["timestamp", "sessionID", "prompt"], "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": { + "EventSessionNextSynthetic": { "type": "object", "properties": { "id": { @@ -19706,7 +21632,7 @@ }, "type": { "type": "string", - "enum": ["session.next.step.failed"] + "enum": ["session.next.synthetic"] }, "properties": { "type": "object", @@ -19718,18 +21644,18 @@ "type": "string", "pattern": "^ses" }, - "error": { - "$ref": "#/components/schemas/SessionErrorUnknown" + "text": { + "type": "string" } }, - "required": ["timestamp", "sessionID", "error"], + "required": ["timestamp", "sessionID", "text"], "additionalProperties": false } }, "required": ["id", "type", "properties"], "additionalProperties": false }, - "EventSessionNextTextStarted": { + "EventSessionNextShellStarted": { "type": "object", "properties": { "id": { @@ -19737,7 +21663,7 @@ }, "type": { "type": "string", - "enum": ["session.next.text.started"] + "enum": ["session.next.shell.started"] }, "properties": { "type": "object", @@ -19748,47 +21674,22 @@ "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" + "callID": { + "type": "string" }, - "delta": { + "command": { "type": "string" } }, - "required": ["timestamp", "sessionID", "delta"], + "required": ["timestamp", "sessionID", "callID", "command"], "additionalProperties": false } }, "required": ["id", "type", "properties"], "additionalProperties": false }, - "EventSessionNextTextEnded": { + "EventSessionNextShellEnded": { "type": "object", "properties": { "id": { @@ -19796,7 +21697,7 @@ }, "type": { "type": "string", - "enum": ["session.next.text.ended"] + "enum": ["session.next.shell.ended"] }, "properties": { "type": "object", @@ -19808,49 +21709,21 @@ "type": "string", "pattern": "^ses" }, - "text": { + "callID": { "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": { + "output": { "type": "string" } }, - "required": ["timestamp", "sessionID", "reasoningID"], + "required": ["timestamp", "sessionID", "callID", "output"], "additionalProperties": false } }, "required": ["id", "type", "properties"], "additionalProperties": false }, - "EventSessionNextReasoningDelta": { + "EventSessionNextStepStarted": { "type": "object", "properties": { "id": { @@ -19858,7 +21731,7 @@ }, "type": { "type": "string", - "enum": ["session.next.reasoning.delta"] + "enum": ["session.next.step.started"] }, "properties": { "type": "object", @@ -19870,21 +21743,37 @@ "type": "string", "pattern": "^ses" }, - "reasoningID": { + "agent": { "type": "string" }, - "delta": { + "model": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "providerID": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "required": ["id", "providerID"], + "additionalProperties": false + }, + "snapshot": { "type": "string" } }, - "required": ["timestamp", "sessionID", "reasoningID", "delta"], + "required": ["timestamp", "sessionID", "agent", "model"], "additionalProperties": false } }, "required": ["id", "type", "properties"], "additionalProperties": false }, - "EventSessionNextReasoningEnded": { + "EventSessionNextStepEnded": { "type": "object", "properties": { "id": { @@ -19892,7 +21781,7 @@ }, "type": { "type": "string", - "enum": ["session.next.reasoning.ended"] + "enum": ["session.next.step.ended"] }, "properties": { "type": "object", @@ -19904,21 +21793,53 @@ "type": "string", "pattern": "^ses" }, - "reasoningID": { + "finish": { "type": "string" }, - "text": { + "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", "reasoningID", "text"], + "required": ["timestamp", "sessionID", "finish", "cost", "tokens"], "additionalProperties": false } }, "required": ["id", "type", "properties"], "additionalProperties": false }, - "EventSessionNextToolInputStarted": { + "EventSessionNextStepFailed": { "type": "object", "properties": { "id": { @@ -19926,7 +21847,7 @@ }, "type": { "type": "string", - "enum": ["session.next.tool.input.started"] + "enum": ["session.next.step.failed"] }, "properties": { "type": "object", @@ -19938,21 +21859,18 @@ "type": "string", "pattern": "^ses" }, - "callID": { - "type": "string" - }, - "name": { - "type": "string" + "error": { + "$ref": "#/components/schemas/SessionErrorUnknown" } }, - "required": ["timestamp", "sessionID", "callID", "name"], + "required": ["timestamp", "sessionID", "error"], "additionalProperties": false } }, "required": ["id", "type", "properties"], "additionalProperties": false }, - "EventSessionNextToolInputDelta": { + "EventSessionNextTextStarted": { "type": "object", "properties": { "id": { @@ -19960,7 +21878,7 @@ }, "type": { "type": "string", - "enum": ["session.next.tool.input.delta"] + "enum": ["session.next.text.started"] }, "properties": { "type": "object", @@ -19971,22 +21889,16 @@ "sessionID": { "type": "string", "pattern": "^ses" - }, - "callID": { - "type": "string" - }, - "delta": { - "type": "string" } }, - "required": ["timestamp", "sessionID", "callID", "delta"], + "required": ["timestamp", "sessionID"], "additionalProperties": false } }, "required": ["id", "type", "properties"], "additionalProperties": false }, - "EventSessionNextToolInputEnded": { + "EventSessionNextTextDelta": { "type": "object", "properties": { "id": { @@ -19994,7 +21906,7 @@ }, "type": { "type": "string", - "enum": ["session.next.tool.input.ended"] + "enum": ["session.next.text.delta"] }, "properties": { "type": "object", @@ -20006,21 +21918,18 @@ "type": "string", "pattern": "^ses" }, - "callID": { - "type": "string" - }, - "text": { + "delta": { "type": "string" } }, - "required": ["timestamp", "sessionID", "callID", "text"], + "required": ["timestamp", "sessionID", "delta"], "additionalProperties": false } }, "required": ["id", "type", "properties"], "additionalProperties": false }, - "EventSessionNextToolCalled": { + "EventSessionNextTextEnded": { "type": "object", "properties": { "id": { @@ -20028,7 +21937,7 @@ }, "type": { "type": "string", - "enum": ["session.next.tool.called"] + "enum": ["session.next.text.ended"] }, "properties": { "type": "object", @@ -20040,71 +21949,18 @@ "type": "string", "pattern": "^ses" }, - "callID": { - "type": "string" - }, - "tool": { + "text": { "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"], + "required": ["timestamp", "sessionID", "text"], "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": { + "EventSessionNextReasoningStarted": { "type": "object", "properties": { "id": { @@ -20112,7 +21968,7 @@ }, "type": { "type": "string", - "enum": ["session.next.tool.progress"] + "enum": ["session.next.reasoning.started"] }, "properties": { "type": "object", @@ -20124,34 +21980,18 @@ "type": "string", "pattern": "^ses" }, - "callID": { + "reasoningID": { "type": "string" - }, - "structured": { - "type": "object" - }, - "content": { - "type": "array", - "items": { - "anyOf": [ - { - "$ref": "#/components/schemas/ToolTextContent" - }, - { - "$ref": "#/components/schemas/ToolFileContent" - } - ] - } } }, - "required": ["timestamp", "sessionID", "callID", "structured", "content"], + "required": ["timestamp", "sessionID", "reasoningID"], "additionalProperties": false } }, "required": ["id", "type", "properties"], "additionalProperties": false }, - "EventSessionNextToolSuccess": { + "EventSessionNextReasoningDelta": { "type": "object", "properties": { "id": { @@ -20159,7 +21999,7 @@ }, "type": { "type": "string", - "enum": ["session.next.tool.success"] + "enum": ["session.next.reasoning.delta"] }, "properties": { "type": "object", @@ -20171,47 +22011,21 @@ "type": "string", "pattern": "^ses" }, - "callID": { + "reasoningID": { "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 + "delta": { + "type": "string" } }, - "required": ["timestamp", "sessionID", "callID", "structured", "content", "provider"], + "required": ["timestamp", "sessionID", "reasoningID", "delta"], "additionalProperties": false } }, "required": ["id", "type", "properties"], "additionalProperties": false }, - "EventSessionNextToolFailed": { + "EventSessionNextReasoningEnded": { "type": "object", "properties": { "id": { @@ -20219,7 +22033,7 @@ }, "type": { "type": "string", - "enum": ["session.next.tool.failed"] + "enum": ["session.next.reasoning.ended"] }, "properties": { "type": "object", @@ -20231,65 +22045,55 @@ "type": "string", "pattern": "^ses" }, - "callID": { + "reasoningID": { "type": "string" }, - "error": { - "$ref": "#/components/schemas/SessionErrorUnknown" - }, - "provider": { - "type": "object", - "properties": { - "executed": { - "type": "boolean" - }, - "metadata": { - "type": "object" - } - }, - "required": ["executed"], - "additionalProperties": false + "text": { + "type": "string" } }, - "required": ["timestamp", "sessionID", "callID", "error", "provider"], + "required": ["timestamp", "sessionID", "reasoningID", "text"], "additionalProperties": false } }, "required": ["id", "type", "properties"], "additionalProperties": false }, - "SessionNextRetry_error": { + "EventSessionNextToolInputStarted": { "type": "object", "properties": { - "message": { + "id": { "type": "string" }, - "statusCode": { - "type": "number" - }, - "isRetryable": { - "type": "boolean" - }, - "responseHeaders": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, - "responseBody": { - "type": "string" + "type": { + "type": "string", + "enum": ["session.next.tool.input.started"] }, - "metadata": { + "properties": { "type": "object", - "additionalProperties": { - "type": "string" - } + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "callID": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "callID", "name"], + "additionalProperties": false } }, - "required": ["message", "isRetryable"], + "required": ["id", "type", "properties"], "additionalProperties": false }, - "EventSessionNextRetried": { + "EventSessionNextToolInputDelta": { "type": "object", "properties": { "id": { @@ -20297,7 +22101,7 @@ }, "type": { "type": "string", - "enum": ["session.next.retried"] + "enum": ["session.next.tool.input.delta"] }, "properties": { "type": "object", @@ -20309,21 +22113,21 @@ "type": "string", "pattern": "^ses" }, - "attempt": { - "type": "number" + "callID": { + "type": "string" }, - "error": { - "$ref": "#/components/schemas/SessionNextRetry_error" + "delta": { + "type": "string" } }, - "required": ["timestamp", "sessionID", "attempt", "error"], + "required": ["timestamp", "sessionID", "callID", "delta"], "additionalProperties": false } }, "required": ["id", "type", "properties"], "additionalProperties": false }, - "EventSessionNextCompactionStarted": { + "EventSessionNextToolInputEnded": { "type": "object", "properties": { "id": { @@ -20331,7 +22135,7 @@ }, "type": { "type": "string", - "enum": ["session.next.compaction.started"] + "enum": ["session.next.tool.input.ended"] }, "properties": { "type": "object", @@ -20343,19 +22147,21 @@ "type": "string", "pattern": "^ses" }, - "reason": { - "type": "string", - "enum": ["auto", "manual"] + "callID": { + "type": "string" + }, + "text": { + "type": "string" } }, - "required": ["timestamp", "sessionID", "reason"], + "required": ["timestamp", "sessionID", "callID", "text"], "additionalProperties": false } }, "required": ["id", "type", "properties"], "additionalProperties": false }, - "EventSessionNextCompactionDelta": { + "EventSessionNextToolCalled": { "type": "object", "properties": { "id": { @@ -20363,7 +22169,7 @@ }, "type": { "type": "string", - "enum": ["session.next.compaction.delta"] + "enum": ["session.next.tool.called"] }, "properties": { "type": "object", @@ -20375,18 +22181,37 @@ "type": "string", "pattern": "^ses" }, - "text": { + "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", "text"], + "required": ["timestamp", "sessionID", "callID", "tool", "input", "provider"], "additionalProperties": false } }, "required": ["id", "type", "properties"], "additionalProperties": false }, - "EventSessionNextCompactionEnded": { + "EventSessionNextToolProgress": { "type": "object", "properties": { "id": { @@ -20394,7 +22219,7 @@ }, "type": { "type": "string", - "enum": ["session.next.compaction.ended"] + "enum": ["session.next.tool.progress"] }, "properties": { "type": "object", @@ -20406,21 +22231,34 @@ "type": "string", "pattern": "^ses" }, - "text": { + "callID": { "type": "string" }, - "include": { - "type": "string" + "structured": { + "type": "object" + }, + "content": { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/components/schemas/ToolTextContent" + }, + { + "$ref": "#/components/schemas/ToolFileContent" + } + ] + } } }, - "required": ["timestamp", "sessionID", "text"], + "required": ["timestamp", "sessionID", "callID", "structured", "content"], "additionalProperties": false } }, "required": ["id", "type", "properties"], "additionalProperties": false }, - "EventPluginAdded": { + "EventSessionNextToolSuccess": { "type": "object", "properties": { "id": { @@ -20428,347 +22266,237 @@ }, "type": { "type": "string", - "enum": ["plugin.added"] + "enum": ["session.next.tool.success"] }, "properties": { "type": "object", "properties": { - "id": { + "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": ["id"], + "required": ["timestamp", "sessionID", "callID", "structured", "content", "provider"], "additionalProperties": false } }, "required": ["id", "type", "properties"], "additionalProperties": false }, - "ModelV2Info": { + "EventSessionNextToolFailed": { "type": "object", "properties": { "id": { "type": "string" }, - "apiID": { - "type": "string" - }, - "providerID": { - "type": "string" - }, - "family": { - "type": "string" - }, - "name": { - "type": "string" + "type": { + "type": "string", + "enum": ["session.next.tool.failed"] }, - "endpoint": { - "anyOf": [ - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["unknown"] - } - }, - "required": ["type"], - "additionalProperties": false + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["openai/responses"] - }, - "url": { - "type": "string" - }, - "websocket": { - "type": "boolean" - } - }, - "required": ["type", "url"], - "additionalProperties": false + "sessionID": { + "type": "string", + "pattern": "^ses" }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["openai/completions"] - }, - "url": { - "type": "string" - }, - "reasoning": { - "anyOf": [ - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["reasoning_content"] - } - }, - "required": ["type"], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["reasoning_details"] - } - }, - "required": ["type"], - "additionalProperties": false - } - ] - } - }, - "required": ["type", "url"], - "additionalProperties": false + "callID": { + "type": "string" }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["anthropic/messages"] - }, - "url": { - "type": "string" - } - }, - "required": ["type", "url"], - "additionalProperties": false + "error": { + "$ref": "#/components/schemas/SessionErrorUnknown" }, - { + "provider": { "type": "object", "properties": { - "type": { - "type": "string", - "enum": ["aisdk"] - }, - "package": { - "type": "string" + "executed": { + "type": "boolean" }, - "url": { - "type": "string" + "metadata": { + "type": "object" } }, - "required": ["type", "package"], + "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" }, - "capabilities": { + "type": { + "type": "string", + "enum": ["session.next.retried"] + }, + "properties": { "type": "object", "properties": { - "tools": { - "type": "boolean" + "timestamp": { + "type": "number" }, - "input": { - "type": "array", - "items": { - "type": "string" - } + "sessionID": { + "type": "string", + "pattern": "^ses" }, - "output": { - "type": "array", - "items": { - "type": "string" - } + "attempt": { + "type": "number" + }, + "error": { + "$ref": "#/components/schemas/SessionNextRetry_error" } }, - "required": ["tools", "input", "output"], + "required": ["timestamp", "sessionID", "attempt", "error"], "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextCompactionStarted": { + "type": "object", + "properties": { + "id": { + "type": "string" }, - "options": { + "type": { + "type": "string", + "enum": ["session.next.compaction.started"] + }, + "properties": { "type": "object", "properties": { - "headers": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, - "body": { - "type": "object" + "timestamp": { + "type": "number" }, - "aisdk": { - "type": "object", - "properties": { - "provider": { - "type": "object" - }, - "request": { - "type": "object" - } - }, - "required": ["provider", "request"], - "additionalProperties": false + "sessionID": { + "type": "string", + "pattern": "^ses" }, - "variant": { - "type": "string" + "reason": { + "type": "string", + "enum": ["auto", "manual"] } }, - "required": ["headers", "body", "aisdk"], + "required": ["timestamp", "sessionID", "reason"], "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextCompactionDelta": { + "type": "object", + "properties": { + "id": { + "type": "string" }, - "variants": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "headers": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, - "body": { - "type": "object" - }, - "aisdk": { - "type": "object", - "properties": { - "provider": { - "type": "object" - }, - "request": { - "type": "object" - } - }, - "required": ["provider", "request"], - "additionalProperties": false - } - }, - "required": ["id", "headers", "body", "aisdk"], - "additionalProperties": false - } + "type": { + "type": "string", + "enum": ["session.next.compaction.delta"] }, - "time": { + "properties": { "type": "object", "properties": { - "released": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - }, - { - "type": "string", - "enum": ["Infinity", "-Infinity", "NaN"] - } - ] + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "text": { + "type": "string" } }, - "required": ["released"], + "required": ["timestamp", "sessionID", "text"], "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextCompactionEnded": { + "type": "object", + "properties": { + "id": { + "type": "string" }, - "cost": { - "type": "array", - "items": { - "type": "object", - "properties": { - "tier": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["context"] - }, - "size": { - "type": "integer" - } - }, - "required": ["type", "size"], - "additionalProperties": false - }, - "input": { - "type": "number" - }, - "output": { - "type": "number" - }, - "cache": { - "type": "object", - "properties": { - "read": { - "type": "number" - }, - "write": { - "type": "number" - } - }, - "required": ["read", "write"], - "additionalProperties": false - } - }, - "required": ["input", "output", "cache"], - "additionalProperties": false - } - }, - "status": { + "type": { "type": "string", - "enum": ["alpha", "beta", "deprecated", "active"] - }, - "enabled": { - "type": "boolean" + "enum": ["session.next.compaction.ended"] }, - "limit": { + "properties": { "type": "object", "properties": { - "context": { - "type": "integer" + "timestamp": { + "type": "number" }, - "input": { - "type": "integer" + "sessionID": { + "type": "string", + "pattern": "^ses" }, - "output": { - "type": "integer" + "text": { + "type": "string" + }, + "include": { + "type": "string" } }, - "required": ["context", "output"], + "required": ["timestamp", "sessionID", "text"], "additionalProperties": false } }, - "required": [ - "id", - "apiID", - "providerID", - "name", - "endpoint", - "capabilities", - "options", - "variants", - "time", - "cost", - "status", - "enabled", - "limit" - ], + "required": ["id", "type", "properties"], "additionalProperties": false }, - "EventCatalogModelUpdated": { + "EventFileWatcherUpdated": { "type": "object", "properties": { "id": { @@ -20776,23 +22504,27 @@ }, "type": { "type": "string", - "enum": ["catalog.model.updated"] + "enum": ["file.watcher.updated"] }, "properties": { "type": "object", "properties": { - "model": { - "$ref": "#/components/schemas/ModelV2Info" + "file": { + "type": "string" + }, + "event": { + "type": "string", + "enum": ["add", "change", "unlink"] } }, - "required": ["model"], + "required": ["file", "event"], "additionalProperties": false } }, "required": ["id", "type", "properties"], "additionalProperties": false }, - "EventModels-devRefreshed": { + "EventSessionCreated": { "type": "object", "properties": { "id": { @@ -20800,87 +22532,111 @@ }, "type": { "type": "string", - "enum": ["models-dev.refreshed"] + "enum": ["session.created"] }, "properties": { "type": "object", - "properties": {} + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "info": { + "$ref": "#/components/schemas/Session" + } + }, + "required": ["sessionID", "info"], + "additionalProperties": false } }, "required": ["id", "type", "properties"], "additionalProperties": false }, - "AccountV2OAuthCredential": { + "EventSessionUpdated": { "type": "object", "properties": { - "type": { - "type": "string", - "enum": ["oauth"] - }, - "refresh": { + "id": { "type": "string" }, - "access": { - "type": "string" + "type": { + "type": "string", + "enum": ["session.updated"] }, - "expires": { - "type": "integer", - "minimum": 0 + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "info": { + "$ref": "#/components/schemas/Session" + } + }, + "required": ["sessionID", "info"], + "additionalProperties": false } }, - "required": ["type", "refresh", "access", "expires"], + "required": ["id", "type", "properties"], "additionalProperties": false }, - "AccountV2ApiKeyCredential": { + "EventSessionDeleted": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", - "enum": ["api"] - }, - "key": { - "type": "string" + "enum": ["session.deleted"] }, - "metadata": { + "properties": { "type": "object", - "additionalProperties": { - "type": "string" - } + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "info": { + "$ref": "#/components/schemas/Session" + } + }, + "required": ["sessionID", "info"], + "additionalProperties": false } }, - "required": ["type", "key"], + "required": ["id", "type", "properties"], "additionalProperties": false }, - "AccountV2Credential": { - "anyOf": [ - { - "$ref": "#/components/schemas/AccountV2OAuthCredential" - }, - { - "$ref": "#/components/schemas/AccountV2ApiKeyCredential" - } - ] - }, - "AccountV2Info": { + "EventMessageUpdated": { "type": "object", "properties": { "id": { "type": "string" }, - "serviceID": { - "type": "string" - }, - "description": { - "type": "string" + "type": { + "type": "string", + "enum": ["message.updated"] }, - "credential": { - "$ref": "#/components/schemas/AccountV2Credential" + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "info": { + "$ref": "#/components/schemas/Message" + } + }, + "required": ["sessionID", "info"], + "additionalProperties": false } }, - "required": ["id", "serviceID", "description", "credential"], + "required": ["id", "type", "properties"], "additionalProperties": false }, - "EventAccountAdded": { + "EventMessageRemoved": { "type": "object", "properties": { "id": { @@ -20888,23 +22644,28 @@ }, "type": { "type": "string", - "enum": ["account.added"] + "enum": ["message.removed"] }, "properties": { "type": "object", "properties": { - "account": { - "$ref": "#/components/schemas/AccountV2Info" + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "messageID": { + "type": "string", + "pattern": "^msg" } }, - "required": ["account"], + "required": ["sessionID", "messageID"], "additionalProperties": false } }, "required": ["id", "type", "properties"], "additionalProperties": false }, - "EventAccountRemoved": { + "EventMessagePartUpdated": { "type": "object", "properties": { "id": { @@ -20912,23 +22673,30 @@ }, "type": { "type": "string", - "enum": ["account.removed"] + "enum": ["message.part.updated"] }, "properties": { "type": "object", "properties": { - "account": { - "$ref": "#/components/schemas/AccountV2Info" + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "part": { + "$ref": "#/components/schemas/Part" + }, + "time": { + "type": "number" } }, - "required": ["account"], + "required": ["sessionID", "part", "time"], "additionalProperties": false } }, "required": ["id", "type", "properties"], "additionalProperties": false }, - "EventAccountSwitched": { + "EventMessagePartRemoved": { "type": "object", "properties": { "id": { @@ -20936,930 +22704,873 @@ }, "type": { "type": "string", - "enum": ["account.switched"] + "enum": ["message.part.removed"] }, "properties": { "type": "object", "properties": { - "serviceID": { - "type": "string" + "sessionID": { + "type": "string", + "pattern": "^ses" }, - "from": { - "type": "string" + "messageID": { + "type": "string", + "pattern": "^msg" }, - "to": { - "type": "string" + "partID": { + "type": "string", + "pattern": "^prt" } }, - "required": ["serviceID"], + "required": ["sessionID", "messageID", "partID"], "additionalProperties": false } }, "required": ["id", "type", "properties"], "additionalProperties": false }, - "PolicyEffect": { - "type": "string", - "enum": ["allow", "deny"] - }, - "ConfigV2ExperimentalPolicy": { + "EventMessagePartDelta": { "type": "object", "properties": { - "action": { - "type": "string", - "enum": ["provider.use"] + "id": { + "type": "string" }, - "effect": { - "$ref": "#/components/schemas/PolicyEffect" + "type": { + "type": "string", + "enum": ["message.part.delta"] }, - "resource": { - "type": "string" + "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": ["action", "effect", "resource"], + "required": ["id", "type", "properties"], "additionalProperties": false }, - "SessionInfo": { + "EventPermissionAsked": { "type": "object", "properties": { "id": { - "type": "string", - "pattern": "^ses" - }, - "parentID": { - "type": "string", - "pattern": "^ses" - }, - "projectID": { "type": "string" }, - "workspaceID": { + "type": { "type": "string", - "pattern": "^wrk" - }, - "path": { - "type": "string" - }, - "agent": { - "type": "string" + "enum": ["permission.asked"] }, - "model": { + "properties": { "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^per" }, - "providerID": { - "type": "string" + "sessionID": { + "type": "string", + "pattern": "^ses" }, - "variant": { + "permission": { "type": "string" - } - }, - "required": ["id", "providerID"], - "additionalProperties": false - }, - "cost": { - "type": "number" - }, - "tokens": { - "type": "object", - "properties": { - "input": { - "type": "number" }, - "output": { - "type": "number" + "patterns": { + "type": "array", + "items": { + "type": "string" + } }, - "reasoning": { - "type": "number" + "metadata": { + "type": "object" }, - "cache": { + "always": { + "type": "array", + "items": { + "type": "string" + } + }, + "tool": { "type": "object", "properties": { - "read": { - "type": "number" + "messageID": { + "type": "string", + "pattern": "^msg" }, - "write": { - "type": "number" + "callID": { + "type": "string" } }, - "required": ["read", "write"], + "required": ["messageID", "callID"], "additionalProperties": false } }, - "required": ["input", "output", "reasoning", "cache"], + "required": ["id", "sessionID", "permission", "patterns", "metadata", "always"], "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventPermissionReplied": { + "type": "object", + "properties": { + "id": { + "type": "string" }, - "time": { + "type": { + "type": "string", + "enum": ["permission.replied"] + }, + "properties": { "type": "object", "properties": { - "created": { - "type": "number" + "sessionID": { + "type": "string", + "pattern": "^ses" }, - "updated": { - "type": "number" + "requestID": { + "type": "string", + "pattern": "^per" }, - "archived": { - "type": "number" + "reply": { + "type": "string", + "enum": ["once", "always", "reject"] } }, - "required": ["created", "updated"], + "required": ["sessionID", "requestID", "reply"], "additionalProperties": false - }, - "title": { - "type": "string" } }, - "required": ["id", "projectID", "cost", "tokens", "time", "title"], + "required": ["id", "type", "properties"], "additionalProperties": false }, - "SessionDelivery": { - "type": "string", - "enum": ["immediate", "deferred"] - }, - "SessionMessageAgentSwitched": { + "EventSessionDiff": { "type": "object", "properties": { "id": { "type": "string" }, - "metadata": { - "type": "object" + "type": { + "type": "string", + "enum": ["session.diff"] }, - "time": { + "properties": { "type": "object", "properties": { - "created": { - "type": "number" + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "diff": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SnapshotFileDiff" + } } }, - "required": ["created"], + "required": ["sessionID", "diff"], "additionalProperties": false - }, - "type": { - "type": "string", - "enum": ["agent-switched"] - }, - "agent": { - "type": "string" } }, - "required": ["id", "time", "type", "agent"], + "required": ["id", "type", "properties"], "additionalProperties": false }, - "SessionMessageModelSwitched": { + "EventSessionError": { "type": "object", "properties": { "id": { "type": "string" }, - "metadata": { - "type": "object" + "type": { + "type": "string", + "enum": ["session.error"] }, - "time": { + "properties": { "type": "object", "properties": { - "created": { - "type": "number" + "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" + } + ] } }, - "required": ["created"], "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventQuestionAsked": { + "type": "object", + "properties": { + "id": { + "type": "string" }, "type": { "type": "string", - "enum": ["model-switched"] + "enum": ["question.asked"] }, - "model": { + "properties": { "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^que" }, - "providerID": { - "type": "string" + "sessionID": { + "type": "string", + "pattern": "^ses" }, - "variant": { - "type": "string" + "questions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/QuestionInfo" + }, + "description": "Questions to ask" + }, + "tool": { + "$ref": "#/components/schemas/QuestionTool" } }, - "required": ["id", "providerID"], + "required": ["id", "sessionID", "questions"], "additionalProperties": false } }, - "required": ["id", "time", "type", "model"], + "required": ["id", "type", "properties"], "additionalProperties": false }, - "SessionMessageUser": { + "EventQuestionReplied": { "type": "object", "properties": { "id": { "type": "string" }, - "metadata": { - "type": "object" + "type": { + "type": "string", + "enum": ["question.replied"] }, - "time": { + "properties": { "type": "object", "properties": { - "created": { - "type": "number" + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "requestID": { + "type": "string", + "pattern": "^que" + }, + "answers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/QuestionAnswer" + } } }, - "required": ["created"], + "required": ["sessionID", "requestID", "answers"], "additionalProperties": false - }, - "text": { + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventQuestionRejected": { + "type": "object", + "properties": { + "id": { "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" - } - }, "type": { "type": "string", - "enum": ["user"] + "enum": ["question.rejected"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "requestID": { + "type": "string", + "pattern": "^que" + } + }, + "required": ["sessionID", "requestID"], + "additionalProperties": false } }, - "required": ["id", "time", "text", "type"], + "required": ["id", "type", "properties"], "additionalProperties": false }, - "SessionMessageSynthetic": { + "EventTodoUpdated": { "type": "object", "properties": { "id": { "type": "string" }, - "metadata": { - "type": "object" + "type": { + "type": "string", + "enum": ["todo.updated"] }, - "time": { + "properties": { "type": "object", "properties": { - "created": { - "type": "number" + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "todos": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Todo" + } } }, - "required": ["created"], + "required": ["sessionID", "todos"], "additionalProperties": false - }, - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "text": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["synthetic"] } }, - "required": ["id", "time", "sessionID", "text", "type"], + "required": ["id", "type", "properties"], "additionalProperties": false }, - "SessionMessageShell": { + "EventSessionStatus": { "type": "object", "properties": { "id": { "type": "string" }, - "metadata": { - "type": "object" + "type": { + "type": "string", + "enum": ["session.status"] }, - "time": { + "properties": { "type": "object", "properties": { - "created": { - "type": "number" + "sessionID": { + "type": "string", + "pattern": "^ses" }, - "completed": { - "type": "number" + "status": { + "$ref": "#/components/schemas/SessionStatus" } }, - "required": ["created"], + "required": ["sessionID", "status"], "additionalProperties": false - }, - "type": { - "type": "string", - "enum": ["shell"] - }, - "callID": { - "type": "string" - }, - "command": { - "type": "string" - }, - "output": { - "type": "string" } }, - "required": ["id", "time", "type", "callID", "command", "output"], + "required": ["id", "type", "properties"], "additionalProperties": false }, - "SessionMessageAssistantText": { + "EventSessionIdle": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", - "enum": ["text"] + "enum": ["session.idle"] }, - "text": { - "type": "string" + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + } + }, + "required": ["sessionID"], + "additionalProperties": false } }, - "required": ["type", "text"], + "required": ["id", "type", "properties"], "additionalProperties": false }, - "SessionMessageAssistantReasoning": { + "EventSessionCompacted": { "type": "object", "properties": { - "type": { - "type": "string", - "enum": ["reasoning"] - }, "id": { "type": "string" }, - "text": { - "type": "string" + "type": { + "type": "string", + "enum": ["session.compacted"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + } + }, + "required": ["sessionID"], + "additionalProperties": false } }, - "required": ["type", "id", "text"], + "required": ["id", "type", "properties"], "additionalProperties": false }, - "SessionMessageToolStatePending": { + "EventLspUpdated": { "type": "object", "properties": { - "status": { + "id": { + "type": "string" + }, + "type": { "type": "string", - "enum": ["pending"] + "enum": ["lsp.updated"] }, - "input": { - "type": "string" + "properties": { + "type": "object", + "properties": {} } }, - "required": ["status", "input"], + "required": ["id", "type", "properties"], "additionalProperties": false }, - "SessionMessageToolStateRunning": { + "EventMcpToolsChanged": { "type": "object", "properties": { - "status": { - "type": "string", - "enum": ["running"] - }, - "input": { - "type": "object" + "id": { + "type": "string" }, - "structured": { - "type": "object" + "type": { + "type": "string", + "enum": ["mcp.tools.changed"] }, - "content": { - "type": "array", - "items": { - "anyOf": [ - { - "$ref": "#/components/schemas/ToolTextContent" - }, - { - "$ref": "#/components/schemas/ToolFileContent" - } - ] - } + "properties": { + "type": "object", + "properties": { + "server": { + "type": "string" + } + }, + "required": ["server"], + "additionalProperties": false } }, - "required": ["status", "input", "structured", "content"], + "required": ["id", "type", "properties"], "additionalProperties": false }, - "SessionMessageToolStateCompleted": { + "EventMcpBrowserOpenFailed": { "type": "object", "properties": { - "status": { - "type": "string", - "enum": ["completed"] - }, - "input": { - "type": "object" - }, - "attachments": { - "type": "array", - "items": { - "$ref": "#/components/schemas/PromptFileAttachment" - } + "id": { + "type": "string" }, - "content": { - "type": "array", - "items": { - "anyOf": [ - { - "$ref": "#/components/schemas/ToolTextContent" - }, - { - "$ref": "#/components/schemas/ToolFileContent" - } - ] - } + "type": { + "type": "string", + "enum": ["mcp.browser.open.failed"] }, - "structured": { - "type": "object" + "properties": { + "type": "object", + "properties": { + "mcpName": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": ["mcpName", "url"], + "additionalProperties": false } }, - "required": ["status", "input", "content", "structured"], + "required": ["id", "type", "properties"], "additionalProperties": false }, - "SessionMessageToolStateError": { + "EventCommandExecuted": { "type": "object", "properties": { - "status": { - "type": "string", - "enum": ["error"] - }, - "input": { - "type": "object" - }, - "content": { - "type": "array", - "items": { - "anyOf": [ - { - "$ref": "#/components/schemas/ToolTextContent" - }, - { - "$ref": "#/components/schemas/ToolFileContent" - } - ] - } + "id": { + "type": "string" }, - "structured": { - "type": "object" + "type": { + "type": "string", + "enum": ["command.executed"] }, - "error": { - "$ref": "#/components/schemas/SessionErrorUnknown" + "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": ["status", "input", "content", "structured", "error"], + "required": ["id", "type", "properties"], "additionalProperties": false }, - "SessionMessageAssistantTool": { + "EventProjectUpdated": { "type": "object", "properties": { - "type": { - "type": "string", - "enum": ["tool"] - }, "id": { "type": "string" }, - "name": { - "type": "string" + "type": { + "type": "string", + "enum": ["project.updated"] }, - "provider": { + "properties": { "type": "object", "properties": { - "executed": { - "type": "boolean" + "id": { + "type": "string" }, - "metadata": { - "type": "object" - } - }, - "required": ["executed"], - "additionalProperties": false - }, - "state": { - "anyOf": [ - { - "$ref": "#/components/schemas/SessionMessageToolStatePending" + "worktree": { + "type": "string" }, - { - "$ref": "#/components/schemas/SessionMessageToolStateRunning" + "vcs": { + "type": "string", + "enum": ["git"] }, - { - "$ref": "#/components/schemas/SessionMessageToolStateCompleted" + "name": { + "type": "string" }, - { - "$ref": "#/components/schemas/SessionMessageToolStateError" - } - ] - }, - "time": { - "type": "object", - "properties": { - "created": { - "type": "number" + "icon": { + "type": "object", + "properties": { + "url": { + "type": "string" + }, + "override": { + "type": "string" + }, + "color": { + "type": "string" + } + }, + "additionalProperties": false }, - "ran": { - "type": "number" + "commands": { + "type": "object", + "properties": { + "start": { + "type": "string", + "description": "Startup script to run when creating a new workspace (worktree)" + } + }, + "additionalProperties": false }, - "completed": { - "type": "number" + "time": { + "type": "object", + "properties": { + "created": { + "type": "integer", + "minimum": 0 + }, + "updated": { + "type": "integer", + "minimum": 0 + }, + "initialized": { + "type": "integer", + "minimum": 0 + } + }, + "required": ["created", "updated"], + "additionalProperties": false }, - "pruned": { - "type": "number" + "sandboxes": { + "type": "array", + "items": { + "type": "string" + } } }, - "required": ["created"], + "required": ["id", "worktree", "time", "sandboxes"], "additionalProperties": false } }, - "required": ["type", "id", "name", "state", "time"], + "required": ["id", "type", "properties"], "additionalProperties": false }, - "SessionMessageAssistant": { + "EventVcsBranchUpdated": { "type": "object", "properties": { "id": { "type": "string" }, - "metadata": { - "type": "object" + "type": { + "type": "string", + "enum": ["vcs.branch.updated"] }, - "time": { + "properties": { "type": "object", "properties": { - "created": { - "type": "number" - }, - "completed": { - "type": "number" + "branch": { + "type": "string" } }, - "required": ["created"], "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventWorkspaceReady": { + "type": "object", + "properties": { + "id": { + "type": "string" }, "type": { "type": "string", - "enum": ["assistant"] + "enum": ["workspace.ready"] }, - "agent": { + "properties": { + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "required": ["name"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventWorkspaceFailed": { + "type": "object", + "properties": { + "id": { "type": "string" }, - "model": { + "type": { + "type": "string", + "enum": ["workspace.failed"] + }, + "properties": { "type": "object", "properties": { - "id": { - "type": "string" - }, - "providerID": { - "type": "string" - }, - "variant": { + "message": { "type": "string" } }, - "required": ["id", "providerID"], + "required": ["message"], "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventWorkspaceStatus": { + "type": "object", + "properties": { + "id": { + "type": "string" }, - "content": { - "type": "array", - "items": { - "anyOf": [ - { - "$ref": "#/components/schemas/SessionMessageAssistantText" - }, - { - "$ref": "#/components/schemas/SessionMessageAssistantReasoning" - }, - { - "$ref": "#/components/schemas/SessionMessageAssistantTool" - } - ] - } + "type": { + "type": "string", + "enum": ["workspace.status"] }, - "snapshot": { + "properties": { "type": "object", "properties": { - "start": { - "type": "string" + "workspaceID": { + "type": "string", + "pattern": "^wrk" }, - "end": { - "type": "string" + "status": { + "type": "string", + "enum": ["connected", "connecting", "disconnected", "error"] } }, + "required": ["workspaceID", "status"], "additionalProperties": false - }, - "finish": { + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventWorktreeReady": { + "type": "object", + "properties": { + "id": { "type": "string" }, - "cost": { - "type": "number" + "type": { + "type": "string", + "enum": ["worktree.ready"] }, - "tokens": { + "properties": { "type": "object", "properties": { - "input": { - "type": "number" - }, - "output": { - "type": "number" - }, - "reasoning": { - "type": "number" + "name": { + "type": "string" }, - "cache": { - "type": "object", - "properties": { - "read": { - "type": "number" - }, - "write": { - "type": "number" - } - }, - "required": ["read", "write"], - "additionalProperties": false + "branch": { + "type": "string" } }, - "required": ["input", "output", "reasoning", "cache"], + "required": ["name"], "additionalProperties": false - }, - "error": { - "$ref": "#/components/schemas/SessionErrorUnknown" } }, - "required": ["id", "time", "type", "agent", "model", "content"], + "required": ["id", "type", "properties"], "additionalProperties": false }, - "SessionMessageCompaction": { + "EventWorktreeFailed": { "type": "object", "properties": { - "type": { - "type": "string", - "enum": ["compaction"] - }, - "reason": { - "type": "string", - "enum": ["auto", "manual"] - }, - "summary": { - "type": "string" - }, - "include": { - "type": "string" - }, "id": { "type": "string" }, - "metadata": { - "type": "object" + "type": { + "type": "string", + "enum": ["worktree.failed"] }, - "time": { + "properties": { "type": "object", "properties": { - "created": { - "type": "number" + "message": { + "type": "string" } }, - "required": ["created"], + "required": ["message"], "additionalProperties": false } }, - "required": ["type", "reason", "summary", "id", "time"], + "required": ["id", "type", "properties"], "additionalProperties": false }, - "SessionMessage": { - "anyOf": [ - { - "$ref": "#/components/schemas/SessionMessageAgentSwitched" - }, - { - "$ref": "#/components/schemas/SessionMessageModelSwitched" - }, - { - "$ref": "#/components/schemas/SessionMessageUser" - }, - { - "$ref": "#/components/schemas/SessionMessageSynthetic" - }, - { - "$ref": "#/components/schemas/SessionMessageShell" + "EventPtyCreated": { + "type": "object", + "properties": { + "id": { + "type": "string" }, - { - "$ref": "#/components/schemas/SessionMessageAssistant" + "type": { + "type": "string", + "enum": ["pty.created"] }, - { - "$ref": "#/components/schemas/SessionMessageCompaction" + "properties": { + "type": "object", + "properties": { + "info": { + "$ref": "#/components/schemas/Pty" + } + }, + "required": ["info"], + "additionalProperties": false } - ] + }, + "required": ["id", "type", "properties"], + "additionalProperties": false }, - "ProviderV2Info": { + "EventPtyUpdated": { "type": "object", "properties": { "id": { "type": "string" }, - "name": { - "type": "string" + "type": { + "type": "string", + "enum": ["pty.updated"] }, - "enabled": { - "anyOf": [ - { - "type": "boolean", - "enum": [false] - }, - { - "type": "object", - "properties": { - "via": { - "type": "string", - "enum": ["env"] - }, - "name": { - "type": "string" - } - }, - "required": ["via", "name"], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "via": { - "type": "string", - "enum": ["account"] - }, - "service": { - "type": "string" - } - }, - "required": ["via", "service"], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "via": { - "type": "string", - "enum": ["custom"] - }, - "data": { - "type": "object" - } - }, - "required": ["via", "data"], - "additionalProperties": false + "properties": { + "type": "object", + "properties": { + "info": { + "$ref": "#/components/schemas/Pty" } - ] - }, - "env": { - "type": "array", - "items": { - "type": "string" - } + }, + "required": ["info"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventPtyExited": { + "type": "object", + "properties": { + "id": { + "type": "string" }, - "endpoint": { - "anyOf": [ - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["unknown"] - } - }, - "required": ["type"], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["openai/responses"] - }, - "url": { - "type": "string" - }, - "websocket": { - "type": "boolean" - } - }, - "required": ["type", "url"], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["openai/completions"] - }, - "url": { - "type": "string" - }, - "reasoning": { - "anyOf": [ - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["reasoning_content"] - } - }, - "required": ["type"], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["reasoning_details"] - } - }, - "required": ["type"], - "additionalProperties": false - } - ] - } - }, - "required": ["type", "url"], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["anthropic/messages"] - }, - "url": { - "type": "string" - } - }, - "required": ["type", "url"], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["aisdk"] - }, - "package": { - "type": "string" - }, - "url": { - "type": "string" - } - }, - "required": ["type", "package"], - "additionalProperties": false - } - ] + "type": { + "type": "string", + "enum": ["pty.exited"] }, - "options": { + "properties": { "type": "object", "properties": { - "headers": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, - "body": { - "type": "object" + "id": { + "type": "string", + "pattern": "^pty" }, - "aisdk": { - "type": "object", - "properties": { - "provider": { - "type": "object" - }, - "request": { - "type": "object" - } - }, - "required": ["provider", "request"], - "additionalProperties": false + "exitCode": { + "type": "integer", + "minimum": 0 } }, - "required": ["headers", "body", "aisdk"], + "required": ["id", "exitCode"], "additionalProperties": false } }, - "required": ["id", "name", "enabled", "env", "endpoint", "options"], + "required": ["id", "type", "properties"], "additionalProperties": false }, - "EventTuiToastShow1": { + "EventPtyDeleted": { "type": "object", "properties": { "id": { @@ -21867,351 +23578,253 @@ }, "type": { "type": "string", - "enum": ["tui.toast.show"] + "enum": ["pty.deleted"] }, "properties": { "type": "object", "properties": { - "title": { - "type": "string" - }, - "message": { - "type": "string" - }, - "variant": { + "id": { "type": "string", - "enum": ["info", "success", "warning", "error"] - }, - "duration": { - "type": "integer", - "exclusiveMinimum": 0 + "pattern": "^pty" } }, - "required": ["message", "variant"], + "required": ["id"], "additionalProperties": false } }, "required": ["id", "type", "properties"], "additionalProperties": false }, - "ModelV2Info1": { + "EventInstallationUpdated": { "type": "object", "properties": { "id": { "type": "string" }, - "apiID": { + "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" }, - "providerID": { + "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" }, - "family": { + "type": { + "type": "string", + "enum": ["server.connected"] + }, + "properties": { + "type": "object", + "properties": {} + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventGlobalDisposed": { + "type": "object", + "properties": { + "id": { "type": "string" }, - "name": { + "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" }, - "endpoint": { - "anyOf": [ - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["unknown"] - } - }, - "required": ["type"], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["openai/responses"] - }, - "url": { - "type": "string" - }, - "websocket": { - "type": "boolean" - } - }, - "required": ["type", "url"], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["openai/completions"] - }, - "url": { - "type": "string" - }, - "reasoning": { - "anyOf": [ - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["reasoning_content"] - } - }, - "required": ["type"], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["reasoning_details"] - } - }, - "required": ["type"], - "additionalProperties": false - } - ] - } - }, - "required": ["type", "url"], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["anthropic/messages"] - }, - "url": { - "type": "string" - } - }, - "required": ["type", "url"], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["aisdk"] - }, - "package": { - "type": "string" - }, - "url": { - "type": "string" - } - }, - "required": ["type", "package"], - "additionalProperties": false - } - ] + "access": { + "type": "string" + }, + "expires": { + "type": "integer", + "minimum": 0 + } + }, + "required": ["type", "refresh", "access", "expires"], + "additionalProperties": false + }, + "AuthApiKeyCredential": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["api"] }, - "capabilities": { + "key": { + "type": "string" + }, + "metadata": { "type": "object", - "properties": { - "tools": { - "type": "boolean" - }, - "input": { - "type": "array", - "items": { - "type": "string" - } - }, - "output": { - "type": "array", - "items": { - "type": "string" - } - } - }, - "required": ["tools", "input", "output"], - "additionalProperties": false + "additionalProperties": { + "type": "string" + } + } + }, + "required": ["type", "key"], + "additionalProperties": false + }, + "AuthCredential": { + "anyOf": [ + { + "$ref": "#/components/schemas/AuthOAuthCredential" }, - "options": { + { + "$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": { - "headers": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, - "body": { - "type": "object" - }, - "aisdk": { - "type": "object", - "properties": { - "provider": { - "type": "object" - }, - "request": { - "type": "object" - } - }, - "required": ["provider", "request"], - "additionalProperties": false - }, - "variant": { - "type": "string" + "account": { + "$ref": "#/components/schemas/AuthInfo" } }, - "required": ["headers", "body", "aisdk"], + "required": ["account"], "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventAccountRemoved": { + "type": "object", + "properties": { + "id": { + "type": "string" }, - "variants": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "headers": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, - "body": { - "type": "object" - }, - "aisdk": { - "type": "object", - "properties": { - "provider": { - "type": "object" - }, - "request": { - "type": "object" - } - }, - "required": ["provider", "request"], - "additionalProperties": false - } - }, - "required": ["id", "headers", "body", "aisdk"], - "additionalProperties": false - } + "type": { + "type": "string", + "enum": ["account.removed"] }, - "time": { + "properties": { "type": "object", "properties": { - "released": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - } - ] + "account": { + "$ref": "#/components/schemas/AuthInfo" } }, - "required": ["released"], + "required": ["account"], "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventAccountSwitched": { + "type": "object", + "properties": { + "id": { + "type": "string" }, - "cost": { - "type": "array", - "items": { - "type": "object", - "properties": { - "tier": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["context"] - }, - "size": { - "type": "integer" - } - }, - "required": ["type", "size"], - "additionalProperties": false - }, - "input": { - "type": "number" - }, - "output": { - "type": "number" - }, - "cache": { - "type": "object", - "properties": { - "read": { - "type": "number" - }, - "write": { - "type": "number" - } - }, - "required": ["read", "write"], - "additionalProperties": false - } - }, - "required": ["input", "output", "cache"], - "additionalProperties": false - } - }, - "status": { + "type": { "type": "string", - "enum": ["alpha", "beta", "deprecated", "active"] - }, - "enabled": { - "type": "boolean" + "enum": ["account.switched"] }, - "limit": { + "properties": { "type": "object", "properties": { - "context": { - "type": "integer" + "serviceID": { + "type": "string" }, - "input": { - "type": "integer" + "from": { + "type": "string" }, - "output": { - "type": "integer" + "to": { + "type": "string" } }, - "required": ["context", "output"], + "required": ["serviceID"], "additionalProperties": false } }, - "required": [ - "id", - "apiID", - "providerID", - "name", - "endpoint", - "capabilities", - "options", - "variants", - "time", - "cost", - "status", - "enabled", - "limit" - ], + "required": ["id", "type", "properties"], "additionalProperties": false }, "BadRequestError": { From 1afa9e32c9ebde43fc94782c883b422a3628daff Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sun, 31 May 2026 01:21:53 +0000 Subject: [PATCH 006/412] 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 8e95f0b91..4dcdf1018 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 007/412] 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 | 1590 +++--- packages/sdk/openapi.json | 4246 +++++++++-------- 7 files changed, 3120 insertions(+), 2774 deletions(-) diff --git a/packages/core/src/permission.ts b/packages/core/src/permission.ts index 07c7d8e7b..95c2745b9 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 051ae7afd..f93fc8eb2 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 f7b657da3..a58131255 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 0f0b695ea..11649d11f 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 b7a50962d..56c741df1 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 b19a5f244..3be97a5cf 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,897 +2406,926 @@ 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 +export type NotFoundError = { + name: "NotFoundError" + data: { + message: string } - title: string - agent?: string - model?: { - id: string - providerID: string - variant?: string +} + +export type TextPartInput = { + id?: string + type: "text" + text: string + synthetic?: boolean + ignored?: boolean + time?: { + start: number + end?: number } - 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 +export type FilePartInput = { + id?: string + type: "file" + mime: string + filename?: string + url: string + source?: FilePartSource +} + +export type AgentPartInput = { + id?: string + type: "agent" + name: string + source?: { + value: string + start: number + end: number } - title: string - agent?: string +} + +export type SubtaskPartInput = { + id?: string + type: "subtask" + prompt: string + description: string + agent: string model?: { - id: string providerID: string - variant?: string + modelID: string } - version: string - metadata?: { - [key: string]: unknown + command?: string +} + +export type SessionBusyError = { + _tag: "SessionBusyError" + sessionID: string + message: string +} + +export type V2SessionsResponse = { + items: Array + cursor: { + previous?: string + next?: string } - time: { - created: number - updated: number - compacting?: number - archived?: number +} + +export type InvalidCursorError = { + _tag: "InvalidCursorError" + message: string +} + +export type UnauthorizedError = { + _tag: "UnauthorizedError" + message: string +} + +export type SessionNotFoundError = { + _tag: "SessionNotFoundError" + sessionID: string + message: string +} + +export type ServiceUnavailableError = { + _tag: "ServiceUnavailableError" + message: string + service?: string +} + +export type UnknownError1 = { + _tag: "UnknownError" + message: string + ref?: string +} + +export type V2SessionMessagesResponse = { + items: Array + cursor: { + previous?: string + next?: string } - permission?: PermissionRuleset - revert?: { - messageID: string - partID?: string - snapshot?: string - diff?: string +} + +export type ProviderNotFoundError = { + _tag: "ProviderNotFoundError" + providerID: string + message: string +} + +export type EventTuiPromptAppend = { + type: "tui.prompt.append" + properties: { + text: string } } -export type NotFoundError = { - name: "NotFoundError" - data: { +export type EventTuiCommandExecute = { + 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 = { + type: "tui.toast.show" + properties: { + title?: string message: string + variant: "info" | "success" | "warning" | "error" + duration?: number } } -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 EventTuiSessionSelect = { + type: "tui.session.select" + properties: { + /** + * Session ID to navigate to + */ + sessionID: string } } -export type Session4 = { +export type Workspace = { id: string - slug: string + type: string + name: string + branch?: string | null + directory?: string | null + extra?: unknown | null 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 + timeUsed: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" +} + +export type WorkspaceCreateError = { + name: "WorkspaceCreateError" + data: { + message: string } - permission?: PermissionRuleset - revert?: { - messageID: string - partID?: string - snapshot?: string - diff?: string +} + +export type WorkspaceWarpError = { + name: "WorkspaceWarpError" + data: { + message: string } } -export type Session5 = { +export type EffectHttpApiErrorForbidden = { + _tag: "Forbidden" +} + +export type EventTuiPromptAppend2 = { 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 + type: "tui.prompt.append" + properties: { + text: string } } -export type Session6 = { +export type EventTuiCommandExecute2 = { 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 + 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 } - time: { - created: number - updated: number - compacting?: number - archived?: number +} + +export type EventTuiToastShow2 = { + id: string + type: "tui.toast.show" + properties: { + title?: string + message: string + variant: "info" | "success" | "warning" | "error" + duration?: number } - permission?: PermissionRuleset - revert?: { - messageID: string - partID?: string - snapshot?: string - diff?: string +} + +export type EventTuiSessionSelect2 = { + id: string + type: "tui.session.select" + properties: { + /** + * Session ID to navigate to + */ + sessionID: string } } -export type Session7 = { +export type ModelV2Info = { id: string - slug: string - projectID: string - workspaceID?: string - directory: string - path?: string - parentID?: string - summary?: { - additions: number - deletions: number - files: number - diffs?: Array + apiID: string + providerID: string + family?: string + name: string + endpoint: + | { + type: "unknown" + } + | { + type: "openai/responses" + url: string + websocket?: boolean + } + | { + type: "openai/completions" + url: string + reasoning?: + | { + type: "reasoning_content" + } + | { + type: "reasoning_details" + } + } + | { + type: "anthropic/messages" + url: string + } + | { + type: "aisdk" + package: string + url?: string + } + capabilities: { + tools: boolean + input: Array + output: Array + } + options: { + headers: { + [key: string]: string + } + body: { + [key: string]: unknown + } + aisdk: { + provider: { + [key: string]: unknown + } + request: { + [key: string]: unknown + } + } + variant?: string } - cost?: number - tokens?: { + variants: Array<{ + id: string + headers: { + [key: string]: string + } + body: { + [key: string]: unknown + } + aisdk: { + provider: { + [key: string]: unknown + } + request: { + [key: string]: unknown + } + } + }> + time: { + released: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + } + cost: Array<{ + tier?: { + type: "context" + size: number + } 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 + }> + status: "alpha" | "beta" | "deprecated" | "active" + enabled: boolean + limit: { + context: number + input?: number + output: number } } -export type TextPartInput = { - id?: string - type: "text" +export type PromptSource = { + start: number + end: number text: string - synthetic?: boolean - ignored?: boolean - time?: { - start: number - end?: number - } - metadata?: { - [key: string]: unknown - } } -export type FilePartInput = { - id?: string - type: "file" +export type PromptFileAttachment = { + uri: string mime: string - filename?: string - url: string - source?: FilePartSource + name?: string + description?: string + source?: PromptSource } -export type AgentPartInput = { - id?: string - type: "agent" +export type PromptAgentAttachment = { name: string - source?: { - value: string - start: number - end: number - } + source?: PromptSource } -export type SubtaskPartInput = { - id?: string - type: "subtask" - prompt: string - description: string - agent: string - model?: { - providerID: string - modelID: string - } - command?: string +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 SessionBusyError = { - _tag: "SessionBusyError" - sessionID: string +export type SessionErrorUnknown = { + type: "unknown" 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 +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 } - version: string + responseBody?: 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 + [key: string]: 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 +export type AuthOAuthCredential = { + type: "oauth" + refresh: string + access: string + expires: number +} + +export type AuthApiKeyCredential = { + type: "api" + key: string metadata?: { - [key: string]: unknown - } - time: { - created: number - updated: number - compacting?: number - archived?: number + [key: string]: string } - permission?: PermissionRuleset - revert?: { - messageID: string - partID?: string - snapshot?: string - diff?: 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 V2SessionsResponse = { - items: Array - cursor: { - previous?: string - next?: 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 InvalidCursorError = { - _tag: "InvalidCursorError" - message: 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 UnauthorizedError = { - _tag: "UnauthorizedError" - message: 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 SessionNotFoundError = { - _tag: "SessionNotFoundError" - sessionID: string - message: string +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 ServiceUnavailableError = { - _tag: "ServiceUnavailableError" - message: string - service?: 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 UnknownError1 = { - _tag: "UnknownError" - message: string - ref?: 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 V2SessionMessagesResponse = { - items: Array - cursor: { - previous?: string - next?: 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 ProviderNotFoundError = { - _tag: "ProviderNotFoundError" - providerID: string - message: 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 EventTuiPromptAppend = { - type: "tui.prompt.append" - properties: { - text: 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 EventTuiCommandExecute = { - 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 SyncEventSessionNextTextStarted = { + type: "sync" + name: "session.next.text.started.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string } } -export type EventTuiToastShow = { - type: "tui.toast.show" - properties: { - title?: string - message: string - variant: "info" | "success" | "warning" | "error" - duration?: number +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 EventTuiSessionSelect = { - type: "tui.session.select" - properties: { - /** - * Session ID to navigate to - */ +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 Workspace = { +export type SyncEventSessionNextReasoningStarted = { + type: "sync" + name: "session.next.reasoning.started.1" id: string - type: string - name: string - branch?: string | null - directory?: string | null - extra?: unknown | null - projectID: string - timeUsed: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + reasoningID: string + } } -export type WorkspaceCreateError = { - name: "WorkspaceCreateError" +export type SyncEventSessionNextReasoningDelta = { + type: "sync" + name: "session.next.reasoning.delta.1" + id: string + seq: number + aggregateID: "sessionID" data: { - message: string + timestamp: number + sessionID: string + reasoningID: string + delta: string } } -export type WorkspaceWarpError = { - name: "WorkspaceWarpError" +export type SyncEventSessionNextReasoningEnded = { + type: "sync" + name: "session.next.reasoning.ended.1" + id: string + seq: number + aggregateID: "sessionID" data: { - message: string + timestamp: number + sessionID: string + reasoningID: string + text: string } } -export type EffectHttpApiErrorForbidden = { - _tag: "Forbidden" +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 EventTuiPromptAppend2 = { +export type SyncEventSessionNextToolInputDelta = { + type: "sync" + name: "session.next.tool.input.delta.1" id: string - type: "tui.prompt.append" - properties: { - text: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + callID: string + delta: string } } -export type EventTuiCommandExecute2 = { +export type SyncEventSessionNextToolInputEnded = { + type: "sync" + name: "session.next.tool.input.ended.1" 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 + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + callID: string + text: string } } -export type EventTuiToastShow2 = { +export type SyncEventSessionNextToolCalled = { + type: "sync" + name: "session.next.tool.called.1" id: string - type: "tui.toast.show" - properties: { - title?: string - message: string - variant: "info" | "success" | "warning" | "error" - duration?: number + 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 EventTuiSessionSelect2 = { +export type SyncEventSessionNextToolProgress = { + type: "sync" + name: "session.next.tool.progress.1" id: string - type: "tui.session.select" - properties: { - /** - * Session ID to navigate to - */ + seq: number + aggregateID: "sessionID" + data: { + timestamp: number sessionID: string + callID: string + structured: { + [key: string]: unknown + } + content: Array } } -export type ModelV2Info = { +export type SyncEventSessionNextToolSuccess = { + type: "sync" + name: "session.next.tool.success.1" id: string - apiID: string - providerID: string - family?: string - name: string - endpoint: - | { - type: "unknown" - } - | { - type: "openai/responses" - url: string - websocket?: boolean - } - | { - type: "openai/completions" - url: string - reasoning?: - | { - type: "reasoning_content" - } - | { - type: "reasoning_details" - } - } - | { - type: "anthropic/messages" - url: string - } - | { - type: "aisdk" - package: string - url?: string - } - capabilities: { - tools: boolean - input: Array - output: Array - } - options: { - headers: { - [key: string]: string - } - body: { + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + callID: string + structured: { [key: string]: unknown } - aisdk: { - provider: { - [key: string]: unknown - } - request: { + content: Array + provider: { + executed: boolean + metadata?: { [key: string]: unknown } } - variant?: string } - variants: Array<{ - id: string - headers: { - [key: string]: string - } - body: { - [key: string]: unknown - } - aisdk: { - provider: { - [key: string]: unknown - } - request: { +} + +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 } } - }> - time: { - released: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - } - cost: Array<{ - tier?: { - type: "context" - size: number - } - input: number - output: number - cache: { - read: number - write: number - } - }> - status: "alpha" | "beta" | "deprecated" | "active" - enabled: boolean - limit: { - context: number - input?: number - output: number } } -export type PromptSource = { - start: number - end: number - text: string +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 PromptFileAttachment = { - uri: string - mime: string - name?: string - description?: string - source?: PromptSource +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 PromptAgentAttachment = { - name: string - source?: PromptSource +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 PromptReferenceAttachment = { - name: string - kind: "local" | "git" | "invalid" - uri?: string - repository?: string - branch?: string - target?: string - targetUri?: string - problem?: string - source?: PromptSource +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 SessionErrorUnknown = { - type: "unknown" - message: string +export type SyncEventSessionCreated = { + type: "sync" + name: "session.created.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + sessionID: string + info: Session + } } -export type ToolTextContent = { - type: "text" - text: string +export type SyncEventSessionUpdated = { + type: "sync" + name: "session.updated.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + sessionID: string + info: Session + } } -export type ToolFileContent = { - type: "file" - uri: string - mime: string - name?: string +export type SyncEventSessionDeleted = { + type: "sync" + name: "session.deleted.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + sessionID: string + info: Session + } } -export type SessionNextRetryError = { - message: string - statusCode?: number - isRetryable: boolean - responseHeaders?: { - [key: string]: string - } - responseBody?: string - metadata?: { - [key: string]: string +export type SyncEventMessageUpdated = { + type: "sync" + name: "message.updated.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + sessionID: string + info: Message } } -export type PermissionV2Action = "allow" | "deny" | "ask" +export type SyncEventMessageRemoved = { + type: "sync" + name: "message.removed.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + sessionID: string + messageID: string + } +} -export type PermissionV2Rule = { - permission: string - pattern: string - action: PermissionV2Action +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 PermissionV2Ruleset = Array +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 06b923bd7..c5990ae47 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,2749 +17683,2842 @@ "required": ["name", "data"], "additionalProperties": false }, - "Session1": { + "NotFoundError": { "type": "object", + "required": ["name", "data"], "properties": { - "id": { + "name": { "type": "string", - "pattern": "^ses" - }, - "slug": { - "type": "string" + "enum": ["NotFoundError"] }, - "projectID": { - "type": "string" + "data": { + "type": "object", + "required": ["message"], + "properties": { + "message": { + "type": "string" + } + } + } + } + }, + "TextPartInput": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^prt" }, - "workspaceID": { + "type": { "type": "string", - "pattern": "^wrk" + "enum": ["text"] }, - "directory": { + "text": { "type": "string" }, - "path": { - "type": "string" + "synthetic": { + "type": "boolean" }, - "parentID": { - "type": "string", - "pattern": "^ses" + "ignored": { + "type": "boolean" }, - "summary": { + "time": { "type": "object", "properties": { - "additions": { - "type": "number" - }, - "deletions": { - "type": "number" - }, - "files": { - "type": "number" + "start": { + "type": "integer", + "minimum": 0 }, - "diffs": { - "type": "array", - "items": { - "$ref": "#/components/schemas/SnapshotFileDiff" - } + "end": { + "type": "integer", + "minimum": 0 } }, - "required": ["additions", "deletions", "files"], + "required": ["start"], "additionalProperties": false }, - "cost": { - "type": "number" + "metadata": { + "type": "object" + } + }, + "required": ["type", "text"], + "additionalProperties": false + }, + "FilePartInput": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^prt" }, - "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 + "type": { + "type": "string", + "enum": ["file"] }, - "share": { - "type": "object", - "properties": { - "url": { - "type": "string" - } - }, - "required": ["url"], - "additionalProperties": false + "mime": { + "type": "string" }, - "title": { + "filename": { "type": "string" }, - "agent": { + "url": { "type": "string" }, - "model": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "providerID": { - "type": "string" - }, - "variant": { - "type": "string" - } - }, - "required": ["id", "providerID"], - "additionalProperties": false + "source": { + "$ref": "#/components/schemas/FilePartSource" + } + }, + "required": ["type", "mime", "url"], + "additionalProperties": false + }, + "AgentPartInput": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^prt" }, - "version": { - "type": "string" + "type": { + "type": "string", + "enum": ["agent"] }, - "metadata": { - "type": "object" + "name": { + "type": "string" }, - "time": { + "source": { "type": "object", "properties": { - "created": { - "type": "integer", - "minimum": 0 + "value": { + "type": "string" }, - "updated": { + "start": { "type": "integer", "minimum": 0 }, - "compacting": { + "end": { "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"], + "required": ["value", "start", "end"], "additionalProperties": false } }, - "required": ["id", "slug", "projectID", "directory", "title", "version", "time"], + "required": ["type", "name"], "additionalProperties": false }, - "Session2": { + "SubtaskPartInput": { "type": "object", "properties": { "id": { "type": "string", - "pattern": "^ses" - }, - "slug": { - "type": "string" - }, - "projectID": { - "type": "string" + "pattern": "^prt" }, - "workspaceID": { + "type": { "type": "string", - "pattern": "^wrk" + "enum": ["subtask"] }, - "directory": { + "prompt": { "type": "string" }, - "path": { + "description": { "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" + "agent": { + "type": "string" }, - "tokens": { + "model": { "type": "object", "properties": { - "input": { - "type": "number" - }, - "output": { - "type": "number" - }, - "reasoning": { - "type": "number" + "providerID": { + "type": "string" }, - "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": { + "modelID": { "type": "string" } }, - "required": ["url"], + "required": ["providerID", "modelID"], "additionalProperties": false }, - "title": { + "command": { "type": "string" + } + }, + "required": ["type", "prompt", "description", "agent"], + "additionalProperties": false + }, + "SessionBusyError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": ["SessionBusyError"] }, - "agent": { + "sessionID": { "type": "string" }, - "model": { + "message": { + "type": "string" + } + }, + "required": ["_tag", "sessionID", "message"], + "additionalProperties": false + }, + "V2SessionsResponse": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SessionInfo" + } + }, + "cursor": { "type": "object", "properties": { - "id": { - "type": "string" - }, - "providerID": { + "previous": { "type": "string" }, - "variant": { + "next": { "type": "string" } }, - "required": ["id", "providerID"], "additionalProperties": false + } + }, + "required": ["items", "cursor"], + "additionalProperties": false + }, + "InvalidCursorError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": ["InvalidCursorError"] }, - "version": { + "message": { "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"], + "required": ["_tag", "message"], "additionalProperties": false }, - "NotFoundError": { + "UnauthorizedError": { "type": "object", - "required": ["name", "data"], "properties": { - "name": { + "_tag": { "type": "string", - "enum": ["NotFoundError"] + "enum": ["UnauthorizedError"] }, - "data": { - "type": "object", - "required": ["message"], - "properties": { - "message": { - "type": "string" - } - } + "message": { + "type": "string" } - } + }, + "required": ["_tag", "message"], + "additionalProperties": false }, - "Session3": { + "SessionNotFoundError": { "type": "object", "properties": { - "id": { + "_tag": { "type": "string", - "pattern": "^ses" + "enum": ["SessionNotFoundError"] }, - "slug": { + "sessionID": { "type": "string" }, - "projectID": { + "message": { "type": "string" - }, - "workspaceID": { + } + }, + "required": ["_tag", "sessionID", "message"], + "additionalProperties": false + }, + "ServiceUnavailableError": { + "type": "object", + "properties": { + "_tag": { "type": "string", - "pattern": "^wrk" + "enum": ["ServiceUnavailableError"] }, - "directory": { + "message": { "type": "string" }, - "path": { + "service": { "type": "string" - }, - "parentID": { + } + }, + "required": ["_tag", "message"], + "additionalProperties": false + }, + "UnknownError1": { + "type": "object", + "properties": { + "_tag": { "type": "string", - "pattern": "^ses" + "enum": ["UnknownError"] }, - "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 + "message": { + "type": "string" }, - "cost": { - "type": "number" + "ref": { + "type": "string" + } + }, + "required": ["_tag", "message"], + "additionalProperties": false + }, + "V2SessionMessagesResponse": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SessionMessage" + } }, - "tokens": { + "cursor": { "type": "object", "properties": { - "input": { - "type": "number" - }, - "output": { - "type": "number" - }, - "reasoning": { - "type": "number" + "previous": { + "type": "string" }, - "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": { + "next": { "type": "string" } }, - "required": ["url"], "additionalProperties": false + } + }, + "required": ["items", "cursor"], + "additionalProperties": false + }, + "ProviderNotFoundError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": ["ProviderNotFoundError"] }, - "title": { + "providerID": { "type": "string" }, - "agent": { + "message": { "type": "string" + } + }, + "required": ["_tag", "providerID", "message"], + "additionalProperties": false + }, + "EventTuiPromptAppend": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["tui.prompt.append"] }, - "model": { + "properties": { "type": "object", "properties": { - "id": { - "type": "string" - }, - "providerID": { - "type": "string" - }, - "variant": { + "text": { "type": "string" } }, - "required": ["id", "providerID"], + "required": ["text"], "additionalProperties": false + } + }, + "required": ["type", "properties"], + "additionalProperties": false + }, + "EventTuiCommandExecute": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["tui.command.execute"] }, - "version": { - "type": "string" - }, - "metadata": { - "type": "object" - }, - "time": { + "properties": { "type": "object", "properties": { - "created": { - "type": "integer", - "minimum": 0 - }, - "updated": { - "type": "integer", - "minimum": 0 - }, - "compacting": { - "type": "integer", - "minimum": 0 - }, - "archived": { - "type": "number" - } - }, - "required": ["created", "updated"], + "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": ["type", "properties"], + "additionalProperties": false + }, + "EventTuiToastShow": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["tui.toast.show"] }, - "permission": { - "$ref": "#/components/schemas/PermissionRuleset" - }, - "revert": { + "properties": { "type": "object", "properties": { - "messageID": { - "type": "string", - "pattern": "^msg" - }, - "partID": { - "type": "string", - "pattern": "^prt" - }, - "snapshot": { + "title": { "type": "string" }, - "diff": { + "message": { "type": "string" + }, + "variant": { + "type": "string", + "enum": ["info", "success", "warning", "error"] + }, + "duration": { + "type": "integer", + "exclusiveMinimum": 0 } }, - "required": ["messageID"], + "required": ["message", "variant"], "additionalProperties": false } }, - "required": ["id", "slug", "projectID", "directory", "title", "version", "time"], + "required": ["type", "properties"], + "additionalProperties": false + }, + "EventTuiSessionSelect": { + "type": "object", + "properties": { + "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": ["type", "properties"], "additionalProperties": false }, - "Session4": { + "Workspace": { "type": "object", "properties": { "id": { "type": "string", - "pattern": "^ses" + "pattern": "^wrk" }, - "slug": { + "type": { "type": "string" }, - "projectID": { + "name": { "type": "string" }, - "workspaceID": { - "type": "string", - "pattern": "^wrk" + "branch": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] }, "directory": { - "type": "string" + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] }, - "path": { - "type": "string" + "extra": { + "anyOf": [ + {}, + { + "type": "null" + } + ] }, - "parentID": { - "type": "string", - "pattern": "^ses" + "projectID": { + "type": "string" }, - "summary": { - "type": "object", - "properties": { - "additions": { + "timeUsed": { + "anyOf": [ + { "type": "number" }, - "deletions": { - "type": "number" + { + "type": "string", + "enum": ["NaN"] }, - "files": { - "type": "number" + { + "type": "string", + "enum": ["Infinity"] }, - "diffs": { - "type": "array", - "items": { - "$ref": "#/components/schemas/SnapshotFileDiff" - } + { + "type": "string", + "enum": ["-Infinity"] + }, + { + "type": "string", + "enum": ["Infinity", "-Infinity", "NaN"] } - }, - "required": ["additions", "deletions", "files"], - "additionalProperties": false - }, - "cost": { - "type": "number" + ] + } + }, + "required": ["id", "type", "name", "projectID", "timeUsed"], + "additionalProperties": false + }, + "WorkspaceCreateError": { + "type": "object", + "properties": { + "name": { + "type": "string", + "enum": ["WorkspaceCreateError"] }, - "tokens": { + "data": { "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 + "message": { + "type": "string" } }, - "required": ["input", "output", "reasoning", "cache"], + "required": ["message"], "additionalProperties": false + } + }, + "required": ["name", "data"], + "additionalProperties": false + }, + "WorkspaceWarpError": { + "type": "object", + "properties": { + "name": { + "type": "string", + "enum": ["WorkspaceWarpError"] }, - "share": { + "data": { "type": "object", "properties": { - "url": { + "message": { "type": "string" } }, - "required": ["url"], + "required": ["message"], "additionalProperties": false - }, - "title": { + } + }, + "required": ["name", "data"], + "additionalProperties": false + }, + "effect_HttpApiError_Forbidden": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": ["Forbidden"] + } + }, + "required": ["_tag"], + "additionalProperties": false + }, + "Event.tui.prompt.append": { + "type": "object", + "properties": { + "id": { "type": "string" }, - "agent": { - "type": "string" + "type": { + "type": "string", + "enum": ["tui.prompt.append"] }, - "model": { + "properties": { "type": "object", "properties": { - "id": { - "type": "string" - }, - "providerID": { - "type": "string" - }, - "variant": { + "text": { "type": "string" } }, - "required": ["id", "providerID"], + "required": ["text"], "additionalProperties": false - }, - "version": { + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "Event.tui.command.execute": { + "type": "object", + "properties": { + "id": { "type": "string" }, - "metadata": { - "type": "object" + "type": { + "type": "string", + "enum": ["tui.command.execute"] }, - "time": { + "properties": { "type": "object", "properties": { - "created": { - "type": "integer", - "minimum": 0 - }, - "updated": { - "type": "integer", - "minimum": 0 - }, - "compacting": { - "type": "integer", - "minimum": 0 - }, - "archived": { - "type": "number" + "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": ["created", "updated"], + "required": ["command"], "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "Event.tui.toast.show": { + "type": "object", + "properties": { + "id": { + "type": "string" }, - "permission": { - "$ref": "#/components/schemas/PermissionRuleset" + "type": { + "type": "string", + "enum": ["tui.toast.show"] }, - "revert": { + "properties": { "type": "object", "properties": { - "messageID": { - "type": "string", - "pattern": "^msg" - }, - "partID": { - "type": "string", - "pattern": "^prt" - }, - "snapshot": { + "title": { "type": "string" }, - "diff": { + "message": { "type": "string" + }, + "variant": { + "type": "string", + "enum": ["info", "success", "warning", "error"] + }, + "duration": { + "type": "integer", + "exclusiveMinimum": 0 } }, - "required": ["messageID"], + "required": ["message", "variant"], "additionalProperties": false } }, - "required": ["id", "slug", "projectID", "directory", "title", "version", "time"], + "required": ["id", "type", "properties"], "additionalProperties": false }, - "Session5": { + "Event.tui.session.select": { "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": { "type": "string", - "pattern": "^ses" + "enum": ["tui.session.select"] }, - "summary": { + "properties": { "type": "object", "properties": { - "additions": { - "type": "number" - }, - "deletions": { - "type": "number" - }, - "files": { - "type": "number" - }, - "diffs": { - "type": "array", - "items": { - "$ref": "#/components/schemas/SnapshotFileDiff" - } + "sessionID": { + "type": "string", + "pattern": "^ses", + "description": "Session ID to navigate to" } }, - "required": ["additions", "deletions", "files"], + "required": ["sessionID"], "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "ModelV2Info": { + "type": "object", + "properties": { + "id": { + "type": "string" }, - "cost": { - "type": "number" + "apiID": { + "type": "string" }, - "tokens": { - "type": "object", - "properties": { - "input": { - "type": "number" - }, - "output": { - "type": "number" - }, - "reasoning": { - "type": "number" + "providerID": { + "type": "string" + }, + "family": { + "type": "string" + }, + "name": { + "type": "string" + }, + "endpoint": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["unknown"] + } + }, + "required": ["type"], + "additionalProperties": false }, - "cache": { + { "type": "object", "properties": { - "read": { - "type": "number" + "type": { + "type": "string", + "enum": ["openai/responses"] }, - "write": { - "type": "number" + "url": { + "type": "string" + }, + "websocket": { + "type": "boolean" } }, - "required": ["read", "write"], + "required": ["type", "url"], "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" + "type": { + "type": "string", + "enum": ["openai/completions"] }, - "write": { - "type": "number" + "url": { + "type": "string" + }, + "reasoning": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["reasoning_content"] + } + }, + "required": ["type"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["reasoning_details"] + } + }, + "required": ["type"], + "additionalProperties": false + } + ] } }, - "required": ["read", "write"], + "required": ["type", "url"], "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" + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["anthropic/messages"] + }, + "url": { + "type": "string" + } + }, + "required": ["type", "url"], + "additionalProperties": false }, - "cache": { + { "type": "object", "properties": { - "read": { - "type": "number" + "type": { + "type": "string", + "enum": ["aisdk"] }, - "write": { - "type": "number" + "package": { + "type": "string" + }, + "url": { + "type": "string" } }, - "required": ["read", "write"], + "required": ["type", "package"], "additionalProperties": false } - }, - "required": ["input", "output", "reasoning", "cache"], - "additionalProperties": false + ] }, - "share": { + "capabilities": { "type": "object", "properties": { - "url": { - "type": "string" + "tools": { + "type": "boolean" + }, + "input": { + "type": "array", + "items": { + "type": "string" + } + }, + "output": { + "type": "array", + "items": { + "type": "string" + } } }, - "required": ["url"], + "required": ["tools", "input", "output"], "additionalProperties": false }, - "title": { - "type": "string" - }, - "agent": { - "type": "string" - }, - "model": { + "options": { "type": "object", "properties": { - "id": { - "type": "string" + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + } }, - "providerID": { - "type": "string" + "body": { + "type": "object" + }, + "aisdk": { + "type": "object", + "properties": { + "provider": { + "type": "object" + }, + "request": { + "type": "object" + } + }, + "required": ["provider", "request"], + "additionalProperties": false }, "variant": { "type": "string" } }, - "required": ["id", "providerID"], + "required": ["headers", "body", "aisdk"], "additionalProperties": false }, - "version": { - "type": "string" - }, - "metadata": { - "type": "object" + "variants": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "body": { + "type": "object" + }, + "aisdk": { + "type": "object", + "properties": { + "provider": { + "type": "object" + }, + "request": { + "type": "object" + } + }, + "required": ["provider", "request"], + "additionalProperties": false + } + }, + "required": ["id", "headers", "body", "aisdk"], + "additionalProperties": false + } }, "time": { "type": "object", "properties": { - "created": { - "type": "integer", - "minimum": 0 - }, - "updated": { - "type": "integer", - "minimum": 0 - }, - "compacting": { - "type": "integer", - "minimum": 0 - }, - "archived": { - "type": "number" + "released": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + }, + { + "type": "string", + "enum": ["Infinity", "-Infinity", "NaN"] + } + ] } }, - "required": ["created", "updated"], + "required": ["released"], "additionalProperties": false }, - "permission": { - "$ref": "#/components/schemas/PermissionRuleset" - }, - "revert": { - "type": "object", - "properties": { - "messageID": { - "type": "string", - "pattern": "^msg" - }, - "partID": { - "type": "string", - "pattern": "^prt" - }, - "snapshot": { - "type": "string" + "cost": { + "type": "array", + "items": { + "type": "object", + "properties": { + "tier": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["context"] + }, + "size": { + "type": "integer" + } + }, + "required": ["type", "size"], + "additionalProperties": false + }, + "input": { + "type": "number" + }, + "output": { + "type": "number" + }, + "cache": { + "type": "object", + "properties": { + "read": { + "type": "number" + }, + "write": { + "type": "number" + } + }, + "required": ["read", "write"], + "additionalProperties": false + } }, - "diff": { - "type": "string" - } - }, - "required": ["messageID"], - "additionalProperties": false - } - }, - "required": ["id", "slug", "projectID", "directory", "title", "version", "time"], - "additionalProperties": false - }, - "TextPartInput": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^prt" + "required": ["input", "output", "cache"], + "additionalProperties": false + } }, - "type": { + "status": { "type": "string", - "enum": ["text"] - }, - "text": { - "type": "string" - }, - "synthetic": { - "type": "boolean" + "enum": ["alpha", "beta", "deprecated", "active"] }, - "ignored": { + "enabled": { "type": "boolean" }, - "time": { + "limit": { "type": "object", "properties": { - "start": { - "type": "integer", - "minimum": 0 + "context": { + "type": "integer" }, - "end": { - "type": "integer", - "minimum": 0 + "input": { + "type": "integer" + }, + "output": { + "type": "integer" } }, - "required": ["start"], + "required": ["context", "output"], "additionalProperties": false + } + }, + "required": [ + "id", + "apiID", + "providerID", + "name", + "endpoint", + "capabilities", + "options", + "variants", + "time", + "cost", + "status", + "enabled", + "limit" + ], + "additionalProperties": false + }, + "PromptSource": { + "type": "object", + "properties": { + "start": { + "type": "number" }, - "metadata": { - "type": "object" + "end": { + "type": "number" + }, + "text": { + "type": "string" } }, - "required": ["type", "text"], + "required": ["start", "end", "text"], "additionalProperties": false }, - "FilePartInput": { + "PromptFileAttachment": { "type": "object", "properties": { - "id": { - "type": "string", - "pattern": "^prt" - }, - "type": { - "type": "string", - "enum": ["file"] + "uri": { + "type": "string" }, "mime": { "type": "string" }, - "filename": { + "name": { "type": "string" }, - "url": { + "description": { "type": "string" }, "source": { - "$ref": "#/components/schemas/FilePartSource" + "$ref": "#/components/schemas/PromptSource" } }, - "required": ["type", "mime", "url"], + "required": ["uri", "mime"], "additionalProperties": false }, - "AgentPartInput": { + "PromptAgentAttachment": { "type": "object", "properties": { - "id": { - "type": "string", - "pattern": "^prt" - }, - "type": { - "type": "string", - "enum": ["agent"] - }, "name": { "type": "string" }, "source": { - "type": "object", - "properties": { - "value": { - "type": "string" - }, - "start": { - "type": "integer", - "minimum": 0 - }, - "end": { - "type": "integer", - "minimum": 0 - } - }, - "required": ["value", "start", "end"], - "additionalProperties": false + "$ref": "#/components/schemas/PromptSource" } }, - "required": ["type", "name"], + "required": ["name"], "additionalProperties": false }, - "SubtaskPartInput": { + "PromptReferenceAttachment": { "type": "object", "properties": { - "id": { - "type": "string", - "pattern": "^prt" + "name": { + "type": "string" }, - "type": { + "kind": { "type": "string", - "enum": ["subtask"] + "enum": ["local", "git", "invalid"] }, - "prompt": { + "uri": { "type": "string" }, - "description": { + "repository": { "type": "string" }, - "agent": { + "branch": { "type": "string" }, - "model": { - "type": "object", - "properties": { - "providerID": { - "type": "string" - }, - "modelID": { - "type": "string" - } - }, - "required": ["providerID", "modelID"], - "additionalProperties": false + "target": { + "type": "string" }, - "command": { + "targetUri": { + "type": "string" + }, + "problem": { "type": "string" + }, + "source": { + "$ref": "#/components/schemas/PromptSource" } }, - "required": ["type", "prompt", "description", "agent"], + "required": ["name", "kind"], "additionalProperties": false }, - "SessionBusyError": { + "SessionErrorUnknown": { "type": "object", "properties": { - "_tag": { + "type": { "type": "string", - "enum": ["SessionBusyError"] - }, - "sessionID": { - "type": "string" + "enum": ["unknown"] }, "message": { "type": "string" } }, - "required": ["_tag", "sessionID", "message"], + "required": ["type", "message"], "additionalProperties": false }, - "Session8": { + "ToolTextContent": { "type": "object", "properties": { - "id": { + "type": { "type": "string", - "pattern": "^ses" + "enum": ["text"] }, - "slug": { + "text": { "type": "string" + } + }, + "required": ["type", "text"], + "additionalProperties": false + }, + "ToolFileContent": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["file"] }, - "projectID": { + "uri": { "type": "string" }, - "workspaceID": { - "type": "string", - "pattern": "^wrk" - }, - "directory": { + "mime": { "type": "string" }, - "path": { + "name": { + "type": "string" + } + }, + "required": ["type", "uri", "mime"], + "additionalProperties": false + }, + "SessionNextRetry_error": { + "type": "object", + "properties": { + "message": { "type": "string" }, - "parentID": { - "type": "string", - "pattern": "^ses" + "statusCode": { + "type": "number" }, - "summary": { + "isRetryable": { + "type": "boolean" + }, + "responseHeaders": { "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 + "additionalProperties": { + "type": "string" + } }, - "cost": { - "type": "number" + "responseBody": { + "type": "string" }, - "tokens": { + "metadata": { "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 + "additionalProperties": { + "type": "string" + } + } + }, + "required": ["message", "isRetryable"], + "additionalProperties": false + }, + "AuthOAuthCredential": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["oauth"] }, - "share": { - "type": "object", - "properties": { - "url": { - "type": "string" - } - }, - "required": ["url"], - "additionalProperties": false + "refresh": { + "type": "string" }, - "title": { + "access": { "type": "string" }, - "agent": { + "expires": { + "type": "integer", + "minimum": 0 + } + }, + "required": ["type", "refresh", "access", "expires"], + "additionalProperties": false + }, + "AuthApiKeyCredential": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["api"] + }, + "key": { "type": "string" }, - "model": { + "metadata": { "type": "object", - "properties": { - "id": { - "type": "string" - }, - "providerID": { - "type": "string" - }, - "variant": { - "type": "string" - } - }, - "required": ["id", "providerID"], - "additionalProperties": false + "additionalProperties": { + "type": "string" + } + } + }, + "required": ["type", "key"], + "additionalProperties": false + }, + "AuthCredential": { + "anyOf": [ + { + "$ref": "#/components/schemas/AuthOAuthCredential" }, - "version": { + { + "$ref": "#/components/schemas/AuthApiKeyCredential" + } + ] + }, + "AuthInfo": { + "type": "object", + "properties": { + "id": { "type": "string" }, - "metadata": { - "type": "object" + "serviceID": { + "type": "string" }, - "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 + "description": { + "type": "string" }, - "permission": { - "$ref": "#/components/schemas/PermissionRuleset" + "credential": { + "$ref": "#/components/schemas/AuthCredential" + } + }, + "required": ["id", "serviceID", "description", "credential"], + "additionalProperties": false + }, + "EventServerInstanceDisposed": { + "type": "object", + "properties": { + "id": { + "type": "string" }, - "revert": { + "type": { + "type": "string", + "enum": ["server.instance.disposed"] + }, + "properties": { "type": "object", "properties": { - "messageID": { - "type": "string", - "pattern": "^msg" - }, - "partID": { - "type": "string", - "pattern": "^prt" - }, - "snapshot": { - "type": "string" - }, - "diff": { + "directory": { "type": "string" } }, - "required": ["messageID"], + "required": ["directory"], "additionalProperties": false } }, - "required": ["id", "slug", "projectID", "directory", "title", "version", "time"], + "required": ["id", "type", "properties"], "additionalProperties": false }, - "Session9": { + "SyncEventSessionNextAgentSwitched": { "type": "object", "properties": { - "id": { + "type": { "type": "string", - "pattern": "^ses" - }, - "slug": { - "type": "string" - }, - "projectID": { - "type": "string" + "enum": ["sync"] }, - "workspaceID": { + "name": { "type": "string", - "pattern": "^wrk" + "enum": ["session.next.agent.switched.1"] }, - "directory": { + "id": { "type": "string" }, - "path": { - "type": "string" + "seq": { + "type": "number" }, - "parentID": { + "aggregateID": { "type": "string", - "pattern": "^ses" + "enum": ["sessionID"] }, - "summary": { + "data": { "type": "object", "properties": { - "additions": { - "type": "number" - }, - "deletions": { + "timestamp": { "type": "number" }, - "files": { - "type": "number" + "sessionID": { + "type": "string", + "pattern": "^ses" }, - "diffs": { - "type": "array", - "items": { - "$ref": "#/components/schemas/SnapshotFileDiff" - } + "agent": { + "type": "string" } }, - "required": ["additions", "deletions", "files"], + "required": ["timestamp", "sessionID", "agent"], "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionNextModelSwitched": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] }, - "cost": { + "name": { + "type": "string", + "enum": ["session.next.model.switched.1"] + }, + "id": { + "type": "string" + }, + "seq": { "type": "number" }, - "tokens": { + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { "type": "object", "properties": { - "input": { - "type": "number" - }, - "output": { + "timestamp": { "type": "number" }, - "reasoning": { - "type": "number" + "sessionID": { + "type": "string", + "pattern": "^ses" }, - "cache": { + "model": { "type": "object", "properties": { - "read": { - "type": "number" + "id": { + "type": "string" }, - "write": { - "type": "number" + "providerID": { + "type": "string" + }, + "variant": { + "type": "string" } }, - "required": ["read", "write"], + "required": ["id", "providerID"], "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"], + "required": ["timestamp", "sessionID", "model"], "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionNextPrompted": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] }, - "permission": { - "$ref": "#/components/schemas/PermissionRuleset" + "name": { + "type": "string", + "enum": ["session.next.prompted.1"] }, - "revert": { + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { "type": "object", "properties": { - "messageID": { - "type": "string", - "pattern": "^msg" + "timestamp": { + "type": "number" }, - "partID": { + "sessionID": { "type": "string", - "pattern": "^prt" - }, - "snapshot": { - "type": "string" + "pattern": "^ses" }, - "diff": { - "type": "string" + "prompt": { + "$ref": "#/components/schemas/Prompt" } }, - "required": ["messageID"], + "required": ["timestamp", "sessionID", "prompt"], "additionalProperties": false } }, - "required": ["id", "slug", "projectID", "directory", "title", "version", "time"], + "required": ["type", "name", "id", "seq", "aggregateID", "data"], "additionalProperties": false }, - "V2SessionsResponse": { + "SyncEventSessionNextSynthetic": { "type": "object", "properties": { - "items": { - "type": "array", - "items": { - "$ref": "#/components/schemas/SessionInfo" - } + "type": { + "type": "string", + "enum": ["sync"] }, - "cursor": { + "name": { + "type": "string", + "enum": ["session.next.synthetic.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { "type": "object", "properties": { - "previous": { - "type": "string" + "timestamp": { + "type": "number" }, - "next": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "text": { "type": "string" } }, + "required": ["timestamp", "sessionID", "text"], "additionalProperties": false } }, - "required": ["items", "cursor"], + "required": ["type", "name", "id", "seq", "aggregateID", "data"], "additionalProperties": false }, - "InvalidCursorError": { + "SyncEventSessionNextShellStarted": { "type": "object", "properties": { - "_tag": { + "type": { "type": "string", - "enum": ["InvalidCursorError"] + "enum": ["sync"] }, - "message": { - "type": "string" - } - }, - "required": ["_tag", "message"], - "additionalProperties": false - }, - "UnauthorizedError": { - "type": "object", - "properties": { - "_tag": { + "name": { "type": "string", - "enum": ["UnauthorizedError"] + "enum": ["session.next.shell.started.1"] }, - "message": { + "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": ["_tag", "message"], + "required": ["type", "name", "id", "seq", "aggregateID", "data"], "additionalProperties": false }, - "SessionNotFoundError": { + "SyncEventSessionNextShellEnded": { "type": "object", "properties": { - "_tag": { + "type": { "type": "string", - "enum": ["SessionNotFoundError"] + "enum": ["sync"] }, - "sessionID": { - "type": "string" + "name": { + "type": "string", + "enum": ["session.next.shell.ended.1"] }, - "message": { + "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": ["_tag", "sessionID", "message"], + "required": ["type", "name", "id", "seq", "aggregateID", "data"], "additionalProperties": false }, - "ServiceUnavailableError": { + "SyncEventSessionNextStepStarted": { "type": "object", "properties": { - "_tag": { + "type": { "type": "string", - "enum": ["ServiceUnavailableError"] + "enum": ["sync"] }, - "message": { - "type": "string" + "name": { + "type": "string", + "enum": ["session.next.step.started.1"] }, - "service": { + "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": ["_tag", "message"], + "required": ["type", "name", "id", "seq", "aggregateID", "data"], "additionalProperties": false }, - "UnknownError1": { + "SyncEventSessionNextStepEnded": { "type": "object", "properties": { - "_tag": { + "type": { "type": "string", - "enum": ["UnknownError"] + "enum": ["sync"] }, - "message": { - "type": "string" + "name": { + "type": "string", + "enum": ["session.next.step.ended.1"] }, - "ref": { + "id": { "type": "string" - } - }, - "required": ["_tag", "message"], - "additionalProperties": false - }, - "V2SessionMessagesResponse": { - "type": "object", - "properties": { - "items": { - "type": "array", - "items": { - "$ref": "#/components/schemas/SessionMessage" - } }, - "cursor": { + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { "type": "object", "properties": { - "previous": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "finish": { "type": "string" }, - "next": { + "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": ["items", "cursor"], + "required": ["type", "name", "id", "seq", "aggregateID", "data"], "additionalProperties": false }, - "ProviderNotFoundError": { + "SyncEventSessionNextStepFailed": { "type": "object", "properties": { - "_tag": { + "type": { "type": "string", - "enum": ["ProviderNotFoundError"] + "enum": ["sync"] }, - "providerID": { - "type": "string" + "name": { + "type": "string", + "enum": ["session.next.step.failed.1"] }, - "message": { + "id": { "type": "string" - } - }, - "required": ["_tag", "providerID", "message"], - "additionalProperties": false - }, - "EventTuiPromptAppend": { - "type": "object", - "properties": { - "type": { + }, + "seq": { + "type": "number" + }, + "aggregateID": { "type": "string", - "enum": ["tui.prompt.append"] + "enum": ["sessionID"] }, - "properties": { + "data": { "type": "object", "properties": { - "text": { - "type": "string" + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "error": { + "$ref": "#/components/schemas/SessionErrorUnknown" } }, - "required": ["text"], + "required": ["timestamp", "sessionID", "error"], "additionalProperties": false } }, - "required": ["type", "properties"], + "required": ["type", "name", "id", "seq", "aggregateID", "data"], "additionalProperties": false }, - "EventTuiCommandExecute": { + "SyncEventSessionNextTextStarted": { "type": "object", "properties": { "type": { "type": "string", - "enum": ["tui.command.execute"] + "enum": ["sync"] }, - "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": ["type", "properties"], - "additionalProperties": false - }, - "EventTuiToastShow": { - "type": "object", - "properties": { - "type": { + "name": { "type": "string", - "enum": ["tui.toast.show"] + "enum": ["session.next.text.started.1"] }, - "properties": { + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { "type": "object", "properties": { - "title": { - "type": "string" - }, - "message": { - "type": "string" + "timestamp": { + "type": "number" }, - "variant": { + "sessionID": { "type": "string", - "enum": ["info", "success", "warning", "error"] - }, - "duration": { - "type": "integer", - "exclusiveMinimum": 0 + "pattern": "^ses" } }, - "required": ["message", "variant"], + "required": ["timestamp", "sessionID"], "additionalProperties": false } }, - "required": ["type", "properties"], + "required": ["type", "name", "id", "seq", "aggregateID", "data"], "additionalProperties": false }, - "EventTuiSessionSelect": { + "SyncEventSessionNextTextDelta": { "type": "object", "properties": { "type": { "type": "string", - "enum": ["tui.session.select"] + "enum": ["sync"] }, - "properties": { + "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", - "description": "Session ID to navigate to" + "pattern": "^ses" + }, + "delta": { + "type": "string" } }, - "required": ["sessionID"], + "required": ["timestamp", "sessionID", "delta"], "additionalProperties": false } }, - "required": ["type", "properties"], + "required": ["type", "name", "id", "seq", "aggregateID", "data"], "additionalProperties": false }, - "Workspace": { + "SyncEventSessionNextTextEnded": { "type": "object", "properties": { - "id": { - "type": "string", - "pattern": "^wrk" - }, "type": { - "type": "string" + "type": "string", + "enum": ["sync"] }, "name": { - "type": "string" - }, - "branch": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] + "type": "string", + "enum": ["session.next.text.ended.1"] }, - "directory": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] + "id": { + "type": "string" }, - "extra": { - "anyOf": [ - {}, - { - "type": "null" - } - ] + "seq": { + "type": "number" }, - "projectID": { - "type": "string" + "aggregateID": { + "type": "string", + "enum": ["sessionID"] }, - "timeUsed": { - "anyOf": [ - { + "data": { + "type": "object", + "properties": { + "timestamp": { "type": "number" }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { + "sessionID": { "type": "string", - "enum": ["-Infinity"] + "pattern": "^ses" }, - { - "type": "string", - "enum": ["Infinity", "-Infinity", "NaN"] + "text": { + "type": "string" } - ] + }, + "required": ["timestamp", "sessionID", "text"], + "additionalProperties": false } }, - "required": ["id", "type", "name", "projectID", "timeUsed"], + "required": ["type", "name", "id", "seq", "aggregateID", "data"], "additionalProperties": false }, - "WorkspaceCreateError": { + "SyncEventSessionNextReasoningStarted": { "type": "object", "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, "name": { "type": "string", - "enum": ["WorkspaceCreateError"] + "enum": ["session.next.reasoning.started.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] }, "data": { "type": "object", "properties": { - "message": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "reasoningID": { "type": "string" } }, - "required": ["message"], + "required": ["timestamp", "sessionID", "reasoningID"], "additionalProperties": false } }, - "required": ["name", "data"], + "required": ["type", "name", "id", "seq", "aggregateID", "data"], "additionalProperties": false }, - "WorkspaceWarpError": { + "SyncEventSessionNextReasoningDelta": { "type": "object", "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, "name": { "type": "string", - "enum": ["WorkspaceWarpError"] + "enum": ["session.next.reasoning.delta.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] }, "data": { "type": "object", "properties": { - "message": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "reasoningID": { + "type": "string" + }, + "delta": { "type": "string" } }, - "required": ["message"], + "required": ["timestamp", "sessionID", "reasoningID", "delta"], "additionalProperties": false } }, - "required": ["name", "data"], + "required": ["type", "name", "id", "seq", "aggregateID", "data"], "additionalProperties": false }, - "effect_HttpApiError_Forbidden": { + "SyncEventSessionNextReasoningEnded": { "type": "object", "properties": { - "_tag": { + "type": { "type": "string", - "enum": ["Forbidden"] - } - }, - "required": ["_tag"], - "additionalProperties": false - }, - "Event.tui.prompt.append": { - "type": "object", - "properties": { + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.reasoning.ended.1"] + }, "id": { "type": "string" }, - "type": { + "seq": { + "type": "number" + }, + "aggregateID": { "type": "string", - "enum": ["tui.prompt.append"] + "enum": ["sessionID"] }, - "properties": { + "data": { "type": "object", "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "reasoningID": { + "type": "string" + }, "text": { "type": "string" } }, - "required": ["text"], + "required": ["timestamp", "sessionID", "reasoningID", "text"], "additionalProperties": false } }, - "required": ["id", "type", "properties"], + "required": ["type", "name", "id", "seq", "aggregateID", "data"], "additionalProperties": false }, - "Event.tui.command.execute": { + "SyncEventSessionNextToolInputStarted": { "type": "object", "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.tool.input.started.1"] + }, "id": { "type": "string" }, - "type": { + "seq": { + "type": "number" + }, + "aggregateID": { "type": "string", - "enum": ["tui.command.execute"] + "enum": ["sessionID"] }, - "properties": { + "data": { "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" - } - ] + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "callID": { + "type": "string" + }, + "name": { + "type": "string" } }, - "required": ["command"], + "required": ["timestamp", "sessionID", "callID", "name"], "additionalProperties": false } }, - "required": ["id", "type", "properties"], + "required": ["type", "name", "id", "seq", "aggregateID", "data"], "additionalProperties": false }, - "Event.tui.toast.show": { + "SyncEventSessionNextToolInputDelta": { "type": "object", "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.tool.input.delta.1"] + }, "id": { "type": "string" }, - "type": { + "seq": { + "type": "number" + }, + "aggregateID": { "type": "string", - "enum": ["tui.toast.show"] + "enum": ["sessionID"] }, - "properties": { + "data": { "type": "object", "properties": { - "title": { - "type": "string" - }, - "message": { - "type": "string" + "timestamp": { + "type": "number" }, - "variant": { + "sessionID": { "type": "string", - "enum": ["info", "success", "warning", "error"] + "pattern": "^ses" }, - "duration": { - "type": "integer", - "exclusiveMinimum": 0 + "callID": { + "type": "string" + }, + "delta": { + "type": "string" } }, - "required": ["message", "variant"], + "required": ["timestamp", "sessionID", "callID", "delta"], "additionalProperties": false } }, - "required": ["id", "type", "properties"], + "required": ["type", "name", "id", "seq", "aggregateID", "data"], "additionalProperties": false }, - "Event.tui.session.select": { + "SyncEventSessionNextToolInputEnded": { "type": "object", "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.tool.input.ended.1"] + }, "id": { "type": "string" }, - "type": { + "seq": { + "type": "number" + }, + "aggregateID": { "type": "string", - "enum": ["tui.session.select"] + "enum": ["sessionID"] }, - "properties": { + "data": { "type": "object", "properties": { + "timestamp": { + "type": "number" + }, "sessionID": { "type": "string", - "pattern": "^ses", - "description": "Session ID to navigate to" + "pattern": "^ses" + }, + "callID": { + "type": "string" + }, + "text": { + "type": "string" } }, - "required": ["sessionID"], + "required": ["timestamp", "sessionID", "callID", "text"], "additionalProperties": false } }, - "required": ["id", "type", "properties"], + "required": ["type", "name", "id", "seq", "aggregateID", "data"], "additionalProperties": false }, - "ModelV2Info": { + "SyncEventSessionNextToolCalled": { "type": "object", "properties": { - "id": { - "type": "string" - }, - "apiID": { - "type": "string" - }, - "providerID": { - "type": "string" - }, - "family": { - "type": "string" + "type": { + "type": "string", + "enum": ["sync"] }, "name": { - "type": "string" - }, - "endpoint": { - "anyOf": [ - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["unknown"] - } - }, - "required": ["type"], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["openai/responses"] - }, - "url": { - "type": "string" - }, - "websocket": { - "type": "boolean" - } - }, - "required": ["type", "url"], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["openai/completions"] - }, - "url": { - "type": "string" - }, - "reasoning": { - "anyOf": [ - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["reasoning_content"] - } - }, - "required": ["type"], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["reasoning_details"] - } - }, - "required": ["type"], - "additionalProperties": false - } - ] - } - }, - "required": ["type", "url"], - "additionalProperties": false + "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" }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["anthropic/messages"] - }, - "url": { - "type": "string" - } - }, - "required": ["type", "url"], - "additionalProperties": false + "sessionID": { + "type": "string", + "pattern": "^ses" }, - { + "callID": { + "type": "string" + }, + "tool": { + "type": "string" + }, + "input": { + "type": "object" + }, + "provider": { "type": "object", "properties": { - "type": { - "type": "string", - "enum": ["aisdk"] - }, - "package": { - "type": "string" + "executed": { + "type": "boolean" }, - "url": { - "type": "string" + "metadata": { + "type": "object" } }, - "required": ["type", "package"], + "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"] }, - "capabilities": { + "name": { + "type": "string", + "enum": ["session.next.tool.progress.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { "type": "object", "properties": { - "tools": { - "type": "boolean" + "timestamp": { + "type": "number" }, - "input": { - "type": "array", - "items": { - "type": "string" - } + "sessionID": { + "type": "string", + "pattern": "^ses" }, - "output": { + "callID": { + "type": "string" + }, + "structured": { + "type": "object" + }, + "content": { "type": "array", "items": { - "type": "string" + "anyOf": [ + { + "$ref": "#/components/schemas/ToolTextContent" + }, + { + "$ref": "#/components/schemas/ToolFileContent" + } + ] } } }, - "required": ["tools", "input", "output"], + "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"] }, - "options": { + "name": { + "type": "string", + "enum": ["session.next.tool.success.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { "type": "object", "properties": { - "headers": { - "type": "object", - "additionalProperties": { - "type": "string" - } + "timestamp": { + "type": "number" }, - "body": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "callID": { + "type": "string" + }, + "structured": { "type": "object" }, - "aisdk": { + "content": { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/components/schemas/ToolTextContent" + }, + { + "$ref": "#/components/schemas/ToolFileContent" + } + ] + } + }, + "provider": { "type": "object", "properties": { - "provider": { - "type": "object" + "executed": { + "type": "boolean" }, - "request": { + "metadata": { "type": "object" } }, - "required": ["provider", "request"], + "required": ["executed"], "additionalProperties": false - }, - "variant": { - "type": "string" } }, - "required": ["headers", "body", "aisdk"], + "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"] }, - "variants": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "headers": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, - "body": { - "type": "object" - }, - "aisdk": { - "type": "object", - "properties": { - "provider": { - "type": "object" - }, - "request": { - "type": "object" - } - }, - "required": ["provider", "request"], - "additionalProperties": false - } - }, - "required": ["id", "headers", "body", "aisdk"], - "additionalProperties": false - } + "name": { + "type": "string", + "enum": ["session.next.tool.failed.1"] }, - "time": { + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { "type": "object", "properties": { - "released": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "callID": { + "type": "string" + }, + "error": { + "$ref": "#/components/schemas/SessionErrorUnknown" + }, + "provider": { + "type": "object", + "properties": { + "executed": { + "type": "boolean" }, - { - "type": "string", - "enum": ["Infinity", "-Infinity", "NaN"] + "metadata": { + "type": "object" } - ] + }, + "required": ["executed"], + "additionalProperties": false } }, - "required": ["released"], + "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"] }, - "cost": { - "type": "array", - "items": { - "type": "object", - "properties": { - "tier": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["context"] - }, - "size": { - "type": "integer" - } - }, - "required": ["type", "size"], - "additionalProperties": false - }, - "input": { - "type": "number" - }, - "output": { - "type": "number" - }, - "cache": { - "type": "object", - "properties": { - "read": { - "type": "number" - }, - "write": { - "type": "number" - } - }, - "required": ["read", "write"], - "additionalProperties": false - } - }, - "required": ["input", "output", "cache"], - "additionalProperties": false - } - }, - "status": { + "name": { "type": "string", - "enum": ["alpha", "beta", "deprecated", "active"] + "enum": ["session.next.retried.1"] }, - "enabled": { - "type": "boolean" + "id": { + "type": "string" }, - "limit": { + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { "type": "object", "properties": { - "context": { - "type": "integer" + "timestamp": { + "type": "number" }, - "input": { - "type": "integer" + "sessionID": { + "type": "string", + "pattern": "^ses" }, - "output": { - "type": "integer" + "attempt": { + "type": "number" + }, + "error": { + "$ref": "#/components/schemas/SessionNextRetry_error" } }, - "required": ["context", "output"], + "required": ["timestamp", "sessionID", "attempt", "error"], "additionalProperties": false } }, - "required": [ - "id", - "apiID", - "providerID", - "name", - "endpoint", - "capabilities", - "options", - "variants", - "time", - "cost", - "status", - "enabled", - "limit" - ], + "required": ["type", "name", "id", "seq", "aggregateID", "data"], "additionalProperties": false }, - "PromptSource": { + "SyncEventSessionNextCompactionStarted": { "type": "object", "properties": { - "start": { - "type": "number" + "type": { + "type": "string", + "enum": ["sync"] }, - "end": { - "type": "number" + "name": { + "type": "string", + "enum": ["session.next.compaction.started.1"] }, - "text": { + "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": ["start", "end", "text"], + "required": ["type", "name", "id", "seq", "aggregateID", "data"], "additionalProperties": false }, - "PromptFileAttachment": { + "SyncEventSessionNextCompactionDelta": { "type": "object", "properties": { - "uri": { - "type": "string" - }, - "mime": { - "type": "string" + "type": { + "type": "string", + "enum": ["sync"] }, "name": { - "type": "string" + "type": "string", + "enum": ["session.next.compaction.delta.1"] }, - "description": { + "id": { "type": "string" }, - "source": { - "$ref": "#/components/schemas/PromptSource" + "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": ["uri", "mime"], + "required": ["type", "name", "id", "seq", "aggregateID", "data"], "additionalProperties": false }, - "PromptAgentAttachment": { + "SyncEventSessionNextCompactionEnded": { "type": "object", "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, "name": { + "type": "string", + "enum": ["session.next.compaction.ended.1"] + }, + "id": { "type": "string" }, - "source": { - "$ref": "#/components/schemas/PromptSource" + "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": ["name"], + "required": ["type", "name", "id", "seq", "aggregateID", "data"], "additionalProperties": false }, - "PromptReferenceAttachment": { + "SyncEventSessionCreated": { "type": "object", "properties": { - "name": { - "type": "string" + "type": { + "type": "string", + "enum": ["sync"] }, - "kind": { + "name": { "type": "string", - "enum": ["local", "git", "invalid"] + "enum": ["session.created.1"] }, - "uri": { + "id": { "type": "string" }, - "repository": { - "type": "string" + "seq": { + "type": "number" }, - "branch": { - "type": "string" + "aggregateID": { + "type": "string", + "enum": ["sessionID"] }, - "target": { - "type": "string" + "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"] }, - "targetUri": { + "id": { "type": "string" }, - "problem": { - "type": "string" + "seq": { + "type": "number" }, - "source": { - "$ref": "#/components/schemas/PromptSource" + "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": ["name", "kind"], + "required": ["type", "name", "id", "seq", "aggregateID", "data"], "additionalProperties": false }, - "SessionErrorUnknown": { + "SyncEventSessionDeleted": { "type": "object", "properties": { "type": { "type": "string", - "enum": ["unknown"] + "enum": ["sync"] }, - "message": { + "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", "message"], + "required": ["type", "name", "id", "seq", "aggregateID", "data"], "additionalProperties": false }, - "ToolTextContent": { + "SyncEventMessageUpdated": { "type": "object", "properties": { "type": { "type": "string", - "enum": ["text"] + "enum": ["sync"] }, - "text": { + "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", "text"], + "required": ["type", "name", "id", "seq", "aggregateID", "data"], "additionalProperties": false }, - "ToolFileContent": { + "SyncEventMessageRemoved": { "type": "object", "properties": { "type": { "type": "string", - "enum": ["file"] + "enum": ["sync"] }, - "uri": { - "type": "string" + "name": { + "type": "string", + "enum": ["message.removed.1"] }, - "mime": { + "id": { "type": "string" }, - "name": { - "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", "uri", "mime"], + "required": ["type", "name", "id", "seq", "aggregateID", "data"], "additionalProperties": false }, - "SessionNextRetry_error": { + "SyncEventMessagePartUpdated": { "type": "object", "properties": { - "message": { - "type": "string" + "type": { + "type": "string", + "enum": ["sync"] }, - "statusCode": { - "type": "number" + "name": { + "type": "string", + "enum": ["message.part.updated.1"] }, - "isRetryable": { - "type": "boolean" + "id": { + "type": "string" }, - "responseHeaders": { - "type": "object", - "additionalProperties": { - "type": "string" - } + "seq": { + "type": "number" }, - "responseBody": { - "type": "string" + "aggregateID": { + "type": "string", + "enum": ["sessionID"] }, - "metadata": { + "data": { "type": "object", - "additionalProperties": { - "type": "string" - } + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "part": { + "$ref": "#/components/schemas/Part" + }, + "time": { + "type": "number" + } + }, + "required": ["sessionID", "part", "time"], + "additionalProperties": false } }, - "required": ["message", "isRetryable"], + "required": ["type", "name", "id", "seq", "aggregateID", "data"], "additionalProperties": false }, - "PermissionV2Action": { - "type": "string", - "enum": ["allow", "deny", "ask"] - }, - "PermissionV2Rule": { + "SyncEventMessagePartRemoved": { "type": "object", "properties": { - "permission": { - "type": "string" + "type": { + "type": "string", + "enum": ["sync"] }, - "pattern": { + "name": { + "type": "string", + "enum": ["message.part.removed.1"] + }, + "id": { "type": "string" }, - "action": { - "$ref": "#/components/schemas/PermissionV2Action" + "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": ["permission", "pattern", "action"], + "required": ["type", "name", "id", "seq", "aggregateID", "data"], "additionalProperties": false }, - "PermissionV2Ruleset": { - "type": "array", - "items": { - "$ref": "#/components/schemas/PermissionV2Rule" - } - }, "PolicyEffect": { "type": "string", "enum": ["allow", "deny"] @@ -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 008/412] 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 1a6918b33..4447c6008 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 009/412] 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 4447c6008..1a6918b33 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 010/412] 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 | 430 +++++++++--------- .../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 | 375 ++++++++++++--- .../test/cli/run/prompt.shared.test.ts | 55 --- .../test/cli/run/runtime.boot.test.ts | 58 ++- packages/plugin/package.json | 6 +- 19 files changed, 762 insertions(+), 893 deletions(-) delete mode 100644 packages/opencode/src/cli/cmd/run/keymap.shared.ts diff --git a/bun.lock b/bun.lock index 9b33108f2..74b6df0fc 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 a403ff1e3..0b5119d94 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 cf6822c06..bc2434f3e 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 b38c2da9d..2790a9e0b 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 c3f9918ac..fd4e9072f 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 - if (next.clear) { - clear() - } - - if (next.arm) { - arm() + const endOffset = Bun.stringWidth(area.plainText) + if (dir === -1 && area.visualCursor.visualRow === 0) { + area.cursorOffset = 0 } - if (next.cycle) { - input.onCycle() + 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 } - 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 - } - - if ( - input.view() === "command" || - input.view() === "model" || - input.view() === "variant" || - input.view() === "subagent-menu" - ) { - return - } - - if (promptHit(keys().clear, promptInfo(event))) { - const handled = requestExit() - if (handled) { - event.preventDefault() - } - } - }) + 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"), + })) + + 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"), + })) + + 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 5bea73a91..d0f36246a 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 c94c664cc..4ac8b40d8 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 affc664b1..1989f5f70 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 2dda26bae..5f9570fd9 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 3ff9801c6..d0113466c 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 eb342a7fd..bc44aafa9 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 d2de3fca8..556b1f862 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 d8489fd2f..cd46e8ccc 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 697e64efc..d20707e5b 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: [], }) - - const app = await testRender( - () => ( - + let offKeymap: (() => void) | undefined + function Harness() { + const renderer = useRenderer() + const keymap = createDefaultOpenTuiKeymap(renderer) + offKeymap = registerOpencodeKeymap(keymap, renderer, tuiConfig) + + 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 35b35ec3e..cbf2cd9c9 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 8dd978553..e610463c7 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 68f5c56d0..bcbf22459 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 011/412] 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 1989f5f70..bd37c1b21 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 ae4d3ad26..2e2b07398 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 cd46e8ccc..461b204a2 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 d20707e5b..214f4637d 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 012/412] 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 4dcdf1018..21f9e60e2 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 013/412] 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 a74d39f4e..5a8fb6451 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 014/412] 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 c85fe53d1..822195356 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 5bef47c3c..106d8f516 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 916bef9d1..a3c97bc8e 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 d31854485..5b53b92bd 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 8cf02ddfe..b0de0802a 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 1d854f18f..7297ef0f3 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 56b544480..68bb43674 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 20367f4d9..9b7438f4f 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 1c237d3ec..31127f2f3 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 b764699e0..aa3ab0e24 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 72c735f6c..19ab93121 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 56c741df1..fa9995ee4 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 7cb8ec2dc..a13792803 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 000000000..b83d22266 --- /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 000000000..f446253de --- /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 000000000..e30a9d16b --- /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 000000000..e19bd5089 --- /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 000000000..03acc84b7 --- /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 000000000..be96fc46a --- /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 000000000..fa08c747c --- /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 000000000..ed6769caa --- /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 000000000..9914a14bd --- /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 000000000..61c3758b0 --- /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 015/412] 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 214f4637d..0f7ddb532 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 016/412] 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 42aebf02d..f0a030144 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 017/412] 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 5c0e5e73f..4fdeb61f7 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 f87a24a74..bb6f1d6a1 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 e32ce9803..7dccec1f2 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 f7f22b432..d77a23380 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 b5fed974a..815303a53 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 018/412] 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 4fdeb61f7..70fcd421c 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 815303a53..50e2a31f9 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 019/412] 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 b83d22266..000000000 --- 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 f446253de..000000000 --- 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 e30a9d16b..000000000 --- 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 e19bd5089..000000000 --- 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 03acc84b7..000000000 --- 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 be96fc46a..000000000 --- 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 fa08c747c..000000000 --- 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 ed6769caa..000000000 --- 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 9914a14bd..000000000 --- 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 61c3758b0..000000000 --- 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 020/412] 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 e764ff502..faa354ba2 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 0be4bc7a5..cc69e7ce0 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 3ddf637f3..8cd0491d6 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 465730f49..1659748fc 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 371234b1e..a64de349d 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 a577b82c1..c61d97d88 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 38a229f6b..c5346cc3c 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 8f881a2ea..1fa139d05 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 899e9acd2..34bb6602e 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 7bb184bb4..359aed418 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 ac2455eea..994c17afd 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 de2908e47..2e508a2e1 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 91aad0877..a960b51ae 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 812e16a20..33b9a29c2 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 36447028f..82138d0b2 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 3a37968e9..67eecc819 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 dc8339e18..f66c72ebe 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 2573a5330..aabb1d03e 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 43fba6138..5a574c46c 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 6a086e2cc..af1636877 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 95c2bc50f..e16abe43d 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 870ed058d..e13df2706 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 adde58949..e1fcd4610 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 55695074a..a156ba287 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 25366ad2e..4331cd654 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 3e1b65c97..b799960b4 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 c8e0acd2b..3f22dbdc7 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 eef134802..bafc0bfa0 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 ad33507e4..38082a748 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 bfde41f86..1fe63c84f 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 67375c8ce..6118ff2ee 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 b61ae4544..e16b36d36 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 b45f9d963..9d57a765b 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 80de0ad92..6b1c7d766 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 e314a4db2..efe050dba 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 f3aef05cf..00c65b078 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 021/412] 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 68803d0d1..95321f71b 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 656555573..7a866c24c 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 022/412] 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 000000000..64441936d --- /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 f9efb62e5..421979261 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 7cb326a5a..bd9f1960a 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 7c6312e0a..25a8f2162 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 de5237cca..3ceb77889 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 950fa7543..3f1cdda07 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 002773601..ac39e1751 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 2918706fc..6dbfd44eb 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 5735df45f..a1b6a0fd2 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 29759e91c..8a4feeb68 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 dfa6704b8..24ae5009f 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 1418a42c8..70c6e866b 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 d93110691..d337d0fb7 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 1f0ce4f3a..eeb9d618d 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 724bb5c3d..a84242482 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 0805eb7e9..b3f3de05b 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 b82c367dd..fc77960cd 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 9fbd0b5b1..3d527d15b 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 4a782621b..3b2f58b86 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 bccdc9e97..1a9a9d43b 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 da7bdc220..e7e44c6c5 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 000000000..64441936d --- /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 faa354ba2..61320735b 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 cc69e7ce0..326177b39 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 8cd0491d6..f3a02342a 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 1659748fc..0136425a7 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 a64de349d..e2bbb0d0b 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 c61d97d88..1abc0a894 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 c5346cc3c..7bbffaec0 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 1fa139d05..966af4f9a 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 34bb6602e..b7dba05c8 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 359aed418..f7bc49278 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 994c17afd..85923a570 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 2e508a2e1..14a2109c9 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 a960b51ae..fe3c576cf 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 33b9a29c2..dbe208a8a 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 82138d0b2..b81eecae3 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 67eecc819..4a9a0eb63 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 f66c72ebe..155a6951b 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 aabb1d03e..7cbbb9a0d 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 5a574c46c..e368dbf6e 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 af1636877..bee983fb8 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 e16abe43d..20ea676f2 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 e13df2706..dcd8c1a4c 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 e1fcd4610..b933e39bb 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 a156ba287..e9dbb9275 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 4331cd654..464add076 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 b799960b4..c5fb4402a 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 3f22dbdc7..f15845d76 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 bafc0bfa0..a6a0d736d 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 38082a748..ecfc59b3f 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 1fe63c84f..58b438c03 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 6118ff2ee..419172318 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 e16b36d36..e300d7075 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 9d57a765b..cf8fa6506 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 6b1c7d766..368f2234e 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 efe050dba..140830a4b 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 00c65b078..c806555ea 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 023/412] 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 1806fb6a7..915ed75fc 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 024/412] 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 915ed75fc..ad860be92 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 025/412] 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 000000000..1dbc0575f --- /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 7c4adb521..19d43746b 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 99da39ebb..b7b1d72ac 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 026/412] 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 000000000..1f8fcaf64 --- /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 0ce73631f..000000000 --- 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 1a6918b33..ee7631848 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 000000000..413f08667 --- /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 2fe023543..000000000 --- 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 5b0b08c96..316974de8 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 027/412] 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 ceb78e632..c687df59b 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 855c58ba5..cae261e72 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 eb63ee534..ebaad3e93 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 862e06fc2..2884c4cf8 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 f124f7eea..8f9b83a79 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 36e394f0a..e7c6a6236 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 206304b39..a868a59dd 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 f33330704..19a372558 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 1b92dce68..399a5b604 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 bb6f1d6a1..a8a867a42 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 7dccec1f2..89652d9a3 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 20ffb60e1..b91e138ed 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 028/412] 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 bc2434f3e..d007707e8 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 fd4e9072f..7f4a9ca5b 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 4ac8b40d8..16c6b2420 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 bd37c1b21..839765d0a 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 79be71cad..63ce618be 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 41a083c70..ca6a55d1d 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 556b1f862..8a88bb7ad 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 c03123aed..f69cb68fd 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 0f7ddb532..a4f4c296e 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 5515787ca..ead73af89 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 9fb6e7b61..e6b40dd92 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 d1b145db2..74fb7ec02 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 029/412] 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 d007707e8..90ba6fc67 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 839765d0a..816f6c992 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 63ce618be..64172e828 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 a4f4c296e..ed2df583c 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 ead73af89..728e18fcf 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 030/412] fix(acp): honor session/cancel by aborting the running turn (#30145) Co-authored-by: Shoubhit Dash --- packages/opencode/src/acp/service.ts | 29 +++++---- .../opencode/test/acp/service-session.test.ts | 64 +++++++++---------- 2 files changed, 49 insertions(+), 44 deletions(-) diff --git a/packages/opencode/src/acp/service.ts b/packages/opencode/src/acp/service.ts index 39840c091..b8c700ea0 100644 --- a/packages/opencode/src/acp/service.ts +++ b/packages/opencode/src/acp/service.ts @@ -330,25 +330,34 @@ export function make(input: { } }) - 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 {} - + const abortBackingSession = Effect.fn("ACP.abortBackingSession")(function* (current: ACPSession.Info) { yield* request( - () => input.sdk.session.abort({ directory: removed.cwd, sessionID: params.sessionId }, { throwOnError: true }), + () => input.sdk.session.abort({ directory: current.cwd, sessionID: current.id }, { throwOnError: true }), "session", ).pipe( Effect.catch((error) => Effect.sync(() => { - log.error("failed to abort session while closing ACP session", { error, sessionID: params.sessionId }) + 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* 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 878d331c4..e5f1bc64c 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)), + 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: [] })) + + 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" }), ) - 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" })) + expect(stillUsable).toBeDefined() + }) - expect(await Effect.runPromise(closing.closeSession({ sessionId: "ses_close" }))).toEqual({}) + 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 031/412] 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 4e8cb9046..4c22942ea 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 966ba932d..d2877dd77 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 c5ab85bc1..4ef870492 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 326d3e624..661a368d1 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 032/412] 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 f8a4b6233..e61503454 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 1be32979d..0bf2357ef 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 3a124be81..4e56c61d2 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 033/412] 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 4ef870492..55b2e9a3f 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 661a368d1..d4158e363 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 034/412] 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 33bff77b9..323068728 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 035/412] 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 74b6df0fc..e9f71f338 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 0b5119d94..e1aed6e8b 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 b80a2389e..cdbf4562d 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 16c6b2420..90ca009e3 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 bc44aafa9..389f868ee 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 64172e828..e575647af 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 2e2b07398..01665db1c 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 4a3a49fb8..03951ec4c 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 f43bff5be..dde41f324 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 ca6a55d1d..5641b8f07 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,13 +963,161 @@ 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")(() => @@ -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 8a88bb7ad..65099394c 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 14882e264..b7148ebce 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 728e18fcf..7eba8bb25 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 36d25c6b4..a6642c4bb 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 74fb7ec02..2dcfc3c4c 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 bcbf22459..5cd7d82b2 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 036/412] 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 dde41f324..8074aa4f6 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 a6642c4bb..da4bfd382 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 037/412] 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 21f9e60e2..c4fa68084 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 038/412] 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 d8aee2d6a..ca341eff1 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 a770a9f04..4c76d8ccb 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} /> -