From 2266489ef8b5a0dba701bf335c7fa6406a72f6cd Mon Sep 17 00:00:00 2001 From: Imanol Maiztegui Date: Thu, 18 Jun 2026 13:41:13 +0200 Subject: [PATCH] refactor: kilo compat for v1.15.13 --- .dockerignore | 12 + .github/ISSUE_TEMPLATE/bug-report.yml | 2 +- .opencode-version | 2 +- .../DEEPENING.md | 37 - .../INTERFACE-DESIGN.md | 44 - .../improve-codebase-architecture/LANGUAGE.md | 53 - .../improve-codebase-architecture/SKILL.md | 71 - AGENTS.md | 2 - bun.lock | 352 +- bunfig.toml | 2 +- nix/hashes.json | 8 +- package.json | 29 +- packages/cli/package.json | 25 + packages/cli/src/debug/agents.ts | 19 + packages/cli/src/debug/index.ts | 7 + packages/cli/src/index.ts | 49 + packages/cli/tsconfig.json | 7 + packages/core/package.json | 11 +- packages/core/src/agent.ts | 192 +- packages/core/src/catalog.ts | 227 +- packages/core/src/config.ts | 203 ++ packages/core/src/config/agent.ts | 25 + packages/core/src/config/attachments.ts | 15 + packages/core/src/config/compaction.ts | 16 + packages/core/src/config/experimental.ts | 18 + packages/core/src/config/formatter.ts | 12 + packages/core/src/config/lsp.ts | 18 + packages/core/src/config/mcp.ts | 36 + packages/core/src/config/plugin.ts | 13 + packages/core/src/config/plugin/agent.ts | 65 + packages/core/src/config/plugin/provider.ts | 95 + packages/core/src/config/provider.ts | 62 + packages/core/src/config/reference.ts | 17 + packages/core/src/config/tool-output.ts | 9 + packages/core/src/config/watcher.ts | 7 + .../src/effect/service-use.ts | 7 +- packages/core/src/event.ts | 2 +- packages/core/src/filesystem.ts | 5 +- packages/core/src/flag/flag.ts | 7 +- packages/core/src/git.ts | 114 + packages/core/src/location-layer.ts | 15 +- packages/core/src/location.ts | 35 +- packages/core/src/model.ts | 2 +- packages/core/src/plugin.ts | 46 +- packages/core/src/plugin/account.ts | 2 +- packages/core/src/plugin/agent.ts | 211 ++ packages/core/src/plugin/boot.ts | 28 +- packages/core/src/plugin/env.ts | 2 +- packages/core/src/plugin/models-dev.ts | 4 +- .../src/plugin/provider/amazon-bedrock.ts | 2 +- .../core/src/plugin/provider/anthropic.ts | 2 +- packages/core/src/plugin/provider/azure.ts | 4 +- packages/core/src/plugin/provider/cerebras.ts | 2 +- .../plugin/provider/cloudflare-workers-ai.ts | 2 +- .../src/plugin/provider/github-copilot.ts | 2 +- .../core/src/plugin/provider/google-vertex.ts | 10 +- packages/core/src/plugin/provider/kilo.ts | 2 +- .../core/src/plugin/provider/llmgateway.ts | 2 +- packages/core/src/plugin/provider/nvidia.ts | 2 +- packages/core/src/plugin/provider/openai.ts | 2 +- packages/core/src/plugin/provider/opencode.ts | 2 +- .../core/src/plugin/provider/openrouter.ts | 2 +- packages/core/src/plugin/provider/vercel.ts | 2 +- packages/core/src/plugin/provider/zenmux.ts | 2 +- packages/core/src/policy.ts | 44 + packages/core/src/project.ts | 128 + packages/core/src/schema.ts | 6 + packages/core/src/state.ts | 63 + packages/core/test/account.test.ts | 15 +- packages/core/test/agent.test.ts | 101 + packages/core/test/catalog.test.ts | 93 +- packages/core/test/config/agent.test.ts | 186 ++ packages/core/test/config/config.test.ts | 460 +++ packages/core/test/config/provider.test.ts | 131 + packages/core/test/event.test.ts | 6 +- packages/core/test/fixture/location.ts | 12 + packages/core/test/location.test.ts | 38 + packages/core/test/model.test.ts | 23 + .../plugin/provider-amazon-bedrock.test.ts | 4 +- .../test/plugin/provider-anthropic.test.ts | 8 +- .../provider-azure-cognitive-services.test.ts | 8 +- .../core/test/plugin/provider-azure.test.ts | 28 +- .../test/plugin/provider-cerebras.test.ts | 8 +- .../provider-cloudflare-workers-ai.test.ts | 24 +- .../plugin/provider-github-copilot.test.ts | 8 +- .../core/test/plugin/provider-gitlab.test.ts | 25 +- .../provider-google-vertex-anthropic.test.ts | 8 +- .../plugin/provider-google-vertex.test.ts | 35 +- packages/core/test/plugin/provider-helper.ts | 12 +- .../core/test/plugin/provider-kilo.test.ts | 12 +- .../test/plugin/provider-llmgateway.test.ts | 8 +- .../core/test/plugin/provider-nvidia.test.ts | 12 +- .../core/test/plugin/provider-openai.test.ts | 8 +- .../test/plugin/provider-opencode.test.ts | 48 +- .../test/plugin/provider-openrouter.test.ts | 12 +- .../core/test/plugin/provider-vercel.test.ts | 12 +- .../core/test/plugin/provider-zenmux.test.ts | 16 +- packages/core/test/policy.test.ts | 83 + packages/core/test/project.test.ts | 220 ++ .../llm/src/protocols/anthropic-messages.ts | 4 +- .../llm/src/protocols/openai-responses.ts | 237 +- packages/llm/src/protocols/utils/lifecycle.ts | 20 +- .../llm/src/protocols/utils/openai-options.ts | 34 +- packages/llm/src/providers/openai-options.ts | 16 +- packages/llm/src/providers/openai.ts | 2 +- packages/llm/src/route/client.ts | 2 +- packages/llm/src/route/protocol.ts | 4 +- packages/llm/test/continuation-scenarios.ts | 2 +- ...i-responses-gpt-5-5-image-tool-result.json | 6 +- ...ponses-gpt-5-5-reasoning-continuation.json | 8 +- .../openai-responses-gpt-5-5-reasoning.json | 6 +- .../test/provider/openai-responses.test.ts | 254 +- packages/llm/test/recorded-scenarios.ts | 2 +- packages/llm/test/tool-runtime.test.ts | 75 + .../migration.sql | 1 + .../snapshot.json | 1500 +++++++++ packages/opencode/package.json | 52 +- packages/opencode/src/account/account.ts | 2 +- packages/opencode/src/account/repo.ts | 2 +- packages/opencode/src/acp/README.md | 174 - packages/opencode/src/acp/agent.ts | 1947 +---------- packages/opencode/src/acp/config-option.ts | 203 ++ packages/opencode/src/acp/content.ts | 250 ++ packages/opencode/src/acp/directory.ts | 209 ++ packages/opencode/src/acp/error.ts | 90 + packages/opencode/src/acp/event.ts | 319 ++ packages/opencode/src/acp/permission.ts | 145 + packages/opencode/src/acp/profile.ts | 42 + packages/opencode/src/acp/runtime.ts | 22 - packages/opencode/src/acp/service.ts | 1058 ++++++ packages/opencode/src/acp/session.ts | 312 +- packages/opencode/src/acp/tool.ts | 290 ++ packages/opencode/src/acp/types.ts | 24 - packages/opencode/src/acp/usage.ts | 238 ++ packages/opencode/src/agent/agent.ts | 2 +- packages/opencode/src/bus/index.ts | 2 +- packages/opencode/src/cli/cmd/acp.ts | 7 +- packages/opencode/src/cli/cmd/debug/v2.ts | 3 +- packages/opencode/src/cli/cmd/tui/app.tsx | 319 +- packages/opencode/src/cli/cmd/tui/attach.ts | 7 +- .../cli/cmd/tui/component/dialog-model.tsx | 18 +- .../cli/cmd/tui/component/dialog-provider.tsx | 9 +- .../cmd/tui/component/dialog-session-list.tsx | 17 +- .../tui/component/dialog-workspace-create.tsx | 37 +- .../tui/component/dialog-workspace-list.tsx | 112 + .../cli/cmd/tui/component/error-component.tsx | 19 +- .../cli/cmd/tui/component/prompt/index.tsx | 39 +- .../src/cli/cmd/tui/config/tui-schema.ts | 10 + .../opencode/src/cli/cmd/tui/context/exit.tsx | 80 +- .../src/cli/cmd/tui/context/thinking.ts | 15 +- .../tui/feature-plugins/session/dialog.tsx | 335 ++ .../cmd/tui/feature-plugins/session/index.tsx | 32 + .../feature-plugins/session/preview-pane.tsx | 382 +++ .../cmd/tui/feature-plugins/session/util.tsx | 70 + .../feature-plugins/system/diff-viewer.tsx | 3 + .../tui/feature-plugins/system/session-v2.tsx | 64 +- .../src/cli/cmd/tui/plugin/internal.ts | 3 + .../opencode/src/cli/cmd/tui/routes/home.tsx | 13 +- .../src/cli/cmd/tui/routes/session/index.tsx | 324 +- packages/opencode/src/cli/cmd/tui/thread.ts | 7 +- .../src/cli/cmd/tui/ui/dialog-select.tsx | 192 +- .../opencode/src/cli/cmd/tui/util/editor.ts | 3 +- packages/opencode/src/config/config.ts | 15 +- packages/opencode/src/config/managed.ts | 9 +- packages/opencode/src/config/provider.ts | 17 +- .../opencode/src/control-plane/workspace.ts | 2 +- packages/opencode/src/effect/runtime-flags.ts | 9 +- packages/opencode/src/env/index.ts | 2 +- packages/opencode/src/file/index.ts | 2 +- packages/opencode/src/file/ripgrep.ts | 2 +- packages/opencode/src/format/index.ts | 2 +- packages/opencode/src/installation/index.ts | 2 +- packages/opencode/src/mcp/auth.ts | 57 +- packages/opencode/src/mcp/index.ts | 18 +- packages/opencode/src/plugin/digitalocean.ts | 54 +- packages/opencode/src/plugin/index.ts | 51 +- packages/opencode/src/plugin/openai/README.md | 31 + .../opencode/src/plugin/{ => openai}/codex.ts | 63 +- .../opencode/src/plugin/openai/ws-pool.ts | 272 ++ packages/opencode/src/plugin/openai/ws.ts | 334 ++ .../opencode/src/project/instance-store.ts | 2 +- packages/opencode/src/project/project.ts | 208 +- packages/opencode/src/provider/auth.ts | 2 +- packages/opencode/src/provider/error.ts | 16 + .../src/provider/models-snapshot.d.ts | 2 - .../opencode/src/provider/models-snapshot.js | 3 - packages/opencode/src/provider/provider.ts | 31 +- packages/opencode/src/provider/transform.ts | 39 +- packages/opencode/src/pty/index.ts | 6 +- packages/opencode/src/pty/pty.node.ts | 1 - .../server/routes/instance/httpapi/AGENTS.md | 10 +- .../routes/instance/httpapi/groups/event.ts | 7 +- .../instance/httpapi/groups/experimental.ts | 4 +- .../routes/instance/httpapi/groups/global.ts | 4 +- .../routes/instance/httpapi/groups/pty.ts | 22 +- .../routes/instance/httpapi/groups/session.ts | 3 +- .../instance/httpapi/groups/v2/location.ts | 5 +- .../instance/httpapi/groups/workspace.ts | 12 +- .../instance/httpapi/handlers/experimental.ts | 4 +- .../routes/instance/httpapi/handlers/pty.ts | 46 +- .../instance/httpapi/handlers/session.ts | 8 +- .../instance/httpapi/handlers/workspace.ts | 23 +- .../httpapi/middleware/authorization.ts | 34 +- .../httpapi/middleware/instance-context.ts | 9 +- .../instance/httpapi/middleware/proxy.ts | 10 +- .../httpapi/middleware/workspace-routing.ts | 37 +- .../server/routes/instance/httpapi/server.ts | 44 +- packages/opencode/src/session/compaction.ts | 2 +- packages/opencode/src/session/llm.ts | 2 +- .../src/session/llm/native-runtime.ts | 8 + packages/opencode/src/session/message-v2.ts | 23 + packages/opencode/src/session/projectors.ts | 1 + packages/opencode/src/session/prompt.ts | 28 +- packages/opencode/src/session/session.sql.ts | 1 + packages/opencode/src/session/session.ts | 25 +- packages/opencode/src/share/share-next.ts | 2 +- packages/opencode/src/sync/index.ts | 2 +- packages/opencode/src/tool/registry.ts | 8 +- packages/opencode/src/tool/shell.ts | 6 +- packages/opencode/src/tool/shell/prompt.ts | 36 +- packages/opencode/src/tool/task.ts | 138 +- packages/opencode/src/tool/task_status.ts | 179 - packages/opencode/src/tool/task_status.txt | 13 - packages/opencode/src/util/proxy-env.ts | 72 + packages/opencode/src/v2/session.ts | 2 +- packages/opencode/src/worktree/index.ts | 2 +- .../opencode/test/acp/agent-interface.test.ts | 52 - .../opencode/test/acp/config-option.test.ts | 229 ++ packages/opencode/test/acp/content.test.ts | 201 ++ packages/opencode/test/acp/directory.test.ts | 185 + packages/opencode/test/acp/error.test.ts | 67 + .../test/acp/event-subscription.test.ts | 977 ------ packages/opencode/test/acp/event.test.ts | 657 ++++ packages/opencode/test/acp/permission.test.ts | 237 ++ .../opencode/test/acp/service-session.test.ts | 1135 +++++++ packages/opencode/test/acp/session.test.ts | 199 ++ packages/opencode/test/acp/tool.test.ts | 169 + packages/opencode/test/acp/usage.test.ts | 314 ++ .../opencode/test/cli/acp/acp-process.test.ts | 70 - .../opencode/test/cli/acp/acp-test-client.ts | 97 + .../test/cli/acp/config-options.test.ts | 103 + packages/opencode/test/cli/acp/helpers.ts | 96 + .../test/cli/acp/initialize-auth.test.ts | 61 + .../opencode/test/cli/acp/lifecycle.test.ts | 118 + .../test/cli/acp/prompt-content.test.ts | 97 + packages/opencode/test/cli/acp/skills.test.ts | 38 + .../test/cli/cmd/tui/model-options.test.ts | 30 + .../test/cli/cmd/tui/sync-fixture.tsx | 89 +- .../opencode/test/cli/run/entry.body.test.ts | 14 +- .../test/cli/run/scrollback.surface.test.ts | 4 +- .../inline-tool-wrap-snapshot.test.tsx.snap | 54 + .../test/cli/tui/app-lifecycle.test.ts | 261 ++ .../tui/inline-tool-wrap-snapshot.test.tsx | 119 + .../opencode/test/cli/tui/keymap.test.tsx | 84 +- .../opencode/test/cli/tui/thinking.test.ts | 36 + .../opencode/test/cli/tui/use-event.test.tsx | 35 +- packages/opencode/test/config/config.test.ts | 1120 +++---- .../test/effect/runtime-flags.test.ts | 32 +- packages/opencode/test/fixture/tui-sdk.ts | 82 + .../native-openai-oauth-tool-loop.json | 10 +- .../session/native-zen-tool-loop.json | 10 +- packages/opencode/test/mcp/auth.test.ts | 78 + packages/opencode/test/plugin/codex.test.ts | 20 +- .../test/plugin/openai-rollout.test.ts | 17 + .../opencode/test/plugin/openai-ws.test.ts | 711 ++++ .../opencode/test/project/project.test.ts | 124 +- .../opencode/test/project/worktree.test.ts | 16 + .../test/provider/header-timeout.test.ts | 232 ++ .../opencode/test/provider/provider.test.ts | 3 +- .../opencode/test/provider/transform.test.ts | 96 + .../opencode/test/pty/info-schema.test.ts | 33 + packages/opencode/test/server/AGENTS.md | 4 +- .../test/server/httpapi-global.test.ts | 63 + .../server/httpapi-instance-context.test.ts | 98 +- ...ts => httpapi-instance-route-auth.test.ts} | 9 +- .../opencode/test/server/httpapi-mcp.test.ts | 4 + .../httpapi-promptasync-context.test.ts | 89 +- .../opencode/test/server/httpapi-pty.test.ts | 8 + .../server/httpapi-public-openapi.test.ts | 6 + .../test/server/httpapi-session.test.ts | 70 +- .../opencode/test/server/httpapi-ui.test.ts | 14 + .../server/httpapi-workspace-routing.test.ts | 129 +- .../test/server/httpapi-workspace.test.ts | 24 + .../test/server/session-actions.test.ts | 79 + .../opencode/test/server/session-list.test.ts | 16 + .../test/server/workspace-proxy.test.ts | 16 + .../server/worktree-endpoint-repro.test.ts | 22 + .../test/session/llm-native-recorded.test.ts | 21 +- .../opencode/test/session/llm-native.test.ts | 41 +- packages/opencode/test/session/llm.test.ts | 1 + packages/opencode/test/session/prompt.test.ts | 29 + packages/opencode/test/session/retry.test.ts | 20 + .../test/session/schema-decoding.test.ts | 2 + .../opencode/test/session/session.test.ts | 31 + .../__snapshots__/parameters.test.ts.snap | 2 +- packages/opencode/test/tool/registry.test.ts | 14 +- packages/opencode/test/tool/shell.test.ts | 13 +- packages/opencode/test/tool/task.test.ts | 33 +- .../opencode/test/tool/task_status.test.ts | 92 - packages/plugin/package.json | 6 +- packages/plugin/src/index.ts | 1 + packages/sdk/js/src/v2/gen/sdk.gen.ts | 12 + packages/sdk/js/src/v2/gen/types.gen.ts | 73 +- packages/sdk/openapi.json | 161 +- packages/stats/AGENTS.md | 1 + packages/stats/README.md | 16 + packages/stats/app/.gitignore | 17 + packages/stats/app/app.config.ts | 5 + packages/stats/app/package.json | 38 + packages/stats/app/src/app.css | 123 + packages/stats/app/src/app.tsx | 31 + .../stats/app/src/asset/logo-ornate-dark.svg | 18 + .../stats/app/src/asset/logo-ornate-light.svg | 18 + .../stats/app/src/asset/unfurl-rankings.png | Bin 0 -> 12791 bytes packages/stats/app/src/entry-client.tsx | 7 + packages/stats/app/src/entry-server.tsx | 37 + packages/stats/app/src/global.d.ts | 1 + packages/stats/app/src/resource.d.ts | 10 + packages/stats/app/src/routes/api/health.ts | 19 + .../stats/app/src/routes/api/newsletter.ts | 29 + packages/stats/app/src/routes/index.css | 2971 +++++++++++++++++ packages/stats/app/src/routes/index.tsx | 1830 ++++++++++ .../stats/app/src/routes/stats/api/health.ts | 1 + .../app/src/routes/stats/api/newsletter.ts | 1 + packages/stats/app/sst-env.d.ts | 10 + packages/stats/app/tsconfig.json | 22 + packages/stats/app/vite.config.ts | 23 + packages/stats/core/drizzle.config.ts | 21 + .../20260522121617_common_dust/migration.sql | 42 + .../20260522121617_common_dust/snapshot.json | 623 ++++ .../migration.sql | 94 + .../snapshot.json | 2033 +++++++++++ .../migration.sql | 5 + .../snapshot.json | 2097 ++++++++++++ .../migration.sql | 3 + .../snapshot.json | 2139 ++++++++++++ .../migration.sql | 6 + .../snapshot.json | 2055 ++++++++++++ packages/stats/core/package.json | 44 + packages/stats/core/src/athena.ts | 139 + packages/stats/core/src/config.ts | 23 + packages/stats/core/src/database.ts | 79 + packages/stats/core/src/database/schema.ts | 160 + packages/stats/core/src/domain/geo.ts | 203 ++ packages/stats/core/src/domain/home.ts | 499 +++ .../stats/core/src/domain/inference.test.ts | 66 + packages/stats/core/src/domain/inference.ts | 252 ++ .../core/src/domain/model-normalization.ts | 30 + packages/stats/core/src/domain/model.ts | 172 + packages/stats/core/src/domain/provider.ts | 168 + packages/stats/core/src/domain/stat.ts | 246 ++ packages/stats/core/src/honeycomb-backfill.ts | 974 ++++++ packages/stats/core/src/index.ts | 11 + packages/stats/core/src/migrate.ts | 4 + packages/stats/core/src/resource.d.ts | 28 + packages/stats/core/src/runtime.ts | 14 + packages/stats/core/src/stat-sync.ts | 93 + packages/stats/core/sst-env.d.ts | 10 + packages/stats/core/tsconfig.json | 11 + packages/stats/server/Dockerfile | 32 + packages/stats/server/package.json | 33 + packages/stats/server/src/ingest.ts | 159 + packages/stats/server/src/resource.d.ts | 11 + packages/stats/server/src/router.ts | 73 + packages/stats/server/src/server.ts | 28 + packages/stats/server/src/shutdown.ts | 17 + packages/stats/server/src/stat-sync.ts | 22 + packages/stats/server/sst-env.d.ts | 10 + packages/stats/server/tsconfig.json | 12 + .../mocks/app/context/global-sync.ts | 2 +- packages/ui/package.json | 16 +- .../src/components/apply-patch-file.test.ts | 1 - packages/ui/src/components/basic-tool.tsx | 15 +- packages/ui/src/components/file.tsx | 89 +- packages/ui/src/components/message-part.tsx | 62 +- .../ui/src/components/session-diff.test.ts | 55 +- packages/ui/src/components/session-diff.ts | 133 +- .../components/todo-panel-motion.stories.tsx | 4 +- .../ui/src/components/tool-error-card.tsx | 26 +- .../ui/src/v2/components/accordion-v2.css | 2 +- .../v2/components/accordion-v2.stories.tsx | 2 +- packages/ui/src/v2/components/avatar-v2.css | 2 +- packages/ui/src/v2/components/badge-v2.css | 2 +- .../ui/src/v2/components/basic-tool-v2.css | 2 +- packages/ui/src/v2/components/button-v2.css | 2 +- packages/ui/src/v2/components/checkbox-v2.css | 6 +- .../src/v2/components/checkbox-v2.stories.tsx | 2 +- packages/ui/src/v2/components/dialog-v2.css | 4 +- .../ui/src/v2/components/diff-changes-v2.css | 2 +- packages/ui/src/v2/components/field-v2.css | 4 +- .../ui/src/v2/components/icon-button-v2.css | 1 - packages/ui/src/v2/components/icon.tsx | 20 + .../ui/src/v2/components/inline-input-v2.css | 4 +- .../v2/components/inline-input-v2.stories.tsx | 2 +- packages/ui/src/v2/components/keybind-v2.css | 4 +- .../ui/src/v2/components/line-comment-v2.css | 2 +- packages/ui/src/v2/components/menu-v2.css | 18 +- .../ui/src/v2/components/menu-v2.stories.tsx | 2 +- packages/ui/src/v2/components/radio-v2.css | 10 +- .../ui/src/v2/components/radio-v2.stories.tsx | 2 +- .../v2/components/segmented-control-v2.css | 2 +- .../segmented-control-v2.stories.tsx | 4 +- packages/ui/src/v2/components/select-v2.css | 2 +- packages/ui/src/v2/components/switch-v2.css | 4 +- packages/ui/src/v2/components/tabs-v2.css | 2 +- .../ui/src/v2/components/text-input-v2.css | 2 +- .../v2/components/text-input-v2.stories.tsx | 2 +- packages/ui/src/v2/components/textarea-v2.css | 2 +- .../src/v2/components/textarea-v2.stories.tsx | 2 +- .../src/v2/components/tool-error-card-v2.css | 2 +- packages/ui/src/v2/styles/theme.css | 3 + patches/gcp-metadata@8.1.2.patch | 14 + patches/virtua@0.49.1.patch | 93 + specs/v2/catalog-config-plugin-lifecycle.md | 322 ++ specs/v2/config.md | 395 +++ specs/v2/provider-policy.md | 291 ++ 416 files changed, 38566 insertions(+), 6605 deletions(-) create mode 100644 .dockerignore delete mode 100644 .opencode/skills/improve-codebase-architecture/DEEPENING.md delete mode 100644 .opencode/skills/improve-codebase-architecture/INTERFACE-DESIGN.md delete mode 100644 .opencode/skills/improve-codebase-architecture/LANGUAGE.md delete mode 100644 .opencode/skills/improve-codebase-architecture/SKILL.md create mode 100644 packages/cli/package.json create mode 100644 packages/cli/src/debug/agents.ts create mode 100644 packages/cli/src/debug/index.ts create mode 100755 packages/cli/src/index.ts create mode 100644 packages/cli/tsconfig.json create mode 100644 packages/core/src/config.ts create mode 100644 packages/core/src/config/agent.ts create mode 100644 packages/core/src/config/attachments.ts create mode 100644 packages/core/src/config/compaction.ts create mode 100644 packages/core/src/config/experimental.ts create mode 100644 packages/core/src/config/formatter.ts create mode 100644 packages/core/src/config/lsp.ts create mode 100644 packages/core/src/config/mcp.ts create mode 100644 packages/core/src/config/plugin.ts create mode 100644 packages/core/src/config/plugin/agent.ts create mode 100644 packages/core/src/config/plugin/provider.ts create mode 100644 packages/core/src/config/provider.ts create mode 100644 packages/core/src/config/reference.ts create mode 100644 packages/core/src/config/tool-output.ts create mode 100644 packages/core/src/config/watcher.ts rename packages/{opencode => core}/src/effect/service-use.ts (85%) create mode 100644 packages/core/src/git.ts create mode 100644 packages/core/src/plugin/agent.ts create mode 100644 packages/core/src/policy.ts create mode 100644 packages/core/src/project.ts create mode 100644 packages/core/src/state.ts create mode 100644 packages/core/test/agent.test.ts create mode 100644 packages/core/test/config/agent.test.ts create mode 100644 packages/core/test/config/config.test.ts create mode 100644 packages/core/test/config/provider.test.ts create mode 100644 packages/core/test/fixture/location.ts create mode 100644 packages/core/test/location.test.ts create mode 100644 packages/core/test/model.test.ts create mode 100644 packages/core/test/policy.test.ts create mode 100644 packages/core/test/project.test.ts create mode 100644 packages/opencode/migration/20260511173437_session-metadata/migration.sql create mode 100644 packages/opencode/migration/20260511173437_session-metadata/snapshot.json delete mode 100644 packages/opencode/src/acp/README.md create mode 100644 packages/opencode/src/acp/config-option.ts create mode 100644 packages/opencode/src/acp/content.ts create mode 100644 packages/opencode/src/acp/directory.ts create mode 100644 packages/opencode/src/acp/error.ts create mode 100644 packages/opencode/src/acp/event.ts create mode 100644 packages/opencode/src/acp/permission.ts create mode 100644 packages/opencode/src/acp/profile.ts delete mode 100644 packages/opencode/src/acp/runtime.ts create mode 100644 packages/opencode/src/acp/service.ts create mode 100644 packages/opencode/src/acp/tool.ts delete mode 100644 packages/opencode/src/acp/types.ts create mode 100644 packages/opencode/src/acp/usage.ts create mode 100644 packages/opencode/src/cli/cmd/tui/component/dialog-workspace-list.tsx create mode 100644 packages/opencode/src/cli/cmd/tui/feature-plugins/session/dialog.tsx create mode 100644 packages/opencode/src/cli/cmd/tui/feature-plugins/session/index.tsx create mode 100644 packages/opencode/src/cli/cmd/tui/feature-plugins/session/preview-pane.tsx create mode 100644 packages/opencode/src/cli/cmd/tui/feature-plugins/session/util.tsx create mode 100644 packages/opencode/src/plugin/openai/README.md rename packages/opencode/src/plugin/{ => openai}/codex.ts (91%) create mode 100644 packages/opencode/src/plugin/openai/ws-pool.ts create mode 100644 packages/opencode/src/plugin/openai/ws.ts delete mode 100644 packages/opencode/src/provider/models-snapshot.d.ts delete mode 100644 packages/opencode/src/provider/models-snapshot.js delete mode 100644 packages/opencode/src/tool/task_status.ts delete mode 100644 packages/opencode/src/tool/task_status.txt create mode 100644 packages/opencode/src/util/proxy-env.ts delete mode 100644 packages/opencode/test/acp/agent-interface.test.ts create mode 100644 packages/opencode/test/acp/config-option.test.ts create mode 100644 packages/opencode/test/acp/content.test.ts create mode 100644 packages/opencode/test/acp/directory.test.ts create mode 100644 packages/opencode/test/acp/error.test.ts delete mode 100644 packages/opencode/test/acp/event-subscription.test.ts create mode 100644 packages/opencode/test/acp/event.test.ts create mode 100644 packages/opencode/test/acp/permission.test.ts create mode 100644 packages/opencode/test/acp/service-session.test.ts create mode 100644 packages/opencode/test/acp/session.test.ts create mode 100644 packages/opencode/test/acp/tool.test.ts create mode 100644 packages/opencode/test/acp/usage.test.ts delete mode 100644 packages/opencode/test/cli/acp/acp-process.test.ts create mode 100644 packages/opencode/test/cli/acp/acp-test-client.ts create mode 100644 packages/opencode/test/cli/acp/config-options.test.ts create mode 100644 packages/opencode/test/cli/acp/helpers.ts create mode 100644 packages/opencode/test/cli/acp/initialize-auth.test.ts create mode 100644 packages/opencode/test/cli/acp/lifecycle.test.ts create mode 100644 packages/opencode/test/cli/acp/prompt-content.test.ts create mode 100644 packages/opencode/test/cli/acp/skills.test.ts create mode 100644 packages/opencode/test/cli/cmd/tui/model-options.test.ts create mode 100644 packages/opencode/test/cli/tui/__snapshots__/inline-tool-wrap-snapshot.test.tsx.snap create mode 100644 packages/opencode/test/cli/tui/app-lifecycle.test.ts create mode 100644 packages/opencode/test/cli/tui/inline-tool-wrap-snapshot.test.tsx create mode 100644 packages/opencode/test/cli/tui/thinking.test.ts create mode 100644 packages/opencode/test/fixture/tui-sdk.ts create mode 100644 packages/opencode/test/mcp/auth.test.ts create mode 100644 packages/opencode/test/plugin/openai-rollout.test.ts create mode 100644 packages/opencode/test/plugin/openai-ws.test.ts create mode 100644 packages/opencode/test/provider/header-timeout.test.ts create mode 100644 packages/opencode/test/pty/info-schema.test.ts create mode 100644 packages/opencode/test/server/httpapi-global.test.ts rename packages/opencode/test/server/{httpapi-raw-route-auth.test.ts => httpapi-instance-route-auth.test.ts} (88%) delete mode 100644 packages/opencode/test/tool/task_status.test.ts create mode 100644 packages/stats/AGENTS.md create mode 100644 packages/stats/README.md create mode 100644 packages/stats/app/.gitignore create mode 100644 packages/stats/app/app.config.ts create mode 100644 packages/stats/app/package.json create mode 100644 packages/stats/app/src/app.css create mode 100644 packages/stats/app/src/app.tsx create mode 100644 packages/stats/app/src/asset/logo-ornate-dark.svg create mode 100644 packages/stats/app/src/asset/logo-ornate-light.svg create mode 100644 packages/stats/app/src/asset/unfurl-rankings.png create mode 100644 packages/stats/app/src/entry-client.tsx create mode 100644 packages/stats/app/src/entry-server.tsx create mode 100644 packages/stats/app/src/global.d.ts create mode 100644 packages/stats/app/src/resource.d.ts create mode 100644 packages/stats/app/src/routes/api/health.ts create mode 100644 packages/stats/app/src/routes/api/newsletter.ts create mode 100644 packages/stats/app/src/routes/index.css create mode 100644 packages/stats/app/src/routes/index.tsx create mode 100644 packages/stats/app/src/routes/stats/api/health.ts create mode 100644 packages/stats/app/src/routes/stats/api/newsletter.ts create mode 100644 packages/stats/app/sst-env.d.ts create mode 100644 packages/stats/app/tsconfig.json create mode 100644 packages/stats/app/vite.config.ts create mode 100644 packages/stats/core/drizzle.config.ts create mode 100644 packages/stats/core/migrations/20260522121617_common_dust/migration.sql create mode 100644 packages/stats/core/migrations/20260522121617_common_dust/snapshot.json create mode 100644 packages/stats/core/migrations/20260523110335_cool_vin_gonzales/migration.sql create mode 100644 packages/stats/core/migrations/20260523110335_cool_vin_gonzales/snapshot.json create mode 100644 packages/stats/core/migrations/20260528012726_worthless_ultimo/migration.sql create mode 100644 packages/stats/core/migrations/20260528012726_worthless_ultimo/snapshot.json create mode 100644 packages/stats/core/migrations/20260528114447_misty_black_knight/migration.sql create mode 100644 packages/stats/core/migrations/20260528114447_misty_black_knight/snapshot.json create mode 100644 packages/stats/core/migrations/20260528121120_easy_multiple_man/migration.sql create mode 100644 packages/stats/core/migrations/20260528121120_easy_multiple_man/snapshot.json create mode 100644 packages/stats/core/package.json create mode 100644 packages/stats/core/src/athena.ts create mode 100644 packages/stats/core/src/config.ts create mode 100644 packages/stats/core/src/database.ts create mode 100644 packages/stats/core/src/database/schema.ts create mode 100644 packages/stats/core/src/domain/geo.ts create mode 100644 packages/stats/core/src/domain/home.ts create mode 100644 packages/stats/core/src/domain/inference.test.ts create mode 100644 packages/stats/core/src/domain/inference.ts create mode 100644 packages/stats/core/src/domain/model-normalization.ts create mode 100644 packages/stats/core/src/domain/model.ts create mode 100644 packages/stats/core/src/domain/provider.ts create mode 100644 packages/stats/core/src/domain/stat.ts create mode 100644 packages/stats/core/src/honeycomb-backfill.ts create mode 100644 packages/stats/core/src/index.ts create mode 100644 packages/stats/core/src/migrate.ts create mode 100644 packages/stats/core/src/resource.d.ts create mode 100644 packages/stats/core/src/runtime.ts create mode 100644 packages/stats/core/src/stat-sync.ts create mode 100644 packages/stats/core/sst-env.d.ts create mode 100644 packages/stats/core/tsconfig.json create mode 100644 packages/stats/server/Dockerfile create mode 100644 packages/stats/server/package.json create mode 100644 packages/stats/server/src/ingest.ts create mode 100644 packages/stats/server/src/resource.d.ts create mode 100644 packages/stats/server/src/router.ts create mode 100644 packages/stats/server/src/server.ts create mode 100644 packages/stats/server/src/shutdown.ts create mode 100644 packages/stats/server/src/stat-sync.ts create mode 100644 packages/stats/server/sst-env.d.ts create mode 100644 packages/stats/server/tsconfig.json create mode 100644 patches/gcp-metadata@8.1.2.patch create mode 100644 patches/virtua@0.49.1.patch create mode 100644 specs/v2/catalog-config-plugin-lifecycle.md create mode 100644 specs/v2/config.md create mode 100644 specs/v2/provider-policy.md diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000..b6d8d73512 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +.git +.opencode +.sst +.turbo +.wrangler +node_modules +**/node_modules +**/.output +**/dist +**/.turbo +**/.vite +**/coverage diff --git a/.github/ISSUE_TEMPLATE/bug-report.yml b/.github/ISSUE_TEMPLATE/bug-report.yml index 96234eb25d..da82bde2e1 100644 --- a/.github/ISSUE_TEMPLATE/bug-report.yml +++ b/.github/ISSUE_TEMPLATE/bug-report.yml @@ -1,5 +1,5 @@ name: Bug report -description: Report an issue that should be fixed +description: Report an issue that should be fixed (avoid pasting giant AI generated summaries or your issue may be closed/ignored) body: - type: textarea id: description diff --git a/.opencode-version b/.opencode-version index 7a00e5f13b..76b19f9118 100644 --- a/.opencode-version +++ b/.opencode-version @@ -1 +1 @@ -v1.15.9 +v1.15.13 diff --git a/.opencode/skills/improve-codebase-architecture/DEEPENING.md b/.opencode/skills/improve-codebase-architecture/DEEPENING.md deleted file mode 100644 index c52fdfd99f..0000000000 --- a/.opencode/skills/improve-codebase-architecture/DEEPENING.md +++ /dev/null @@ -1,37 +0,0 @@ -# Deepening - -How to deepen a cluster of shallow modules safely, given its dependencies. Assumes the vocabulary in [LANGUAGE.md](LANGUAGE.md) — **module**, **interface**, **seam**, **adapter**. - -## Dependency categories - -When assessing a candidate for deepening, classify its dependencies. The category determines how the deepened module is tested across its seam. - -### 1. In-process - -Pure computation, in-memory state, no I/O. Always deepenable — merge the modules and test through the new interface directly. No adapter needed. - -### 2. Local-substitutable - -Dependencies that have local test stand-ins (PGLite for Postgres, in-memory filesystem). Deepenable if the stand-in exists. The deepened module is tested with the stand-in running in the test suite. The seam is internal; no port at the module's external interface. - -### 3. Remote but owned (Ports & Adapters) - -Your own services across a network boundary (microservices, internal APIs). Define a **port** (interface) at the seam. The deep module owns the logic; the transport is injected as an **adapter**. Tests use an in-memory adapter. Production uses an HTTP/gRPC/queue adapter. - -Recommendation shape: _"Define a port at the seam, implement an HTTP adapter for production and an in-memory adapter for testing, so the logic sits in one deep module even though it's deployed across a network."_ - -### 4. True external (Mock) - -Third-party services (Stripe, Twilio, etc.) you don't control. The deepened module takes the external dependency as an injected port; tests provide a mock adapter. - -## Seam discipline - -- **One adapter means a hypothetical seam. Two adapters means a real one.** Don't introduce a port unless at least two adapters are justified (typically production + test). A single-adapter seam is just indirection. -- **Internal seams vs external seams.** A deep module can have internal seams (private to its implementation, used by its own tests) as well as the external seam at its interface. Don't expose internal seams through the interface just because tests use them. - -## Testing strategy: replace, don't layer - -- Old unit tests on shallow modules become waste once tests at the deepened module's interface exist — delete them. -- Write new tests at the deepened module's interface. The **interface is the test surface**. -- Tests assert on observable outcomes through the interface, not internal state. -- Tests should survive internal refactors — they describe behaviour, not implementation. If a test has to change when the implementation changes, it's testing past the interface. diff --git a/.opencode/skills/improve-codebase-architecture/INTERFACE-DESIGN.md b/.opencode/skills/improve-codebase-architecture/INTERFACE-DESIGN.md deleted file mode 100644 index 3197723a0d..0000000000 --- a/.opencode/skills/improve-codebase-architecture/INTERFACE-DESIGN.md +++ /dev/null @@ -1,44 +0,0 @@ -# Interface Design - -When the user wants to explore alternative interfaces for a chosen deepening candidate, use this parallel sub-agent pattern. Based on "Design It Twice" (Ousterhout) — your first idea is unlikely to be the best. - -Uses the vocabulary in [LANGUAGE.md](LANGUAGE.md) — **module**, **interface**, **seam**, **adapter**, **leverage**. - -## Process - -### 1. Frame the problem space - -Before spawning sub-agents, write a user-facing explanation of the problem space for the chosen candidate: - -- The constraints any new interface would need to satisfy -- The dependencies it would rely on, and which category they fall into (see [DEEPENING.md](DEEPENING.md)) -- A rough illustrative code sketch to ground the constraints — not a proposal, just a way to make the constraints concrete - -Show this to the user, then immediately proceed to Step 2. The user reads and thinks while the sub-agents work in parallel. - -### 2. Spawn sub-agents - -Spawn 3+ sub-agents in parallel using the Agent tool. Each must produce a **radically different** interface for the deepened module. - -Prompt each sub-agent with a separate technical brief (file paths, coupling details, dependency category from [DEEPENING.md](DEEPENING.md), what sits behind the seam). The brief is independent of the user-facing problem-space explanation in Step 1. Give each agent a different design constraint: - -- Agent 1: "Minimize the interface — aim for 1–3 entry points max. Maximise leverage per entry point." -- Agent 2: "Maximise flexibility — support many use cases and extension." -- Agent 3: "Optimise for the most common caller — make the default case trivial." -- Agent 4 (if applicable): "Design around ports & adapters for cross-seam dependencies." - -Include both [LANGUAGE.md](LANGUAGE.md) vocabulary and CONTEXT.md vocabulary in the brief so each sub-agent names things consistently with the architecture language and the project's domain language. - -Each sub-agent outputs: - -1. Interface (types, methods, params — plus invariants, ordering, error modes) -2. Usage example showing how callers use it -3. What the implementation hides behind the seam -4. Dependency strategy and adapters (see [DEEPENING.md](DEEPENING.md)) -5. Trade-offs — where leverage is high, where it's thin - -### 3. Present and compare - -Present designs sequentially so the user can absorb each one, then compare them in prose. Contrast by **depth** (leverage at the interface), **locality** (where change concentrates), and **seam placement**. - -After comparing, give your own recommendation: which design you think is strongest and why. If elements from different designs would combine well, propose a hybrid. Be opinionated — the user wants a strong read, not a menu. diff --git a/.opencode/skills/improve-codebase-architecture/LANGUAGE.md b/.opencode/skills/improve-codebase-architecture/LANGUAGE.md deleted file mode 100644 index dd9b60fea0..0000000000 --- a/.opencode/skills/improve-codebase-architecture/LANGUAGE.md +++ /dev/null @@ -1,53 +0,0 @@ -# Language - -Shared vocabulary for every suggestion this skill makes. Use these terms exactly — don't substitute "component," "service," "API," or "boundary." Consistent language is the whole point. - -## Terms - -**Module** -Anything with an interface and an implementation. Deliberately scale-agnostic — applies equally to a function, class, package, or tier-spanning slice. -_Avoid_: unit, component, service. - -**Interface** -Everything a caller must know to use the module correctly. Includes the type signature, but also invariants, ordering constraints, error modes, required configuration, and performance characteristics. -_Avoid_: API, signature (too narrow — those refer only to the type-level surface). - -**Implementation** -What's inside a module — its body of code. Distinct from **Adapter**: a thing can be a small adapter with a large implementation (a Postgres repo) or a large adapter with a small implementation (an in-memory fake). Reach for "adapter" when the seam is the topic; "implementation" otherwise. - -**Depth** -Leverage at the interface — the amount of behaviour a caller (or test) can exercise per unit of interface they have to learn. A module is **deep** when a large amount of behaviour sits behind a small interface. A module is **shallow** when the interface is nearly as complex as the implementation. - -**Seam** _(from Michael Feathers)_ -A place where you can alter behaviour without editing in that place. The _location_ at which a module's interface lives. Choosing where to put the seam is its own design decision, distinct from what goes behind it. -_Avoid_: boundary (overloaded with DDD's bounded context). - -**Adapter** -A concrete thing that satisfies an interface at a seam. Describes _role_ (what slot it fills), not substance (what's inside). - -**Leverage** -What callers get from depth. More capability per unit of interface they have to learn. One implementation pays back across N call sites and M tests. - -**Locality** -What maintainers get from depth. Change, bugs, knowledge, and verification concentrate at one place rather than spreading across callers. Fix once, fixed everywhere. - -## Principles - -- **Depth is a property of the interface, not the implementation.** A deep module can be internally composed of small, mockable, swappable parts — they just aren't part of the interface. A module can have **internal seams** (private to its implementation, used by its own tests) as well as the **external seam** at its interface. -- **The deletion test.** Imagine deleting the module. If complexity vanishes, the module wasn't hiding anything (it was a pass-through). If complexity reappears across N callers, the module was earning its keep. -- **The interface is the test surface.** Callers and tests cross the same seam. If you want to test _past_ the interface, the module is probably the wrong shape. -- **One adapter means a hypothetical seam. Two adapters means a real one.** Don't introduce a seam unless something actually varies across it. - -## Relationships - -- A **Module** has exactly one **Interface** (the surface it presents to callers and tests). -- **Depth** is a property of a **Module**, measured against its **Interface**. -- A **Seam** is where a **Module**'s **Interface** lives. -- An **Adapter** sits at a **Seam** and satisfies the **Interface**. -- **Depth** produces **Leverage** for callers and **Locality** for maintainers. - -## Rejected framings - -- **Depth as ratio of implementation-lines to interface-lines** (Ousterhout): rewards padding the implementation. We use depth-as-leverage instead. -- **"Interface" as the TypeScript `interface` keyword or a class's public methods**: too narrow — interface here includes every fact a caller must know. -- **"Boundary"**: overloaded with DDD's bounded context. Say **seam** or **interface**. diff --git a/.opencode/skills/improve-codebase-architecture/SKILL.md b/.opencode/skills/improve-codebase-architecture/SKILL.md deleted file mode 100644 index 05984a6096..0000000000 --- a/.opencode/skills/improve-codebase-architecture/SKILL.md +++ /dev/null @@ -1,71 +0,0 @@ ---- -name: improve-codebase-architecture -description: Find deepening opportunities in a codebase, informed by the domain language in CONTEXT.md and the decisions in docs/adr/. Use when the user wants to improve architecture, find refactoring opportunities, consolidate tightly-coupled modules, or make a codebase more testable and AI-navigable. ---- - -# Improve Codebase Architecture - -Surface architectural friction and propose **deepening opportunities** — refactors that turn shallow modules into deep ones. The aim is testability and AI-navigability. - -## Glossary - -Use these terms exactly in every suggestion. Consistent language is the point — don't drift into "component," "service," "API," or "boundary." Full definitions in [LANGUAGE.md](LANGUAGE.md). - -- **Module** — anything with an interface and an implementation (function, class, package, slice). -- **Interface** — everything a caller must know to use the module: types, invariants, error modes, ordering, config. Not just the type signature. -- **Implementation** — the code inside. -- **Depth** — leverage at the interface: a lot of behaviour behind a small interface. **Deep** = high leverage. **Shallow** = interface nearly as complex as the implementation. -- **Seam** — where an interface lives; a place behaviour can be altered without editing in place. (Use this, not "boundary.") -- **Adapter** — a concrete thing satisfying an interface at a seam. -- **Leverage** — what callers get from depth. -- **Locality** — what maintainers get from depth: change, bugs, knowledge concentrated in one place. - -Key principles (see [LANGUAGE.md](LANGUAGE.md) for the full list): - -- **Deletion test**: imagine deleting the module. If complexity vanishes, it was a pass-through. If complexity reappears across N callers, it was earning its keep. -- **The interface is the test surface.** -- **One adapter = hypothetical seam. Two adapters = real seam.** - -This skill is _informed_ by the project's domain model. The domain language gives names to good seams; ADRs record decisions the skill should not re-litigate. - -## Process - -### 1. Explore - -Read the project's domain glossary and any ADRs in the area you're touching first. - -Then use the Agent tool with `subagent_type=Explore` to walk the codebase. Don't follow rigid heuristics — explore organically and note where you experience friction: - -- Where does understanding one concept require bouncing between many small modules? -- Where are modules **shallow** — interface nearly as complex as the implementation? -- Where have pure functions been extracted just for testability, but the real bugs hide in how they're called (no **locality**)? -- Where do tightly-coupled modules leak across their seams? -- Which parts of the codebase are untested, or hard to test through their current interface? - -Apply the **deletion test** to anything you suspect is shallow: would deleting it concentrate complexity, or just move it? A "yes, concentrates" is the signal you want. - -### 2. Present candidates - -Present a numbered list of deepening opportunities. For each candidate: - -- **Files** — which files/modules are involved -- **Problem** — why the current architecture is causing friction -- **Solution** — plain English description of what would change -- **Benefits** — explained in terms of locality and leverage, and also in how tests would improve - -**Use CONTEXT.md vocabulary for the domain, and [LANGUAGE.md](LANGUAGE.md) vocabulary for the architecture.** If `CONTEXT.md` defines "Order," talk about "the Order intake module" — not "the FooBarHandler," and not "the Order service." - -**ADR conflicts**: if a candidate contradicts an existing ADR, only surface it when the friction is real enough to warrant revisiting the ADR. Mark it clearly (e.g. _"contradicts ADR-0007 — but worth reopening because…"_). Don't list every theoretical refactor an ADR forbids. - -Do NOT propose interfaces yet. Ask the user: "Which of these would you like to explore?" - -### 3. Grilling loop - -Once the user picks a candidate, drop into a grilling conversation. Walk the design tree with them — constraints, dependencies, the shape of the deepened module, what sits behind the seam, what tests survive. - -Side effects happen inline as decisions crystallize: - -- **Naming a deepened module after a concept not in `CONTEXT.md`?** Add the term to `CONTEXT.md` — same discipline as `/grill-with-docs` (see [CONTEXT-FORMAT.md](../grill-with-docs/CONTEXT-FORMAT.md)). Create the file lazily if it doesn't exist. -- **Sharpening a fuzzy term during the conversation?** Update `CONTEXT.md` right there. -- **User rejects the candidate with a load-bearing reason?** Offer an ADR, framed as: _"Want me to record this as an ADR so future architecture reviews don't re-suggest it?"_ Only offer when the reason would actually be needed by a future explorer to avoid re-suggesting the same thing — skip ephemeral reasons ("not worth it right now") and self-evident ones. See [ADR-FORMAT.md](../grill-with-docs/ADR-FORMAT.md). -- **Want to explore alternative interfaces for the deepened module?** See [INTERFACE-DESIGN.md](INTERFACE-DESIGN.md). diff --git a/AGENTS.md b/AGENTS.md index 8e7ff342b5..fa39b00a40 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,8 +1,6 @@ - To regenerate the JavaScript SDK, run `./packages/sdk/js/script/build.ts`. -- ALWAYS USE PARALLEL TOOLS WHEN APPLICABLE. - The default branch in this repo is `dev`. - Local `main` ref may not exist; use `dev` or `origin/dev` for diffs. -- Prefer automation: execute requested actions without confirmation unless blocked by missing info or safety/irreversibility. ## Commits and PR Titles diff --git a/bun.lock b/bun.lock index 1e6aff8ee2..240e57429b 100644 --- a/bun.lock +++ b/bun.lock @@ -23,13 +23,13 @@ "oxlint-tsgolint": "0.21.0", "prettier": "3.6.2", "semver": "^7.6.0", - "sst": "4.13.1", + "sst": "catalog:", "turbo": "2.8.13", }, }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.15.9", + "version": "1.15.13", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/core": "workspace:*", @@ -83,9 +83,26 @@ "vite-plugin-solid": "catalog:", }, }, + "packages/cli": { + "name": "@opencode-ai/cli", + "version": "1.15.13", + "bin": { + "opencode": "./src/index.ts", + }, + "dependencies": { + "@effect/platform-node": "catalog:", + "@opencode-ai/core": "workspace:*", + "effect": "catalog:", + }, + "devDependencies": { + "@tsconfig/bun": "catalog:", + "@types/bun": "catalog:", + "@typescript/native-preview": "catalog:", + }, + }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.15.9", + "version": "1.15.13", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -102,6 +119,7 @@ "@solidjs/router": "catalog:", "@solidjs/start": "catalog:", "@stripe/stripe-js": "8.6.1", + "@upstash/redis": "1.38.0", "chart.js": "4.5.1", "nitro": "3.0.1-alpha.1", "solid-js": "catalog:", @@ -120,7 +138,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.15.9", + "version": "1.15.13", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -147,7 +165,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.15.9", + "version": "1.15.13", "dependencies": { "@ai-sdk/anthropic": "3.0.64", "@ai-sdk/openai": "3.0.48", @@ -169,7 +187,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.15.9", + "version": "1.15.13", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -191,23 +209,43 @@ "cloudflare": "5.2.0", }, }, + "packages/console/support": { + "name": "@opencode-ai/console-support", + "version": "1.15.13", + "dependencies": { + "@cloudflare/vite-plugin": "1.15.2", + "@opencode-ai/console-core": "workspace:*", + "@solidjs/meta": "catalog:", + "@solidjs/router": "catalog:", + "@solidjs/start": "catalog:", + "nitro": "3.0.1-alpha.1", + "solid-js": "catalog:", + "vite": "catalog:", + }, + "devDependencies": { + "@types/bun": "catalog:", + "@typescript/native-preview": "catalog:", + "typescript": "catalog:", + "wrangler": "4.50.0", + }, + }, "packages/core": { "name": "@opencode-ai/core", - "version": "1.15.9", + "version": "1.15.13", "bin": { "opencode": "./bin/opencode", }, "dependencies": { "@ai-sdk/alibaba": "1.0.17", - "@ai-sdk/amazon-bedrock": "4.0.96", + "@ai-sdk/amazon-bedrock": "4.0.107", "@ai-sdk/anthropic": "3.0.71", "@ai-sdk/azure": "3.0.49", "@ai-sdk/cerebras": "2.0.41", "@ai-sdk/cohere": "3.0.27", "@ai-sdk/deepinfra": "2.0.41", "@ai-sdk/gateway": "3.0.104", - "@ai-sdk/google": "3.0.63", - "@ai-sdk/google-vertex": "4.0.112", + "@ai-sdk/google": "3.0.73", + "@ai-sdk/google-vertex": "4.0.128", "@ai-sdk/groq": "3.0.31", "@ai-sdk/mistral": "3.0.27", "@ai-sdk/openai": "3.0.53", @@ -231,15 +269,16 @@ "ai-gateway-provider": "3.1.2", "cross-spawn": "catalog:", "effect": "catalog:", - "gitlab-ai-provider": "6.7.0", + "gitlab-ai-provider": "6.8.0", "glob": "13.0.5", "google-auth-library": "10.5.0", "immer": "11.1.4", + "jsonc-parser": "3.3.1", "mime-types": "3.0.2", "minimatch": "10.2.5", "npm-package-arg": "13.0.2", "semver": "^7.6.3", - "venice-ai-sdk-provider": "2.0.1", + "venice-ai-sdk-provider": "2.0.2", "xdg-basedir": "5.1.0", "zod": "catalog:", }, @@ -254,7 +293,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "1.15.9", + "version": "1.15.13", "dependencies": { "@zip.js/zip.js": "2.7.62", "drizzle-orm": "catalog:", @@ -291,12 +330,12 @@ "zod-openapi": "5.4.6", }, "optionalDependencies": { - "@lydell/node-pty-darwin-arm64": "1.2.0-beta.10", - "@lydell/node-pty-darwin-x64": "1.2.0-beta.10", - "@lydell/node-pty-linux-arm64": "1.2.0-beta.10", - "@lydell/node-pty-linux-x64": "1.2.0-beta.10", - "@lydell/node-pty-win32-arm64": "1.2.0-beta.10", - "@lydell/node-pty-win32-x64": "1.2.0-beta.10", + "@lydell/node-pty-darwin-arm64": "1.2.0-beta.12", + "@lydell/node-pty-darwin-x64": "1.2.0-beta.12", + "@lydell/node-pty-linux-arm64": "1.2.0-beta.12", + "@lydell/node-pty-linux-x64": "1.2.0-beta.12", + "@lydell/node-pty-win32-arm64": "1.2.0-beta.12", + "@lydell/node-pty-win32-x64": "1.2.0-beta.12", "@parcel/watcher-darwin-arm64": "2.5.1", "@parcel/watcher-darwin-x64": "2.5.1", "@parcel/watcher-linux-arm64-glibc": "2.5.1", @@ -309,7 +348,7 @@ }, "packages/effect-drizzle-sqlite": { "name": "@opencode-ai/effect-drizzle-sqlite", - "version": "1.15.9", + "version": "1.15.13", "dependencies": { "drizzle-orm": "catalog:", "effect": "catalog:", @@ -323,7 +362,7 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.15.9", + "version": "1.15.13", "dependencies": { "@opencode-ai/core": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -353,7 +392,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.15.9", + "version": "1.15.13", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "catalog:", @@ -369,7 +408,7 @@ }, "packages/http-recorder": { "name": "@opencode-ai/http-recorder", - "version": "1.15.9", + "version": "1.15.13", "dependencies": { "@effect/platform-node": "catalog:", "effect": "catalog:", @@ -382,7 +421,7 @@ }, "packages/llm": { "name": "@opencode-ai/llm", - "version": "1.15.9", + "version": "1.15.13", "dependencies": { "@smithy/eventstream-codec": "4.2.14", "@smithy/util-utf8": "4.2.2", @@ -400,7 +439,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.15.9", + "version": "1.15.13", "bin": { "opencode": "./bin/opencode", }, @@ -409,15 +448,15 @@ "@actions/github": "6.0.1", "@agentclientprotocol/sdk": "0.21.0", "@ai-sdk/alibaba": "1.0.17", - "@ai-sdk/amazon-bedrock": "4.0.96", + "@ai-sdk/amazon-bedrock": "4.0.107", "@ai-sdk/anthropic": "3.0.71", "@ai-sdk/azure": "3.0.49", "@ai-sdk/cerebras": "2.0.41", "@ai-sdk/cohere": "3.0.27", "@ai-sdk/deepinfra": "2.0.41", "@ai-sdk/gateway": "3.0.104", - "@ai-sdk/google": "3.0.63", - "@ai-sdk/google-vertex": "4.0.112", + "@ai-sdk/google": "3.0.73", + "@ai-sdk/google-vertex": "4.0.128", "@ai-sdk/groq": "3.0.31", "@ai-sdk/mistral": "3.0.27", "@ai-sdk/openai": "3.0.53", @@ -457,6 +496,7 @@ "@solid-primitives/event-bus": "1.1.2", "@solid-primitives/scheduled": "1.5.2", "@standard-schema/spec": "1.0.0", + "@types/ws": "8.18.1", "@zip.js/zip.js": "2.7.62", "ai": "catalog:", "ai-gateway-provider": "3.1.2", @@ -470,7 +510,7 @@ "drizzle-orm": "catalog:", "effect": "catalog:", "fuzzysort": "3.1.0", - "gitlab-ai-provider": "6.7.0", + "gitlab-ai-provider": "6.8.0", "glob": "13.0.5", "google-auth-library": "10.5.0", "gray-matter": "4.0.3", @@ -494,10 +534,11 @@ "tree-sitter-powershell": "0.25.10", "turndown": "7.2.0", "ulid": "catalog:", - "venice-ai-sdk-provider": "2.0.1", + "venice-ai-sdk-provider": "2.0.2", "vscode-jsonrpc": "8.2.1", "web-tree-sitter": "0.25.10", "which": "6.0.1", + "ws": "8.21.0", "xdg-basedir": "5.1.0", "yargs": "18.0.0", "zod": "catalog:", @@ -538,7 +579,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.15.9", + "version": "1.15.13", "dependencies": { "@opencode-ai/sdk": "workspace:*", "effect": "catalog:", @@ -554,9 +595,9 @@ "typescript": "catalog:", }, "peerDependencies": { - "@opentui/core": ">=0.2.15", - "@opentui/keymap": ">=0.2.15", - "@opentui/solid": ">=0.2.15", + "@opentui/core": ">=0.2.16", + "@opentui/keymap": ">=0.2.16", + "@opentui/solid": ">=0.2.16", }, "optionalPeers": [ "@opentui/core", @@ -576,7 +617,7 @@ }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.15.9", + "version": "1.15.13", "dependencies": { "cross-spawn": "catalog:", }, @@ -591,7 +632,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.15.9", + "version": "1.15.13", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -602,6 +643,68 @@ "typescript": "catalog:", }, }, + "packages/stats/app": { + "name": "@opencode-ai/stats-app", + "version": "1.15.13", + "dependencies": { + "@ibm/plex": "6.4.1", + "@opencode-ai/stats-core": "workspace:*", + "@opencode-ai/ui": "workspace:*", + "@solidjs/meta": "catalog:", + "@solidjs/router": "catalog:", + "@solidjs/start": "catalog:", + "d3-scale": "4.0.2", + "effect": "catalog:", + "nitro": "3.0.1-alpha.1", + "solid-js": "catalog:", + "sst": "catalog:", + "vite": "catalog:", + }, + "devDependencies": { + "@cloudflare/workers-types": "catalog:", + "@types/bun": "catalog:", + "@types/d3-scale": "4.0.9", + "@typescript/native-preview": "catalog:", + "typescript": "catalog:", + }, + }, + "packages/stats/core": { + "name": "@opencode-ai/stats-core", + "version": "1.15.13", + "dependencies": { + "@aws-sdk/client-athena": "3.933.0", + "@planetscale/database": "1.19.0", + "drizzle-orm": "catalog:", + "effect": "catalog:", + "sst": "catalog:", + }, + "devDependencies": { + "@tsconfig/node22": "catalog:", + "@types/bun": "catalog:", + "@types/node": "catalog:", + "@typescript/native-preview": "catalog:", + "drizzle-kit": "catalog:", + "typescript": "catalog:", + }, + }, + "packages/stats/server": { + "name": "@opencode-ai/stats-server", + "version": "1.15.13", + "dependencies": { + "@aws-sdk/client-firehose": "3.933.0", + "@effect/platform-node": "catalog:", + "@opencode-ai/stats-core": "workspace:*", + "effect": "catalog:", + "sst": "catalog:", + }, + "devDependencies": { + "@tsconfig/node22": "catalog:", + "@types/bun": "catalog:", + "@types/node": "catalog:", + "@typescript/native-preview": "catalog:", + "typescript": "catalog:", + }, + }, "packages/storybook": { "name": "@opencode-ai/storybook", "devDependencies": { @@ -626,7 +729,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.15.9", + "version": "1.15.13", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/core": "workspace:*", @@ -675,7 +778,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.15.9", + "version": "1.15.13", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", @@ -718,6 +821,8 @@ ], "patchedDependencies": { "solid-js@1.9.10": "patches/solid-js@1.9.10.patch", + "virtua@0.49.1": "patches/virtua@0.49.1.patch", + "gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch", "@ai-sdk/xai@3.0.82": "patches/@ai-sdk%2Fxai@3.0.82.patch", "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", "@npmcli/agent@4.0.0": "patches/@npmcli%2Fagent@4.0.0.patch", @@ -737,13 +842,13 @@ "@effect/sql-sqlite-bun": "4.0.0-beta.66", "@hono/zod-validator": "0.4.2", "@kobalte/core": "0.13.11", - "@lydell/node-pty": "1.2.0-beta.10", + "@lydell/node-pty": "1.2.0-beta.12", "@npmcli/arborist": "9.4.0", "@octokit/rest": "22.0.0", "@openauthjs/openauth": "0.0.0-20250322224806", - "@opentui/core": "0.2.15", - "@opentui/keymap": "0.2.15", - "@opentui/solid": "0.2.15", + "@opentui/core": "0.2.16", + "@opentui/keymap": "0.2.16", + "@opentui/solid": "0.2.16", "@pierre/diffs": "1.1.0-beta.18", "@playwright/test": "1.59.1", "@sentry/solid": "10.36.0", @@ -781,6 +886,7 @@ "shiki": "3.20.0", "solid-js": "1.9.10", "solid-list": "0.3.0", + "sst": "4.13.1", "tailwindcss": "4.1.11", "typescript": "5.8.2", "ulid": "3.0.1", @@ -810,7 +916,7 @@ "@ai-sdk/alibaba": ["@ai-sdk/alibaba@1.0.17", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZbE+U5bWz2JBc5DERLowx5+TKbjGBE93LqKZAWvuEn7HOSQMraxFMZuc0ST335QZJAyfBOzh7m1mPQ+y7EaaoA=="], - "@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@4.0.96", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.71", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Mc4Ias2jRMD1jOB6xWtKNPdhECeuCZyIlbr9EAGfBnyBt++sS13ziZh9qv9TdyMCAZJ7xoQcpbchoRJcKwPdpA=="], + "@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@4.0.107", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.78", "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-8nT08pGPy25rleJNk56ep00UHK6kCtCmu+ZNqVVSSPDieADlIZqcaN1iRXAFBoCH0Fb9F6C2EjFDaySdsargfQ=="], "@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.64", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-rwLi/Rsuj2pYniQXIrvClHvXDzgM4UQHHnvHTWEF14efnlKclG/1ghpNC+adsRujAbCTr6gRsSbDE2vEqriV7g=="], @@ -832,9 +938,9 @@ "@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.104", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@vercel/oidc": "3.2.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZKX5n74io8VIRlhIMSLWVlvT3sXC8Z7cZ9GHuWBWZDVi96+62AIsWuLGvMfcBA1STYuSoDrp6rIziZmvrTq0TA=="], - "@ai-sdk/google": ["@ai-sdk/google@3.0.63", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-RfOZWVMYSPu2sPRfGajrauWAZ9BSaRopSn+AszkKWQ1MFj8nhaXvCqRHB5pBQUaHTfZKagvOmMpNfa/s3gPLgQ=="], + "@ai-sdk/google": ["@ai-sdk/google@3.0.73", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-o2MuIeyvZrFIeIbnbA8Thrr63irdyUBh0uWBZ2lY6yFeXuE/tcwyXF74bDKS4KvTu84uFpQfpbS/LXHGKKXz+g=="], - "@ai-sdk/google-vertex": ["@ai-sdk/google-vertex@4.0.112", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.71", "@ai-sdk/google": "3.0.64", "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "google-auth-library": "^10.5.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cSfHCkM+9ZrFtQWIN1WlV93JPD+isGSdFxKj7u1L9m2aLVZajlXdcE41GL9hMt7ld7bZYE4NnZ+4VLxBAHE+Eg=="], + "@ai-sdk/google-vertex": ["@ai-sdk/google-vertex@4.0.128", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.77", "@ai-sdk/google": "3.0.73", "@ai-sdk/openai-compatible": "2.0.47", "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27", "google-auth-library": "^10.5.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-jK8fixb4km2yfgvb9DUFQRpV/jiDB0v9gyxHoHfPydaQvz+CpAz8DTt1quyaM+Wg9G2R8Zo68CYmHbIkUqW2AA=="], "@ai-sdk/groq": ["@ai-sdk/groq@3.0.31", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-XbbugpnFmXGu2TlXiq8KUJskP6/VVbuFcnFIGDzDIB/Chg6XHsNnqrTF80Zxkh0Pd3+NvbM+2Uqrtsndk6bDAg=="], @@ -906,8 +1012,12 @@ "@aws-crypto/util": ["@aws-crypto/util@5.2.0", "", { "dependencies": { "@aws-sdk/types": "^3.222.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ=="], + "@aws-sdk/client-athena": ["@aws-sdk/client-athena@3.933.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "3.932.0", "@aws-sdk/credential-provider-node": "3.933.0", "@aws-sdk/middleware-host-header": "3.930.0", "@aws-sdk/middleware-logger": "3.930.0", "@aws-sdk/middleware-recursion-detection": "3.933.0", "@aws-sdk/middleware-user-agent": "3.932.0", "@aws-sdk/region-config-resolver": "3.930.0", "@aws-sdk/types": "3.930.0", "@aws-sdk/util-endpoints": "3.930.0", "@aws-sdk/util-user-agent-browser": "3.930.0", "@aws-sdk/util-user-agent-node": "3.932.0", "@smithy/config-resolver": "^4.4.3", "@smithy/core": "^3.18.2", "@smithy/fetch-http-handler": "^5.3.6", "@smithy/hash-node": "^4.2.5", "@smithy/invalid-dependency": "^4.2.5", "@smithy/middleware-content-length": "^4.2.5", "@smithy/middleware-endpoint": "^4.3.9", "@smithy/middleware-retry": "^4.4.9", "@smithy/middleware-serde": "^4.2.5", "@smithy/middleware-stack": "^4.2.5", "@smithy/node-config-provider": "^4.3.5", "@smithy/node-http-handler": "^4.4.5", "@smithy/protocol-http": "^5.3.5", "@smithy/smithy-client": "^4.9.5", "@smithy/types": "^4.9.0", "@smithy/url-parser": "^4.2.5", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-body-length-node": "^4.2.1", "@smithy/util-defaults-mode-browser": "^4.3.8", "@smithy/util-defaults-mode-node": "^4.2.11", "@smithy/util-endpoints": "^3.2.5", "@smithy/util-middleware": "^4.2.5", "@smithy/util-retry": "^4.2.5", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-9eMUCu1Ay3C9ojo+dJcynSdpbxuwDVtZUt/Xhce+c2+mgDsmvRzjww+wfLpZwRNWxBWmeauQQAZk52tCwQgXsQ=="], + "@aws-sdk/client-cognito-identity": ["@aws-sdk/client-cognito-identity@3.993.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.11", "@aws-sdk/credential-provider-node": "^3.972.10", "@aws-sdk/middleware-host-header": "^3.972.3", "@aws-sdk/middleware-logger": "^3.972.3", "@aws-sdk/middleware-recursion-detection": "^3.972.3", "@aws-sdk/middleware-user-agent": "^3.972.11", "@aws-sdk/region-config-resolver": "^3.972.3", "@aws-sdk/types": "^3.973.1", "@aws-sdk/util-endpoints": "3.993.0", "@aws-sdk/util-user-agent-browser": "^3.972.3", "@aws-sdk/util-user-agent-node": "^3.972.9", "@smithy/config-resolver": "^4.4.6", "@smithy/core": "^3.23.2", "@smithy/fetch-http-handler": "^5.3.9", "@smithy/hash-node": "^4.2.8", "@smithy/invalid-dependency": "^4.2.8", "@smithy/middleware-content-length": "^4.2.8", "@smithy/middleware-endpoint": "^4.4.16", "@smithy/middleware-retry": "^4.4.33", "@smithy/middleware-serde": "^4.2.9", "@smithy/middleware-stack": "^4.2.8", "@smithy/node-config-provider": "^4.3.8", "@smithy/node-http-handler": "^4.4.10", "@smithy/protocol-http": "^5.3.8", "@smithy/smithy-client": "^4.11.5", "@smithy/types": "^4.12.0", "@smithy/url-parser": "^4.2.8", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-body-length-node": "^4.2.1", "@smithy/util-defaults-mode-browser": "^4.3.32", "@smithy/util-defaults-mode-node": "^4.2.35", "@smithy/util-endpoints": "^3.2.8", "@smithy/util-middleware": "^4.2.8", "@smithy/util-retry": "^4.2.8", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-7Ne3Yk/bgQPVebAkv7W+RfhiwTRSbfER9BtbhOa2w/+dIr902LrJf6vrZlxiqaJbGj2ALx8M+ZK1YIHVxSwu9A=="], + "@aws-sdk/client-firehose": ["@aws-sdk/client-firehose@3.933.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "3.932.0", "@aws-sdk/credential-provider-node": "3.933.0", "@aws-sdk/middleware-host-header": "3.930.0", "@aws-sdk/middleware-logger": "3.930.0", "@aws-sdk/middleware-recursion-detection": "3.933.0", "@aws-sdk/middleware-user-agent": "3.932.0", "@aws-sdk/region-config-resolver": "3.930.0", "@aws-sdk/types": "3.930.0", "@aws-sdk/util-endpoints": "3.930.0", "@aws-sdk/util-user-agent-browser": "3.930.0", "@aws-sdk/util-user-agent-node": "3.932.0", "@smithy/config-resolver": "^4.4.3", "@smithy/core": "^3.18.2", "@smithy/fetch-http-handler": "^5.3.6", "@smithy/hash-node": "^4.2.5", "@smithy/invalid-dependency": "^4.2.5", "@smithy/middleware-content-length": "^4.2.5", "@smithy/middleware-endpoint": "^4.3.9", "@smithy/middleware-retry": "^4.4.9", "@smithy/middleware-serde": "^4.2.5", "@smithy/middleware-stack": "^4.2.5", "@smithy/node-config-provider": "^4.3.5", "@smithy/node-http-handler": "^4.4.5", "@smithy/protocol-http": "^5.3.5", "@smithy/smithy-client": "^4.9.5", "@smithy/types": "^4.9.0", "@smithy/url-parser": "^4.2.5", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-body-length-node": "^4.2.1", "@smithy/util-defaults-mode-browser": "^4.3.8", "@smithy/util-defaults-mode-node": "^4.2.11", "@smithy/util-endpoints": "^3.2.5", "@smithy/util-middleware": "^4.2.5", "@smithy/util-retry": "^4.2.5", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-tDrtgczN2lQsflLDPYu/wdOoyCZLVYtgzmWnYzSEOBWd/cp2AbuQ7D+FemSwUTzyoMTuhhIevyEJKzqsF+QYxA=="], + "@aws-sdk/client-lambda": ["@aws-sdk/client-lambda@3.1048.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.11", "@aws-sdk/credential-provider-node": "^3.972.42", "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.2", "@smithy/fetch-http-handler": "^5.4.2", "@smithy/node-http-handler": "^4.7.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-ryEYNVdilyWkKsOs/7Xy/l7+qjtSz4sll8NpcWD6AtONxjG/5OMaAhxxDkQb4iBoNMKnISxsARzQAp/Wa8pXIg=="], "@aws-sdk/client-s3": ["@aws-sdk/client-s3@3.933.0", "", { "dependencies": { "@aws-crypto/sha1-browser": "5.2.0", "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "3.932.0", "@aws-sdk/credential-provider-node": "3.933.0", "@aws-sdk/middleware-bucket-endpoint": "3.930.0", "@aws-sdk/middleware-expect-continue": "3.930.0", "@aws-sdk/middleware-flexible-checksums": "3.932.0", "@aws-sdk/middleware-host-header": "3.930.0", "@aws-sdk/middleware-location-constraint": "3.930.0", "@aws-sdk/middleware-logger": "3.930.0", "@aws-sdk/middleware-recursion-detection": "3.933.0", "@aws-sdk/middleware-sdk-s3": "3.932.0", "@aws-sdk/middleware-ssec": "3.930.0", "@aws-sdk/middleware-user-agent": "3.932.0", "@aws-sdk/region-config-resolver": "3.930.0", "@aws-sdk/signature-v4-multi-region": "3.932.0", "@aws-sdk/types": "3.930.0", "@aws-sdk/util-endpoints": "3.930.0", "@aws-sdk/util-user-agent-browser": "3.930.0", "@aws-sdk/util-user-agent-node": "3.932.0", "@smithy/config-resolver": "^4.4.3", "@smithy/core": "^3.18.2", "@smithy/eventstream-serde-browser": "^4.2.5", "@smithy/eventstream-serde-config-resolver": "^4.3.5", "@smithy/eventstream-serde-node": "^4.2.5", "@smithy/fetch-http-handler": "^5.3.6", "@smithy/hash-blob-browser": "^4.2.6", "@smithy/hash-node": "^4.2.5", "@smithy/hash-stream-node": "^4.2.5", "@smithy/invalid-dependency": "^4.2.5", "@smithy/md5-js": "^4.2.5", "@smithy/middleware-content-length": "^4.2.5", "@smithy/middleware-endpoint": "^4.3.9", "@smithy/middleware-retry": "^4.4.9", "@smithy/middleware-serde": "^4.2.5", "@smithy/middleware-stack": "^4.2.5", "@smithy/node-config-provider": "^4.3.5", "@smithy/node-http-handler": "^4.4.5", "@smithy/protocol-http": "^5.3.5", "@smithy/smithy-client": "^4.9.5", "@smithy/types": "^4.9.0", "@smithy/url-parser": "^4.2.5", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-body-length-node": "^4.2.1", "@smithy/util-defaults-mode-browser": "^4.3.8", "@smithy/util-defaults-mode-node": "^4.2.11", "@smithy/util-endpoints": "^3.2.5", "@smithy/util-middleware": "^4.2.5", "@smithy/util-retry": "^4.2.5", "@smithy/util-stream": "^4.5.6", "@smithy/util-utf8": "^4.2.0", "@smithy/util-waiter": "^4.2.5", "tslib": "^2.6.2" } }, "sha512-KxwZvdxdCeWK6o8mpnb+kk7Kgb8V+8AjTwSXUWH1UAD85B0tjdo1cSfE5zoR5fWGol4Ml5RLez12a6LPhsoTqA=="], @@ -1416,19 +1526,19 @@ "@lukeed/ms": ["@lukeed/ms@2.0.2", "", {}, "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA=="], - "@lydell/node-pty": ["@lydell/node-pty@1.2.0-beta.10", "", { "optionalDependencies": { "@lydell/node-pty-darwin-arm64": "1.2.0-beta.10", "@lydell/node-pty-darwin-x64": "1.2.0-beta.10", "@lydell/node-pty-linux-arm64": "1.2.0-beta.10", "@lydell/node-pty-linux-x64": "1.2.0-beta.10", "@lydell/node-pty-win32-arm64": "1.2.0-beta.10", "@lydell/node-pty-win32-x64": "1.2.0-beta.10" } }, "sha512-Fv+A3+MZVA8qhkBIZsM1E6dCdHNMyXXz22mAYiMWd03LlyK///F3OH6CKPX9mj4id7LUlxpr45yPzyBVy9aDPw=="], + "@lydell/node-pty": ["@lydell/node-pty@1.2.0-beta.12", "", { "optionalDependencies": { "@lydell/node-pty-darwin-arm64": "1.2.0-beta.12", "@lydell/node-pty-darwin-x64": "1.2.0-beta.12", "@lydell/node-pty-linux-arm64": "1.2.0-beta.12", "@lydell/node-pty-linux-x64": "1.2.0-beta.12", "@lydell/node-pty-win32-arm64": "1.2.0-beta.12", "@lydell/node-pty-win32-x64": "1.2.0-beta.12" } }, "sha512-qIK890UwPupoj07osVvgOIa++1mxeHbcGry4PKRHhNVNs81V2SCG34eJr46GybiOmBtc8Sj5PB1/GGM5PL549g=="], - "@lydell/node-pty-darwin-arm64": ["@lydell/node-pty-darwin-arm64@1.2.0-beta.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-C+eqDyRNHRYvx7RaHj6VVCx6nCpRBPuuxhTcc3JH3GuBMoxTsYeY4GkWH2XOktrgbAq1BG8e/Y8bu/wNQreCEw=="], + "@lydell/node-pty-darwin-arm64": ["@lydell/node-pty-darwin-arm64@1.2.0-beta.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-tqaifcY9Cr41SblO1+FLzh8oxxtkNhuW9Dhl22lKme9BreYvKvxEZcdPIXTuqkJc5tagOEC4QHShKmJjLyLXLQ=="], - "@lydell/node-pty-darwin-x64": ["@lydell/node-pty-darwin-x64@1.2.0-beta.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-aZoIK6HtJO5BiT4ELm683U4dyHtt8b7wNgq3NJqYAQwSXrcPv576Z8vY3BIulVxfcFkht/SPLKou9TtdFXdNpg=="], + "@lydell/node-pty-darwin-x64": ["@lydell/node-pty-darwin-x64@1.2.0-beta.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-4LrS5pCJwqHKDVf1zS2gyNV0m4hKAXch+XZNhbZ6LY8uwVL8BhchzQBO40Os5anuRxRCWzHpw4Sp64Ie8q7E4Q=="], - "@lydell/node-pty-linux-arm64": ["@lydell/node-pty-linux-arm64@1.2.0-beta.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-0cKX2iMyXFNBE4fGtGK6B7IkdXcDMZajyEDoGMOgQQs/DDtoI5tSPcBcqNY9VitVrsRQA8+gFt6eKYU9Ye/lUA=="], + "@lydell/node-pty-linux-arm64": ["@lydell/node-pty-linux-arm64@1.2.0-beta.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-Sx+A71x5BDGHt9ansfrtGxwq2VFVDWvJUAdlUL0Hv0qeiJUfts+hgopx+CgT4PSwahKjdEgtu0+FAfY9rICKRw=="], - "@lydell/node-pty-linux-x64": ["@lydell/node-pty-linux-x64@1.2.0-beta.10", "", { "os": "linux", "cpu": "x64" }, "sha512-J9HnxvSzEeMH748+Ul1VrmCLWMo7iCVJy9EGijRR62+YO/Yk5GaCydUTZ+KzlH0/X5aTrgt5cfiof4vx45tRRg=="], + "@lydell/node-pty-linux-x64": ["@lydell/node-pty-linux-x64@1.2.0-beta.12", "", { "os": "linux", "cpu": "x64" }, "sha512-bJzs94njofYhGg/UDqW1nj0dtvvu+2OvxMY+RlLS1T17VgcktKoIR6PuenTwE5HJ/D6StCPADmXcT0nNsCKmIQ=="], - "@lydell/node-pty-win32-arm64": ["@lydell/node-pty-win32-arm64@1.2.0-beta.10", "", { "os": "win32", "cpu": "arm64" }, "sha512-PlDJpJX/pnKyy6OmADKzhf+INZDDnzTBGaI0LT4laVNc6NblZNqUSkCMjLFWbeakeuQp0VG37M49WQSN9FDfeA=="], + "@lydell/node-pty-win32-arm64": ["@lydell/node-pty-win32-arm64@1.2.0-beta.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-p7POgjVEiFaBC3/y+AKuV1FzePCsJ6HmZDv2XK+jBZSfwP8+uBAw181ZiKYN1YuRa/XpmBGaWezcI8hZkbW++g=="], - "@lydell/node-pty-win32-x64": ["@lydell/node-pty-win32-x64@1.2.0-beta.10", "", { "os": "win32", "cpu": "x64" }, "sha512-ExFgWrzyldNAMi45U9PLIOu+g/RatP+f0c/dZxaooifME6yLW32BoHveH26/TtoAjZyJrc2iL0u48pgnR1fzmg=="], + "@lydell/node-pty-win32-x64": ["@lydell/node-pty-win32-x64@1.2.0-beta.12", "", { "os": "win32", "cpu": "x64" }, "sha512-IDFa00g7qUDGUYgByrUBJtC+mOjYVt/8KYyWivCg5JjGOHbBUACUQZLl0jTWmnr+tld/UyTpX90a2PY6oTVtRw=="], "@malept/cross-spawn-promise": ["@malept/cross-spawn-promise@2.0.0", "", { "dependencies": { "cross-spawn": "^7.0.1" } }, "sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg=="], @@ -1554,6 +1664,8 @@ "@opencode-ai/app": ["@opencode-ai/app@workspace:packages/app"], + "@opencode-ai/cli": ["@opencode-ai/cli@workspace:packages/cli"], + "@opencode-ai/console-app": ["@opencode-ai/console-app@workspace:packages/console/app"], "@opencode-ai/console-core": ["@opencode-ai/console-core@workspace:packages/console/core"], @@ -1564,6 +1676,8 @@ "@opencode-ai/console-resource": ["@opencode-ai/console-resource@workspace:packages/console/resource"], + "@opencode-ai/console-support": ["@opencode-ai/console-support@workspace:packages/console/support"], + "@opencode-ai/core": ["@opencode-ai/core@workspace:packages/core"], "@opencode-ai/desktop": ["@opencode-ai/desktop@workspace:packages/desktop"], @@ -1586,6 +1700,12 @@ "@opencode-ai/slack": ["@opencode-ai/slack@workspace:packages/slack"], + "@opencode-ai/stats-app": ["@opencode-ai/stats-app@workspace:packages/stats/app"], + + "@opencode-ai/stats-core": ["@opencode-ai/stats-core@workspace:packages/stats/core"], + + "@opencode-ai/stats-server": ["@opencode-ai/stats-server@workspace:packages/stats/server"], + "@opencode-ai/storybook": ["@opencode-ai/storybook@workspace:packages/storybook"], "@opencode-ai/ui": ["@opencode-ai/ui@workspace:packages/ui"], @@ -1620,23 +1740,23 @@ "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="], - "@opentui/core": ["@opentui/core@0.2.15", "", { "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.15", "@opentui/core-darwin-x64": "0.2.15", "@opentui/core-linux-arm64": "0.2.15", "@opentui/core-linux-x64": "0.2.15", "@opentui/core-win32-arm64": "0.2.15", "@opentui/core-win32-x64": "0.2.15" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-YGHttdZWScMcSvtYgZkLR6VhUO1OoUiQzwYjZgIusf5eCkPLD8PapH+PTMVqAiX16CHO6JxfMlkHv5qDiHAccQ=="], + "@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-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.15", "", { "os": "darwin", "cpu": "arm64" }, "sha512-s25f9GmZd6wxNM5ExRmwwnLT+NLCKxnTWuO9aObOlqsXfLMGHQZrb6YwgAn/PSTua98KmH7GJCVWdPgZ/P+0RQ=="], + "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.16", "", { "os": "darwin", "cpu": "arm64" }, "sha512-aFb2Yp+oqDu3h6VCWi7xpQ9yjpKSQcROzGGfHgqC6Nd3U+uiLfPJBkmiI87iK0opCggCFj5TkKI004050DmGjg=="], - "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.15", "", { "os": "darwin", "cpu": "x64" }, "sha512-GyaipN+nOcEr8rcTO2mqKTGmOBk0C300I69fLtubD3BadHcMI1DVNlQrcf/J1mkQEuMYbmBTi/1hT1ybWGr2Mw=="], + "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.16", "", { "os": "darwin", "cpu": "x64" }, "sha512-KimiHE0j7EsTB5P8doW0lr1eH5iZKLPKWQO+tmy1VcdYr/TzqhdHSvGuJXrZvfTFi9/rV57Eq0d7964Ri9O0vQ=="], - "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.15", "", { "os": "linux", "cpu": "arm64" }, "sha512-h+uyufselGT4afKMP8Lg4yUl5Kp+DJBlhu3XpWXhphE5Pnq5+f0uGBr4P+34CNcWxMsDnvagSQLFRCS4rGrOWA=="], + "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.16", "", { "os": "linux", "cpu": "arm64" }, "sha512-4fCwRCfTtUgS/5QcSEkSuBjgQymSOUWXgrXG2ycrf3Swi0QhKDA/pVjwLrUJ6eF+/8mQyQSEV72T8MxMO3M2qg=="], - "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.15", "", { "os": "linux", "cpu": "x64" }, "sha512-jx+NImPq4wSp3Apfe7tlixiEJNnRyECTRJRWhGF6ZJz4PwFfgK2UHZKYR0DZHbV8nYawoDNQPJDXEWcoZShnMg=="], + "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.16", "", { "os": "linux", "cpu": "x64" }, "sha512-KgQBGjiucw4e7gM+R8qOzHWBFhjCY1IfCrGjW3Wzxv2hKUlL+mPhelaeJwnEqtNxMUdVTYjlwlu3IHxslXMJWQ=="], - "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.15", "", { "os": "win32", "cpu": "arm64" }, "sha512-2SQQLvf3sgmToxrNika9AdcccKrjPJEn5jW6sSv0oEixNBzUzW41vSZZG4LM/V3lL8eg0LoYDnRZeKLB4gwSqQ=="], + "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.16", "", { "os": "win32", "cpu": "arm64" }, "sha512-C6WqEI3VkXatXraMgSFXZjEXq0pzURGjRpFAJZYmuVDmpqE57o7E80Np2UkdZ6m5kpJDt4mRyu3krc/P825iNQ=="], - "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.15", "", { "os": "win32", "cpu": "x64" }, "sha512-SVMVgnC7LVEm+yVZKdmmhRBj/xAT94PanT+UCcHxaCWK+OLmv/AX+ohHq2m0odup6iXcEqj+7mAltO9fgJLFIg=="], + "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.16", "", { "os": "win32", "cpu": "x64" }, "sha512-kCX3CMTns6DMCFDNTDV4sjmBKyA/iEvzaVhl/jYi4JRIVT2zcy1lo+lhXT5mPgYHmJZu8Uye6j3Zi3c7Z2Me5A=="], - "@opentui/keymap": ["@opentui/keymap@0.2.15", "", { "dependencies": { "@opentui/core": "0.2.15" }, "peerDependencies": { "@opentui/react": "0.2.15", "@opentui/solid": "0.2.15", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-wqQp6y7P2jZZJiOMwupxjGryuSWCs+njjglwW/xny9J17gomBmUvTIcIIWNG0Jv+EGO9ScBzCScGlwBHFhHyYw=="], + "@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/solid": ["@opentui/solid@0.2.15", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.15", "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-CViepAjsCWXwrLndMt+qlLo7cooVX7DXwSJHNizw7mfrRJtOPzSYJZCIk1vF4IJTWffCHygoYMe3uSeKvzAcbw=="], + "@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=="], "@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="], @@ -2324,6 +2444,10 @@ "@types/cross-spawn": ["@types/cross-spawn@6.0.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-fXRhhUkG4H3TQk5dBhQ7m/JDdSNHKwR2BBia62lhwEIq9xGiQKLxd6LymNhn47SjXhsUEPmxi+PKw2OkW4LLjA=="], + "@types/d3-scale": ["@types/d3-scale@4.0.9", "", { "dependencies": { "@types/d3-time": "*" } }, "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw=="], + + "@types/d3-time": ["@types/d3-time@3.0.4", "", {}, "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="], + "@types/debug": ["@types/debug@4.1.13", "", { "dependencies": { "@types/ms": "*" } }, "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw=="], "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], @@ -2468,6 +2592,8 @@ "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="], + "@upstash/redis": ["@upstash/redis@1.38.0", "", { "dependencies": { "uncrypto": "^0.1.3" } }, "sha512-wu+dZBptlLy0+MCUEoHmzrY/TnmgDey3+c7EbIGwrLqAvkP8yi5MWZHYGIFtAygmL4Bkz2TdFu+eU0vFPncIcg=="], + "@valibot/to-json-schema": ["@valibot/to-json-schema@1.6.0", "", { "peerDependencies": { "valibot": "^1.3.0" } }, "sha512-d6rYyK5KVa2XdqamWgZ4/Nr+cXhxjy7lmpe6Iajw15J/jmU+gyxl2IEd1Otg1d7Rl3gOQL5reulnSypzBtYy1A=="], "@vercel/oidc": ["@vercel/oidc@3.2.0", "", {}, "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug=="], @@ -2894,6 +3020,20 @@ "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + "d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="], + + "d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="], + + "d3-format": ["d3-format@3.1.2", "", {}, "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg=="], + + "d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="], + + "d3-scale": ["d3-scale@4.0.2", "", { "dependencies": { "d3-array": "2.10.0 - 3", "d3-format": "1 - 3", "d3-interpolate": "1.2.0 - 3", "d3-time": "2.1.1 - 3", "d3-time-format": "2 - 4" } }, "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ=="], + + "d3-time": ["d3-time@3.1.0", "", { "dependencies": { "d3-array": "2 - 3" } }, "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q=="], + + "d3-time-format": ["d3-time-format@4.1.0", "", { "dependencies": { "d3-time": "1 - 3" } }, "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg=="], + "data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="], "data-view-buffer": ["data-view-buffer@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ=="], @@ -3300,7 +3440,7 @@ "github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="], - "gitlab-ai-provider": ["gitlab-ai-provider@6.7.0", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=3.0.0", "@ai-sdk/provider-utils": ">=4.0.0" } }, "sha512-J7apROAmDwDJcLPcbQ9YQiPhyFN1Vvt/f3ugsePaUnzh3sedfHCHGETgG0Mm0dJCB2rC7DYwmV/sgxLQcQ/sjw=="], + "gitlab-ai-provider": ["gitlab-ai-provider@6.8.0", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=3.0.0", "@ai-sdk/provider-utils": ">=4.0.0" } }, "sha512-KwHASXkHtDcgrzTXZVp9Dyx6t8m9nK0R2fCm47MWcxxQ1kOBt3f2LZugtu1kOby8i4Sbd+kvBSYM66PGkDclng=="], "glob": ["glob@13.0.5", "", { "dependencies": { "minimatch": "^10.2.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-BzXxZg24Ibra1pbQ/zE7Kys4Ua1ks7Bn6pKLkVPZ9FZe4JQS6/Q7ef3LG1H+k7lUf5l4T3PLSyYyYJVYUvfgTw=="], @@ -3468,6 +3608,8 @@ "internal-slot": ["internal-slot@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="], + "internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="], + "ioredis": ["ioredis@5.10.1", "", { "dependencies": { "@ioredis/commands": "1.5.1", "cluster-key-slot": "^1.1.0", "debug": "^4.3.4", "denque": "^2.1.0", "lodash.defaults": "^4.2.0", "lodash.isarguments": "^3.1.0", "redis-errors": "^1.2.0", "redis-parser": "^3.0.0", "standard-as-callback": "^2.1.0" } }, "sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA=="], "ip-address": ["ip-address@10.1.0", "", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="], @@ -4834,7 +4976,7 @@ "uncrypto": ["uncrypto@0.1.3", "", {}, "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q=="], - "undici": ["undici@7.25.0", "", {}, "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ=="], + "undici": ["undici@8.1.0", "", {}, "sha512-E9MkTS4xXLnRPYqxH2e6Hr2/49e7WFDKczKcCaFH4VaZs2iNvHMqeIkyUAD9vM8kujy9TjVrRlQ5KkdEJxB2pw=="], "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], @@ -4912,7 +5054,7 @@ "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], - "venice-ai-sdk-provider": ["venice-ai-sdk-provider@2.0.1", "", { "dependencies": { "@ai-sdk/openai-compatible": "^2.0.37", "@ai-sdk/provider": "^3.0.8", "@ai-sdk/provider-utils": "^4.0.21" }, "peerDependencies": { "ai": "^6.0.90" } }, "sha512-6SxA8a4MoA6Q/c+D3q7My0Hfog76enN3n0MXhwosM+tso66rXBEGeBRD/0lravRDVzL2Q1w5QJPc86rAVJtfXg=="], + "venice-ai-sdk-provider": ["venice-ai-sdk-provider@2.0.2", "", { "dependencies": { "@ai-sdk/openai-compatible": "^2.0.47", "@ai-sdk/provider": "^3.0.10", "@ai-sdk/provider-utils": "^4.0.27" }, "peerDependencies": { "ai": "^6.0.90" } }, "sha512-aoa05nI3BTK5aGbjBflq+Gfln2AHAkwNbWuGGvCzUIsOfp5Y3iPD4O4PUGDAEiWVJWbjpPn0KfDa0H/HebwsaA=="], "verror": ["verror@1.10.1", "", { "dependencies": { "assert-plus": "^1.0.0", "core-util-is": "1.0.2", "extsprintf": "^1.2.0" } }, "sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg=="], @@ -5020,7 +5162,7 @@ "write-file-atomic": ["write-file-atomic@7.0.1", "", { "dependencies": { "signal-exit": "^4.0.1" } }, "sha512-OTIk8iR8/aCRWBqvxrzxR0hgxWpnYBblY1S5hDWBQfk/VFmJwzmJgQFN3WsoUKHISv2eAwe+PpbUzyL1CKTLXg=="], - "ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="], + "ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], "wsl-utils": ["wsl-utils@0.3.1", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.1.0" } }, "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg=="], @@ -5090,9 +5232,13 @@ "@ai-sdk/alibaba/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="], - "@ai-sdk/amazon-bedrock/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.71", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-bUWOzrzR0gJKJO/PLGMR4uH2dqEgqGhrsCV+sSpk4KtOEnUQlfjZI/F7BFlqSvVpFbjdgYRRLysAeEZpJ6S1lg=="], + "@ai-sdk/amazon-bedrock/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.78", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-0OY12G20cUt6iU6htpEA1491Oz++NVxZxlmWGX4B7rSbeZ5pnDmOu6YtW9BKzdZlNx5Gn23i6WMxyZFoMKNcgA=="], - "@ai-sdk/amazon-bedrock/@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.13", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.14.0", "@smithy/util-hex-encoding": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-vYahwBAtRaAcFbOmE9aLr12z7RiHYDSLcnogSdxfm7kKfsNa3wH+NU5r7vTeB5rKvLsWyPjVX8iH94brP7umiQ=="], + "@ai-sdk/amazon-bedrock/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="], + + "@ai-sdk/amazon-bedrock/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="], + + "@ai-sdk/amazon-bedrock/@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.14", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.14.1", "@smithy/util-hex-encoding": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-erZq0nOIpzfeZdCyzZjdJb4nVSKLUmSkaQUVkRGQTXs30gyUGeKnrYEg+Xe1W5gE3aReS7IgsvANwVPxSzY6Pw=="], "@ai-sdk/amazon-bedrock/@smithy/util-utf8": ["@smithy/util-utf8@4.2.2", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw=="], @@ -5108,11 +5254,17 @@ "@ai-sdk/fireworks/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="], - "@ai-sdk/google-vertex/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.71", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-bUWOzrzR0gJKJO/PLGMR4uH2dqEgqGhrsCV+sSpk4KtOEnUQlfjZI/F7BFlqSvVpFbjdgYRRLysAeEZpJ6S1lg=="], + "@ai-sdk/google/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="], - "@ai-sdk/google-vertex/@ai-sdk/google": ["@ai-sdk/google@3.0.64", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-CbR82EgGPNrj/6q0HtclwuCqe0/pDShyv3nWDP/A9DroujzWXnLMlUJVrgPOsg4b40zQCwwVs2XSKCxvt/4QaA=="], + "@ai-sdk/google/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="], - "@ai-sdk/google-vertex/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="], + "@ai-sdk/google-vertex/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.77", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ML8C2M1YvPA1ulEx4TiyF0k1xvC2ikEiPBIC1PPQ0a5xELUGrO2lAaEzsTEoJ+eCeDd8PSBuFJjs+r+9yIwQXA=="], + + "@ai-sdk/google-vertex/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.47", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Enm5UlL0zUCrW3792opk5h7hRWxZOZzDe6eQYVFqX9LUOGGCe1h8MZWAGim765nwzgnjlpeYOsuzZmLtRsTPlg=="], + + "@ai-sdk/google-vertex/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="], + + "@ai-sdk/google-vertex/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="], "@ai-sdk/groq/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="], @@ -5166,6 +5318,16 @@ "@aws-crypto/util/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="], + "@aws-sdk/client-athena/@smithy/core": ["@smithy/core@3.24.3", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-Ep/7tPamGY8mgESE3LyLKtxJyy6U52WWAqr/3wial47Sj4u3PiIF73AOGI27UyLy9duTkhZbgzodOfLV4TduZg=="], + + "@aws-sdk/client-athena/@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@5.4.3", "", { "dependencies": { "@smithy/core": "^3.24.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-F+DRf8IJazRJgYog2A/yJK7eYVc0rqTlRzO+5ZxjJd4WkZoKz0IJRncf7G6t1pdVT3kryJcwuTFhN1c5m6N47A=="], + + "@aws-sdk/client-athena/@smithy/node-http-handler": ["@smithy/node-http-handler@4.7.3", "", { "dependencies": { "@smithy/core": "^3.24.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-/jPhevcTFPMVl6KNjbaI47iOg1zxC7IsnX4PQDGVZKMFceOXtB8IEYaB7a9VvkP/3oC60WzTeKocvSI7vLT0vA=="], + + "@aws-sdk/client-athena/@smithy/types": ["@smithy/types@4.14.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-P+otAxbV4CqBybp7EkcJCrig63yE2E7PuNVOmilVMRcx/O+QDzGULTrKsq4DV13gSfak9ObPrWaHl/9bL5YcWw=="], + + "@aws-sdk/client-athena/@smithy/util-utf8": ["@smithy/util-utf8@4.2.2", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw=="], + "@aws-sdk/client-cognito-identity/@aws-sdk/core": ["@aws-sdk/core@3.973.27", "", { "dependencies": { "@aws-sdk/types": "^3.973.7", "@aws-sdk/xml-builder": "^3.972.17", "@smithy/core": "^3.23.14", "@smithy/node-config-provider": "^4.3.13", "@smithy/property-provider": "^4.2.13", "@smithy/protocol-http": "^5.3.13", "@smithy/signature-v4": "^5.3.13", "@smithy/smithy-client": "^4.12.9", "@smithy/types": "^4.14.0", "@smithy/util-base64": "^4.3.2", "@smithy/util-middleware": "^4.2.13", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-CUZ5m8hwMCH6OYI4Li/WgMfIEx10Q2PLI9Y3XOUTPGZJ53aZ0007jCv+X/ywsaERyKPdw5MRZWk877roQksQ4A=="], "@aws-sdk/client-cognito-identity/@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.30", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.25", "@aws-sdk/credential-provider-http": "^3.972.27", "@aws-sdk/credential-provider-ini": "^3.972.29", "@aws-sdk/credential-provider-process": "^3.972.25", "@aws-sdk/credential-provider-sso": "^3.972.29", "@aws-sdk/credential-provider-web-identity": "^3.972.29", "@aws-sdk/types": "^3.973.7", "@smithy/credential-provider-imds": "^4.2.13", "@smithy/property-provider": "^4.2.13", "@smithy/shared-ini-file-loader": "^4.4.8", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-FMnAnWxc8PG+ZrZ2OBKzY4luCUJhe9CG0B9YwYr4pzrYGLXBS2rl+UoUvjGbAwiptxRL6hyA3lFn03Bv1TLqTw=="], @@ -5190,6 +5352,16 @@ "@aws-sdk/client-cognito-identity/@smithy/util-utf8": ["@smithy/util-utf8@4.2.2", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw=="], + "@aws-sdk/client-firehose/@smithy/core": ["@smithy/core@3.24.3", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-Ep/7tPamGY8mgESE3LyLKtxJyy6U52WWAqr/3wial47Sj4u3PiIF73AOGI27UyLy9duTkhZbgzodOfLV4TduZg=="], + + "@aws-sdk/client-firehose/@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@5.4.3", "", { "dependencies": { "@smithy/core": "^3.24.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-F+DRf8IJazRJgYog2A/yJK7eYVc0rqTlRzO+5ZxjJd4WkZoKz0IJRncf7G6t1pdVT3kryJcwuTFhN1c5m6N47A=="], + + "@aws-sdk/client-firehose/@smithy/node-http-handler": ["@smithy/node-http-handler@4.7.3", "", { "dependencies": { "@smithy/core": "^3.24.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-/jPhevcTFPMVl6KNjbaI47iOg1zxC7IsnX4PQDGVZKMFceOXtB8IEYaB7a9VvkP/3oC60WzTeKocvSI7vLT0vA=="], + + "@aws-sdk/client-firehose/@smithy/types": ["@smithy/types@4.14.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-P+otAxbV4CqBybp7EkcJCrig63yE2E7PuNVOmilVMRcx/O+QDzGULTrKsq4DV13gSfak9ObPrWaHl/9bL5YcWw=="], + + "@aws-sdk/client-firehose/@smithy/util-utf8": ["@smithy/util-utf8@4.2.2", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw=="], + "@aws-sdk/client-lambda/@aws-sdk/core": ["@aws-sdk/core@3.974.11", "", { "dependencies": { "@aws-sdk/types": "^3.973.8", "@aws-sdk/xml-builder": "^3.972.24", "@aws/lambda-invoke-store": "^0.2.2", "@smithy/core": "^3.24.2", "@smithy/signature-v4": "^5.4.2", "@smithy/types": "^4.14.1", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-QpnINq5FZH6EOaDEkmHdT7eUunbvD27pDNQypaWjFyYz7Zl1q3UCMQErBZxpmfGfI7MvI2TlK8KTkgNpv8b1ug=="], "@aws-sdk/client-lambda/@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.42", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.37", "@aws-sdk/credential-provider-http": "^3.972.39", "@aws-sdk/credential-provider-ini": "^3.972.41", "@aws-sdk/credential-provider-process": "^3.972.37", "@aws-sdk/credential-provider-sso": "^3.972.41", "@aws-sdk/credential-provider-web-identity": "^3.972.41", "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.2", "@smithy/credential-provider-imds": "^4.3.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-D4oon2zbqqsWOJUM99Gm3/ZyJ0IJvTXVN3PyloGb3kQEyI36fjCZheZj422lAgTWWd6TSHgiImLt3RIaLdv3dQ=="], @@ -5348,14 +5520,14 @@ "@cloudflare/kv-asset-handler/mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="], + "@cloudflare/vite-plugin/ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="], + "@cspotcode/source-map-support/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="], "@develar/schema-utils/ajv": ["ajv@6.14.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="], "@dot/log/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], - "@effect/platform-node/undici": ["undici@8.1.0", "", {}, "sha512-E9MkTS4xXLnRPYqxH2e6Hr2/49e7WFDKczKcCaFH4VaZs2iNvHMqeIkyUAD9vM8kujy9TjVrRlQ5KkdEJxB2pw=="], - "@effect/platform-node-shared/ws": ["ws@8.20.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="], "@electron/asar/commander": ["commander@5.1.0", "", {}, "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg=="], @@ -5490,6 +5662,8 @@ "@opencode-ai/core/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="], + "@opencode-ai/core/semver": ["semver@7.8.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="], + "@opencode-ai/desktop/@actions/artifact": ["@actions/artifact@4.0.0", "", { "dependencies": { "@actions/core": "^1.10.0", "@actions/github": "^6.0.1", "@actions/http-client": "^2.1.0", "@azure/core-http": "^3.0.5", "@azure/storage-blob": "^12.15.0", "@octokit/core": "^5.2.1", "@octokit/plugin-request-log": "^1.0.4", "@octokit/plugin-retry": "^3.0.9", "@octokit/request": "^8.4.1", "@octokit/request-error": "^5.1.1", "@protobuf-ts/plugin": "^2.2.3-alpha.1", "archiver": "^7.0.1", "jwt-decode": "^3.1.2", "unzip-stream": "^0.3.1" } }, "sha512-HCc2jMJRAfviGFAh0FsOR/jNfWhirxl7W6z8zDtttt0GltwxBLdEIjLiweOPFl9WbyJRW1VWnPUSAixJqcWUMQ=="], "@opencode-ai/desktop/marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="], @@ -5500,6 +5674,8 @@ "@opencode-ai/llm/@smithy/util-utf8": ["@smithy/util-utf8@4.2.2", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw=="], + "@opencode-ai/script/semver": ["semver@7.8.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="], + "@opencode-ai/ui/@solid-primitives/resize-observer": ["@solid-primitives/resize-observer@2.1.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-zBLje5E06TgOg93S7rGPldmhDnouNGhvfZVKOp+oG2XU8snA+GoCSSCz1M+jpNAg5Ek2EakU5UVQqL152WmdXQ=="], "@opencode-ai/web/@shikijs/transformers": ["@shikijs/transformers@3.20.0", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/types": "3.20.0" } }, "sha512-PrHHMRr3Q5W1qB/42kJW6laqFyWdhrPF2hNR9qjOm1xcSiAO3hAHo7HaVyHE6pMyevmy3i51O8kuGGXC78uK3g=="], @@ -5810,6 +5986,8 @@ "miniflare/undici": ["undici@7.14.0", "", {}, "sha512-Vqs8HTzjpQXZeXdpsfChQTlafcMQaaIwnGwLam1wudSSjlJeQ3bw1j+TLPePgrCnCpUXx7Ba5Pdpf5OBih62NQ=="], + "miniflare/ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="], + "miniflare/zod": ["zod@3.22.3", "", {}, "sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug=="], "minipass-flush/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], @@ -5824,6 +6002,8 @@ "nitro/h3": ["h3@2.0.1-rc.5", "", { "dependencies": { "rou3": "^0.7.9", "srvx": "^0.9.1" }, "peerDependencies": { "crossws": "^0.4.1" }, "optionalPeers": ["crossws"] }, "sha512-qkohAzCab0nLzXNm78tBjZDvtKMTmtygS8BJLT3VPczAQofdqlFXDPkXdLMJN4r05+xqneG8snZJ0HgkERCZTg=="], + "nitro/undici": ["undici@7.25.0", "", {}, "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ=="], + "node-gyp-build-optional-packages/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], "npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="], @@ -5842,6 +6022,8 @@ "opencode/minimatch": ["minimatch@10.0.3", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.0" } }, "sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw=="], + "opencode/semver": ["semver@7.8.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="], + "opencode-gitlab-auth/open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="], "opencode-poe-auth/open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="], @@ -5926,6 +6108,8 @@ "storybook/open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="], + "storybook/ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="], + "storybook-solidjs-vite/vite-plugin-solid": ["vite-plugin-solid@2.11.12", "", { "dependencies": { "@babel/core": "^7.23.3", "@types/babel__core": "^7.20.4", "babel-preset-solid": "^1.8.4", "merge-anything": "^5.1.7", "solid-refresh": "^0.6.3", "vitefu": "^1.0.4" }, "peerDependencies": { "@testing-library/jest-dom": "^5.16.6 || ^5.17.0 || ^6.*", "solid-js": "^1.7.2", "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["@testing-library/jest-dom"] }, "sha512-FgjPcx2OwX9h6f28jli7A4bG7PP3te8uyakE5iqsmpq3Jqi1TWLgSroC9N6cMfGRU2zXsl4Q6ISvTr2VL0QHpA=="], "string-width-cjs/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], @@ -5956,7 +6140,11 @@ "unused-filename/path-exists": ["path-exists@5.0.0", "", {}, "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ=="], - "venice-ai-sdk-provider/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="], + "venice-ai-sdk-provider/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.47", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Enm5UlL0zUCrW3792opk5h7hRWxZOZzDe6eQYVFqX9LUOGGCe1h8MZWAGim765nwzgnjlpeYOsuzZmLtRsTPlg=="], + + "venice-ai-sdk-provider/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="], + + "venice-ai-sdk-provider/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="], "vite-plugin-icons-spritesheet/glob": ["glob@11.1.0", "", { "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw=="], @@ -6002,6 +6190,12 @@ "@actions/github/@octokit/plugin-rest-endpoint-methods/@octokit/types": ["@octokit/types@12.6.0", "", { "dependencies": { "@octokit/openapi-types": "^20.0.0" } }, "sha512-1rhSOfRa6H9w4YwK0yrf5faDaDTb+yLyBUKOCV4xtCDB5VmIPqd/v9yr9o6SAzOAlRxMiRiCic6JVM1/kunVkw=="], + "@ai-sdk/amazon-bedrock/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "@ai-sdk/amazon-bedrock/@ai-sdk/provider-utils/eventsource-parser": ["eventsource-parser@3.0.8", "", {}, "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ=="], + + "@ai-sdk/amazon-bedrock/@smithy/eventstream-codec/@smithy/types": ["@smithy/types@4.14.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-59b5HtSVrVR/eYNei3BUj3DCPKD/G7EtDDe7OEJE7i7FtQFugYo6MxbotS8mVJkLNVf8gYaAlEBwwtJ9HzhWSg=="], + "@ai-sdk/anthropic/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], "@ai-sdk/azure/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], @@ -6012,6 +6206,14 @@ "@ai-sdk/deepinfra/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "@ai-sdk/google-vertex/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "@ai-sdk/google-vertex/@ai-sdk/provider-utils/eventsource-parser": ["eventsource-parser@3.0.8", "", {}, "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ=="], + + "@ai-sdk/google/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "@ai-sdk/google/@ai-sdk/provider-utils/eventsource-parser": ["eventsource-parser@3.0.8", "", {}, "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ=="], + "@ai-sdk/groq/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], "@ai-sdk/mistral/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], @@ -6658,6 +6860,10 @@ "unplugin/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], + "venice-ai-sdk-provider/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "venice-ai-sdk-provider/@ai-sdk/provider-utils/eventsource-parser": ["eventsource-parser@3.0.8", "", {}, "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ=="], + "vitest/@vitest/expect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], "vitest/@vitest/expect/chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], diff --git a/bunfig.toml b/bunfig.toml index 47c4ac5396..546f04843e 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -2,7 +2,7 @@ exact = true # Only install newly resolved package versions published at least 3 days ago. minimumReleaseAge = 259200 -minimumReleaseAgeExcludes = ["@opentui/core", "@opentui/core-darwin-arm64", "@opentui/core-darwin-x64", "@opentui/core-linux-arm64", "@opentui/core-linux-x64", "@opentui/core-win32-arm64", "@opentui/core-win32-x64", "@opentui/keymap", "@opentui/solid"] +minimumReleaseAgeExcludes = ["@opentui/core", "@opentui/core-darwin-arm64", "@opentui/core-darwin-x64", "@opentui/core-linux-arm64", "@opentui/core-linux-x64", "@opentui/core-win32-arm64", "@opentui/core-win32-x64", "@opentui/keymap", "@opentui/solid", "gitlab-ai-provider"] [test] root = "./do-not-run-tests-from-root" diff --git a/nix/hashes.json b/nix/hashes.json index 592b0a9dc1..8e95f0b91a 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-1RiaZQHzIhdtcOJUMsLagpP+nBBL/Qu6zQgrAXMHDCI=", - "aarch64-linux": "sha256-5QZhtkWuNpY/qUxlKRHcGbILOAVnuyzu+h8VDIuMQcU=", - "aarch64-darwin": "sha256-9w3QA22XNc1itGLyhikYU90xGH/iLUUM+SSGXla7lhw=", - "x86_64-darwin": "sha256-cSYiyhhSqIYiTeK1uWHDkHbYstQYD5jEB7JaYjWjgi4=" + "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=" } } diff --git a/package.json b/package.json index d8e49ad006..400b05bd5e 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "packageManager": "bun@1.3.14", "scripts": { "dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts", + "dev:stats": "bun sst shell --stage=production -- bun run --cwd packages/stats/app dev", "dev:storybook": "bun --cwd packages/storybook storybook", "lint": "oxlint", "typecheck": "bun turbo typecheck", @@ -14,7 +15,7 @@ "postinstall": "bun run --cwd packages/opencode fix-node-pty && bun run script/setup-git.ts", "prepare": "husky", "random": "echo 'Random script'", - "hello": "echo 'Hello World!'", + "sso": "aws sso login --sso-session=opencode --no-browser", "test": "echo 'do not run tests from root' && exit 1", "extension": "bun --cwd packages/kilo-vscode script/launch.ts", "dev-setup": "bun run --cwd packages/opencode --conditions=browser src/index.ts dev-setup" @@ -31,9 +32,8 @@ "@types/bun": "1.3.14", "@types/cross-spawn": "6.0.6", "@octokit/rest": "22.0.0", - "@hono/zod-validator": "0.4.2", - "@opentui/core": "0.2.15", - "@opentui/solid": "0.2.15", + "@opentui/core": "0.2.16", + "@opentui/solid": "0.2.16", "ulid": "3.0.1", "@kobalte/core": "0.13.11", "@types/luxon": "3.7.1", @@ -77,13 +77,14 @@ "@solidjs/start": "https://pkg.pr.new/@solidjs/start@dfb2020", "solid-js": "1.9.12", "vite-plugin-solid": "2.11.10", - "@lydell/node-pty": "1.2.0-beta.10", - "@opentui/keymap": "0.2.15", - "@effect/sql-sqlite-bun": "4.0.0-beta.66" + "@lydell/node-pty": "1.2.0-beta.12", + "@opentui/keymap": "0.2.16", + "@effect/sql-sqlite-bun": "4.0.0-beta.66", + "@hono/zod-validator": "0.4.2", + "sst": "4.13.1" } }, "devDependencies": { - "@actions/artifact": "5.0.1", "@tsconfig/bun": "catalog:", "@types/mime-types": "3.0.1", "@typescript/native-preview": "catalog:", @@ -93,20 +94,20 @@ "oxlint-tsgolint": "0.21.0", "prettier": "3.6.2", "semver": "^7.6.0", - "sst": "4.13.1", "turbo": "2.9.14", "@types/bun": "catalog:", "@changesets/changelog-github": "^0.5.1", - "@changesets/cli": "^2.27.10" + "@changesets/cli": "^2.27.10", + "@actions/artifact": "5.0.1", + "sst": "catalog:" }, "dependencies": { - "@aws-sdk/client-s3": "3.1025.0", "@kilocode/plugin": "workspace:*", "@opencode-ai/script": "workspace:*", "@kilocode/sdk": "workspace:*", - "heap-snapshot-toolkit": "1.1.3", "typescript": "catalog:", - "@morphllm/morphsdk": "0.2.166" + "@aws-sdk/client-s3": "3.933.0", + "heap-snapshot-toolkit": "1.1.3" }, "repository": { "type": "git", @@ -152,7 +153,9 @@ "@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch", "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", "solid-js@1.9.10": "patches/solid-js@1.9.10.patch", + "virtua@0.49.1": "patches/virtua@0.49.1.patch", "@ai-sdk/xai@3.0.82": "patches/@ai-sdk%2Fxai@3.0.82.patch", + "gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch", "mammoth@1.12.0": "patches/mammoth@1.12.0.patch" }, "version": "7.3.46", diff --git a/packages/cli/package.json b/packages/cli/package.json new file mode 100644 index 0000000000..d17d7ac214 --- /dev/null +++ b/packages/cli/package.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "name": "@kilocode/cli", + "version": "7.3.46", + "private": true, + "type": "module", + "license": "MIT", + "bin": { + "opencode": "./src/index.ts" + }, + "scripts": { + "dev": "bun run src/index.ts", + "typecheck": "tsgo --noEmit" + }, + "dependencies": { + "@effect/platform-node": "catalog:", + "@opencode-ai/core": "workspace:*", + "effect": "catalog:" + }, + "devDependencies": { + "@tsconfig/bun": "catalog:", + "@types/bun": "catalog:", + "@typescript/native-preview": "catalog:" + } +} diff --git a/packages/cli/src/debug/agents.ts b/packages/cli/src/debug/agents.ts new file mode 100644 index 0000000000..b1593f9d96 --- /dev/null +++ b/packages/cli/src/debug/agents.ts @@ -0,0 +1,19 @@ +import { EOL } from "os" +import { AgentV2 } from "@opencode-ai/core/agent" +import { PluginBoot } from "@opencode-ai/core/plugin/boot" +import * as Effect from "effect/Effect" +import * as Command from "effect/unstable/cli/Command" + +export const AgentsCommand = Command.make("agents", {}, () => + Effect.gen(function* () { + yield* PluginBoot.Service.use((service) => service.wait()) + const agents = yield* AgentV2.Service.use((service) => service.all()) + process.stdout.write( + JSON.stringify( + agents.sort((a, b) => a.id.localeCompare(b.id)), + null, + 2, + ) + EOL, + ) + }), +).pipe(Command.withDescription("List all agents")) diff --git a/packages/cli/src/debug/index.ts b/packages/cli/src/debug/index.ts new file mode 100644 index 0000000000..3e4e990225 --- /dev/null +++ b/packages/cli/src/debug/index.ts @@ -0,0 +1,7 @@ +import * as Command from "effect/unstable/cli/Command" +import { AgentsCommand } from "./agents" + +export const DebugCommand = Command.make("debug").pipe( + Command.withDescription("Debugging and troubleshooting tools"), + Command.withSubcommands([AgentsCommand]), +) diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts new file mode 100755 index 0000000000..1d16c33adb --- /dev/null +++ b/packages/cli/src/index.ts @@ -0,0 +1,49 @@ +#!/usr/bin/env bun + +import * as NodeRuntime from "@effect/platform-node/NodeRuntime" +import * as NodeServices from "@effect/platform-node/NodeServices" +import { AccountV2 } from "@opencode-ai/core/account" +import { AgentV2 } from "@opencode-ai/core/agent" +import { Catalog } from "@opencode-ai/core/catalog" +import { Config } from "@opencode-ai/core/config" +import { EventV2 } from "@opencode-ai/core/event" +import { Location } from "@opencode-ai/core/location" +import { Npm } from "@opencode-ai/core/npm" +import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginBoot } from "@opencode-ai/core/plugin/boot" +import { Policy } from "@opencode-ai/core/policy" +import { AbsolutePath } from "@opencode-ai/core/schema" +import * as Effect from "effect/Effect" +import * as Layer from "effect/Layer" +import * as Command from "effect/unstable/cli/Command" +import { DebugCommand } from "./debug" + +const cli = Command.make("opencode", {}, () => Effect.void).pipe( + Command.withDescription("OpenCode command line interface"), + Command.withSubcommands([DebugCommand]), +) + +const locationLayer = Location.defaultLayer({ + directory: AbsolutePath.make(process.cwd()), +}) + +const policyLayer = Policy.defaultLayer.pipe(Layer.provideMerge(locationLayer)) +const pluginLayer = PluginV2.defaultLayer +const eventLayer = EventV2.defaultLayer + +const layer = PluginBoot.layer.pipe( + Layer.provideMerge( + Layer.mergeAll( + NodeServices.layer, + Catalog.layer.pipe(Layer.provideMerge(Layer.mergeAll(eventLayer, pluginLayer, policyLayer))), + eventLayer, + pluginLayer, + AccountV2.defaultLayer, + AgentV2.defaultLayer, + Config.defaultLayer.pipe(Layer.provideMerge(policyLayer)), + Npm.defaultLayer, + ), + ), +) + +Command.run(cli, { version: "local" }).pipe(Effect.provide(layer), Effect.scoped, NodeRuntime.runMain) diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json new file mode 100644 index 0000000000..fe5c4d217b --- /dev/null +++ b/packages/cli/tsconfig.json @@ -0,0 +1,7 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "@tsconfig/bun/tsconfig.json", + "compilerOptions": { + "noUncheckedIndexedAccess": false + } +} diff --git a/packages/core/package.json b/packages/core/package.json index bfbec60f89..1e0319dfb5 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -46,15 +46,15 @@ "xdg-basedir": "5.1.0", "zod": "catalog:", "@ai-sdk/alibaba": "1.0.17", - "@ai-sdk/amazon-bedrock": "4.0.96", + "@ai-sdk/amazon-bedrock": "4.0.107", "@ai-sdk/anthropic": "3.0.71", "@ai-sdk/azure": "3.0.49", "@ai-sdk/cerebras": "2.0.54", "@ai-sdk/cohere": "3.0.27", "@ai-sdk/deepinfra": "2.0.41", "@ai-sdk/gateway": "3.0.104", - "@ai-sdk/google": "3.0.63", - "@ai-sdk/google-vertex": "4.0.112", + "@ai-sdk/google": "3.0.73", + "@ai-sdk/google-vertex": "4.0.128", "@ai-sdk/groq": "3.0.31", "@ai-sdk/mistral": "3.0.27", "@ai-sdk/openai": "3.0.53", @@ -68,10 +68,11 @@ "@aws-sdk/credential-providers": "3.993.0", "@openrouter/ai-sdk-provider": "2.9.0", "ai-gateway-provider": "3.1.2", - "gitlab-ai-provider": "6.7.0", + "gitlab-ai-provider": "6.8.0", "google-auth-library": "10.5.0", "immer": "11.1.4", - "venice-ai-sdk-provider": "2.0.1" + "venice-ai-sdk-provider": "2.0.2", + "jsonc-parser": "3.3.1" }, "overrides": { "drizzle-orm": "catalog:" diff --git a/packages/core/src/agent.ts b/packages/core/src/agent.ts index 7f4456c59f..ec7dfa2ade 100644 --- a/packages/core/src/agent.ts +++ b/packages/core/src/agent.ts @@ -1,147 +1,107 @@ export * as AgentV2 from "./agent" -import { Context, Effect, HashMap, Layer, Option, Order, pipe, Schema, Array } from "effect" -import { produce, type Draft } from "immer" +import { Array, Context, Effect, Layer, Schema, Scope } from "effect" +import { castDraft, enableMapSet, type Draft } from "immer" import { ModelV2 } from "./model" import { PermissionV2 } from "./permission" -import { PluginV2 } from "./plugin" import { ProviderV2 } from "./provider" +import { PositiveInt } from "./schema" +import { State } from "./state" export const ID = Schema.String.pipe(Schema.brand("AgentV2.ID")) export type ID = typeof ID.Type -export const Mode = Schema.Literals(["subagent", "primary", "all"]).annotate({ identifier: "AgentV2.Mode" }) -export type Mode = typeof Mode.Type +export const Color = Schema.Union([ + Schema.String.check(Schema.isPattern(/^#[0-9a-fA-F]{6}$/)), + Schema.Literals(["primary", "secondary", "accent", "success", "warning", "error", "info"]), +]) -export const Info = Schema.Struct({ - name: ID, - description: Schema.optional(Schema.String), - mode: Mode, - hidden: Schema.Boolean.pipe(Schema.optional), - color: Schema.String.pipe(Schema.optional), - permission: PermissionV2.Ruleset, +export class Info extends Schema.Class("AgentV2.Info")({ + id: ID, model: ModelV2.Ref.pipe(Schema.optional), + options: ProviderV2.Options, system: Schema.String.pipe(Schema.optional), - options: ProviderV2.Options.pipe(Schema.optional), - steps: Schema.Int.pipe(Schema.optional), -}).annotate({ identifier: "AgentV2.Info" }) -export type Info = typeof Info.Type + description: Schema.String.pipe(Schema.optional), + mode: Schema.Literals(["subagent", "primary", "all"]), + hidden: Schema.Boolean, + color: Color.pipe(Schema.optional), + steps: PositiveInt.pipe(Schema.optional), + permissions: PermissionV2.Ruleset, +}) { + static empty(id: ID) { + return new Info({ + id, + options: { + headers: {}, + body: {}, + aisdk: { + provider: {}, + request: {}, + }, + }, + mode: "all", + hidden: false, + permissions: [], + }) + } +} -export class NotFoundError extends Schema.TaggedErrorClass()("AgentV2.NotFound", { - agent: ID, -}) {} +type Data = { + agents: Map +} -export class InvalidDefaultError extends Schema.TaggedErrorClass()("AgentV2.InvalidDefault", { - agent: ID, - reason: Schema.Literals(["missing", "subagent", "hidden"]), -}) {} - -export class NoDefaultError extends Schema.TaggedErrorClass()("AgentV2.NoDefault", {}) {} +export type Editor = { + list: () => readonly Info[] + get: (id: ID) => Info | undefined + update: (id: ID, fn: (agent: Draft) => void) => void + remove: (id: ID) => void +} export interface Interface { - readonly get: (agent: ID) => Effect.Effect - readonly list: () => Effect.Effect - readonly update: (agent: ID, fn: (agent: Draft) => void) => Effect.Effect - readonly remove: (agent: ID) => Effect.Effect - readonly defaultInfo: () => Effect.Effect - readonly defaultAgent: () => Effect.Effect - readonly setDefault: (agent: ID) => Effect.Effect + readonly transform: State.Interface["transform"] + readonly update: (update: State.Transform) => Effect.Effect + readonly get: (id: ID) => Effect.Effect + readonly all: () => Effect.Effect } export class Service extends Context.Service()("@opencode/v2/Agent") {} +enableMapSet() + export const layer = Layer.effect( Service, Effect.gen(function* () { - const plugin = yield* PluginV2.Service - let agents = HashMap.empty() - let defaultAgent: ID | undefined - - const result: Interface = { - get: Effect.fn("AgentV2.get")(function* (agent) { - const match = HashMap.get(agents, agent) - if (!match.valueOrUndefined) return yield* new NotFoundError({ agent }) - return match.value + const state = State.create({ + initial: () => ({ agents: new Map() }), + editor: (draft) => ({ + list: () => Array.fromIterable(draft.agents.values()) as Info[], + get: (id) => draft.agents.get(id), + update: (id, fn) => { + const current = draft.agents.get(id) ?? castDraft(Info.empty(id)) + if (!draft.agents.has(id)) draft.agents.set(id, current) + fn(current) + current.id = id + }, + remove: (id) => { + draft.agents.delete(id) + }, }), + }) - list: Effect.fn("AgentV2.list")(function* () { - return pipe( - HashMap.toValues(agents), - Array.sortWith((agent) => agent.name, Order.String), - ) + return Service.of({ + transform: state.transform, + update: Effect.fn("AgentV2.update")(function* (update) { + const transform = yield* state.transform() + yield* transform(update) }), - - update: Effect.fnUntraced(function* (agent, fn) { - const next = produce( - HashMap.get(agents, agent).pipe( - Option.getOrElse( - () => - ({ - name: agent, - mode: "all", - permission: [], - options: { - headers: {}, - body: {}, - aisdk: { - provider: {}, - request: {}, - }, - }, - }) satisfies Info, - ), - ), - fn, - ) - const updated = yield* plugin.trigger("agent.update", {}, { agent: next, cancel: false }) - if (updated.cancel) return - agents = HashMap.set(agents, agent, { ...updated.agent, name: agent }) + get: Effect.fn("AgentV2.get")(function* (id) { + return state.get().agents.get(id) }), - - remove: Effect.fn("AgentV2.remove")(function* (agent) { - const existing = Option.getOrUndefined(HashMap.get(agents, agent)) - if (!existing) return - if ((yield* plugin.trigger("agent.remove", { agent: existing }, { cancel: false })).cancel) return - agents = HashMap.remove(agents, agent) - if (defaultAgent === agent) defaultAgent = undefined + all: Effect.fn("AgentV2.all")(function* () { + return Array.fromIterable(state.get().agents.values()) }), - - defaultInfo: Effect.fn("AgentV2.defaultInfo")(function* () { - const updated = yield* plugin.trigger("agent.default", {}, { agent: defaultAgent }) - const selected = updated.agent - if (selected) { - const agent = yield* result - .get(selected) - .pipe( - Effect.catchTag("AgentV2.NotFound", () => - Effect.fail(new InvalidDefaultError({ agent: selected, reason: "missing" })), - ), - ) - if (agent.mode === "subagent") return yield* new InvalidDefaultError({ agent: selected, reason: "subagent" }) - if (agent.hidden === true) return yield* new InvalidDefaultError({ agent: selected, reason: "hidden" }) - return agent - } - - const visible = pipe( - yield* result.list(), - Array.findFirst((agent) => agent.mode !== "subagent" && agent.hidden !== true), - ) - if (Option.isSome(visible)) return visible.value - return yield* new NoDefaultError() - }), - - defaultAgent: Effect.fn("AgentV2.defaultAgent")(function* () { - return (yield* result.defaultInfo()).name - }), - - setDefault: Effect.fn("AgentV2.setDefault")(function* (agent) { - yield* result.get(agent) - defaultAgent = agent - }), - } - - return Service.of(result) + }) }), ) -export const defaultLayer = layer.pipe(Layer.provide(PluginV2.defaultLayer)) +export const defaultLayer = layer diff --git a/packages/core/src/catalog.ts b/packages/core/src/catalog.ts index 2bbd7bc49b..a12de5d476 100644 --- a/packages/core/src/catalog.ts +++ b/packages/core/src/catalog.ts @@ -1,18 +1,22 @@ export * as Catalog from "./catalog" -import { Context, Effect, HashMap, Layer, Option, Order, pipe, Schema, Array, Scope, Stream } from "effect" -import { produce, type Draft } from "immer" +import { Context, Effect, Layer, Option, Order, pipe, Schema, Array, Scope, Stream } from "effect" +import { castDraft, enableMapSet, type Draft } from "immer" import { ModelV2 } from "./model" import { PluginV2 } from "./plugin" import { ProviderV2 } from "./provider" import { Location } from "./location" import { EventV2 } from "./event" +import { Policy } from "./policy" +import { State } from "./state" export type ProviderRecord = { provider: ProviderV2.Info models: Map } +export type DefaultModel = { providerID: ProviderV2.ID; modelID: ModelV2.ID } + export class ProviderNotFoundError extends Schema.TaggedErrorClass()( "CatalogV2.ProviderNotFound", { @@ -25,6 +29,8 @@ export class ModelNotFoundError extends Schema.TaggedErrorClass) => void) => void - updateModel: (providerID: ProviderV2.ID, modelID: ModelV2.ID, fn: (model: Draft) => void) => void +type Data = { + providers: Map + defaultModel?: DefaultModel +} + +export type Editor = { provider: { + list: () => readonly ProviderRecord[] + get: (providerID: ProviderV2.ID) => ProviderRecord | undefined update: (providerID: ProviderV2.ID, fn: (provider: Draft) => void) => void remove: (providerID: ProviderV2.ID) => void } model: { + get: (providerID: ProviderV2.ID, modelID: ModelV2.ID) => ModelV2.Info | undefined update: (providerID: ProviderV2.ID, modelID: ModelV2.ID, fn: (model: Draft) => void) => void remove: (providerID: ProviderV2.ID, modelID: ModelV2.ID) => void + default: { + get: () => DefaultModel | undefined + set: (providerID: ProviderV2.ID, modelID: ModelV2.ID) => void + } } } -export type Loader = (update: (ctx: Context) => void) => Effect.Effect - export interface Interface { - readonly loader: () => Effect.Effect + readonly transform: State.Interface["transform"] readonly provider: { readonly get: (providerID: ProviderV2.ID) => Effect.Effect readonly all: () => Effect.Effect @@ -65,29 +78,25 @@ export interface Interface { readonly all: () => Effect.Effect readonly available: () => Effect.Effect readonly default: () => Effect.Effect> - readonly setDefault: ( - providerID: ProviderV2.ID, - modelID: ModelV2.ID, - ) => Effect.Effect readonly small: (providerID: ProviderV2.ID) => Effect.Effect> } } export class Service extends Context.Service()("@opencode/v2/Catalog") {} +enableMapSet() + export const layer = Layer.effect( Service, Effect.gen(function* () { yield* Location.Service - let records = HashMap.empty() - let loaders: { update: (ctx: Context) => void }[] = [] - let defaultModel: { providerID: ProviderV2.ID; modelID: ModelV2.ID } | undefined const plugin = yield* PluginV2.Service const events = yield* EventV2.Service + const policy = yield* Policy.Service const scope = yield* Scope.Scope const resolve = (model: ModelV2.Info) => { - const provider = Option.getOrThrow(HashMap.get(records, model.providerID)).provider + const provider = state.get().providers.get(model.providerID)!.provider const endpoint = model.endpoint.type === "unknown" ? provider.endpoint @@ -120,9 +129,9 @@ export const layer = Layer.effect( } function* getRecord(providerID: ProviderV2.ID) { - const match = HashMap.get(records, providerID) - if (!match.valueOrUndefined) return yield* new ProviderNotFoundError({ providerID }) - return match.value + const match = state.get().providers.get(providerID) + if (!match) return yield* new ProviderNotFoundError({ providerID }) + return match } const normalizeEndpoint = (item: Draft | Draft) => { @@ -131,114 +140,73 @@ export const layer = Layer.effect( delete item.options.aisdk.provider.baseURL } - const clone = (input: HashMap.HashMap) => - HashMap.fromIterable( - HashMap.toEntries(input).map(([key, value]) => [key, { ...value, models: new Map(value.models) }] as const), - ) - - const context = (draft: { - records: HashMap.HashMap - data: ProviderRecord[] - }): Context => { - const result: Context = { - data: draft.data, - updateProvider: (providerID, fn) => result.provider.update(providerID, fn), - updateModel: (providerID, modelID, fn) => result.model.update(providerID, modelID, fn), - provider: { - update: (providerID, fn) => { - const current = Option.getOrUndefined(HashMap.get(draft.records, providerID)) - const provider = produce(current?.provider ?? ProviderV2.Info.empty(providerID), (draft) => { - fn(draft) - normalizeEndpoint(draft) - }) - const next = { - provider, - models: current?.models ?? new Map(), - } - draft.records = HashMap.set(draft.records, providerID, next) - const index = draft.data.findIndex((item) => item.provider.id === providerID) - if (index === -1) draft.data.push(next) - else draft.data[index] = next + const state = State.create({ + initial: () => ({ providers: new Map() }), + editor: (draft) => { + const result: Editor = { + provider: { + list: () => Array.fromIterable(draft.providers.values()) as ProviderRecord[], + get: (providerID) => draft.providers.get(providerID), + update: (providerID, fn) => { + let current = draft.providers.get(providerID) + if (!current) { + current = castDraft({ + provider: ProviderV2.Info.empty(providerID), + models: new Map(), + }) + draft.providers.set(providerID, current) + } + fn(current.provider) + normalizeEndpoint(current.provider) + }, + remove: (providerID) => { + draft.providers.delete(providerID) + }, }, - remove: (providerID) => { - draft.records = HashMap.remove(draft.records, providerID) - const index = draft.data.findIndex((item) => item.provider.id === providerID) - if (index !== -1) draft.data.splice(index, 1) + model: { + get: (providerID, modelID) => draft.providers.get(providerID)?.models.get(modelID), + update: (providerID, modelID, fn) => { + result.provider.update(providerID, () => {}) + const record = draft.providers.get(providerID)! + const model = record.models.get(modelID) ?? castDraft(ModelV2.Info.empty(providerID, modelID)) + if (!record.models.has(modelID)) record.models.set(modelID, model) + fn(model) + model.id = modelID + model.providerID = providerID + normalizeEndpoint(model) + }, + remove: (providerID, modelID) => { + draft.providers.get(providerID)?.models.delete(modelID) + }, + default: { + get: () => draft.defaultModel, + set: (providerID, modelID) => { + draft.defaultModel = { providerID, modelID } + }, + }, }, - }, - model: { - update: (providerID, modelID, fn) => { - const current = Option.getOrThrow(HashMap.get(draft.records, providerID)) - const model = produce(current.models.get(modelID) ?? ModelV2.Info.empty(providerID, modelID), (draft) => { - fn(draft) - normalizeEndpoint(draft) - }) - const next = { - provider: current.provider, - models: new Map(current.models).set(modelID, new ModelV2.Info({ ...model, id: modelID, providerID })), - } - draft.records = HashMap.set(draft.records, providerID, next) - const index = draft.data.findIndex((item) => item.provider.id === providerID) - if (index === -1) draft.data.push(next) - else draft.data[index] = next - }, - remove: (providerID, modelID) => { - const current = Option.getOrUndefined(HashMap.get(draft.records, providerID)) - if (!current) return - const next = { - provider: current.provider, - models: new Map(current.models), - } - next.models.delete(modelID) - draft.records = HashMap.set(draft.records, providerID, next) - const index = draft.data.findIndex((item) => item.provider.id === providerID) - if (index !== -1) draft.data[index] = next - }, - }, - } - return result - } - - const transform = Effect.fn("CatalogV2.transform")(function* () { - const draft = { records: clone(records), data: HashMap.toValues(records) } - yield* plugin.trigger("catalog.transform", context(draft), {}) - records = draft.records + } + return result + }, + finalize: Effect.fn("CatalogV2.finalize")(function* (catalog, reason) { + if (reason !== "plugin.added") yield* plugin.trigger("catalog.transform", catalog, {}).pipe(Effect.asVoid) + for (const record of [...catalog.provider.list()]) { + if ((yield* policy.evaluate("provider.use", record.provider.id, "allow")) === "deny") { + catalog.provider.remove(record.provider.id) + } + } + }), }) - const rebuild = Effect.fn("CatalogV2.rebuild")(function* () { - const draft = { records: HashMap.empty(), data: [] as ProviderRecord[] } - for (const loader of loaders) loader.update(context(draft)) - yield* plugin.trigger("catalog.transform", context(draft), {}) - records = draft.records - }) - - yield* plugin.added().pipe( - Stream.runForEach((id) => - Effect.gen(function* () { - const draft = { records: clone(records), data: HashMap.toValues(records) } - yield* plugin.triggerFor(id, "catalog.transform", context(draft), {}) - records = draft.records - }), + yield* events.subscribe(PluginV2.Event.Added).pipe( + Stream.runForEach((event) => + state.update((catalog) => plugin.triggerFor(event.data.id, "catalog.transform", catalog, {}), "plugin.added"), ), Effect.forkIn(scope, { startImmediately: true }), ) const result: Interface = { - loader: Effect.fn("CatalogV2.loader")(function* () { - const loader = { update: (_ctx: Context) => {} } - loaders = [...loaders, loader] - const scope = yield* Scope.Scope - yield* Scope.addFinalizer( - scope, - Effect.sync(() => { - loaders = loaders.filter((item) => item !== loader) - }).pipe(Effect.andThen(rebuild())), - ) - return Effect.fnUntraced(function* (update) { - loader.update = update - yield* rebuild() - }) - }), + transform: state.transform, provider: { get: Effect.fn("CatalogV2.provider.get")(function* (providerID) { @@ -247,11 +215,11 @@ export const layer = Layer.effect( }), all: Effect.fn("CatalogV2.provider.all")(function* () { - return globalThis.Array.from(HashMap.values(records)).map((record) => record.provider) + return Array.fromIterable(state.get().providers.values()).map((record) => record.provider) }), available: Effect.fn("CatalogV2.provider.available")(function* () { - return globalThis.Array.from(HashMap.values(records)) + return Array.fromIterable(state.get().providers.values()) .map((record) => record.provider) .filter((provider) => provider.enabled) }), @@ -267,9 +235,8 @@ export const layer = Layer.effect( all: Effect.fn("CatalogV2.model.all")(function* () { return pipe( - records, - HashMap.toValues, - Array.flatMap((record) => globalThis.Array.from(record.models.values())), + Array.fromIterable(state.get().providers.values()), + Array.flatMap((record) => Array.fromIterable(record.models.values())), Array.map(resolve), Array.sortWith((item) => item.time.released.epochMilliseconds, Order.flip(Order.Number)), ) @@ -277,12 +244,13 @@ export const layer = Layer.effect( available: Effect.fn("CatalogV2.model.available")(function* () { return (yield* result.model.all()).filter((model) => { - const record = Option.getOrUndefined(HashMap.get(records, model.providerID)) + const record = state.get().providers.get(model.providerID) return record?.provider.enabled !== false && model.enabled }) }), default: Effect.fn("CatalogV2.model.default")(function* () { + const defaultModel = state.get().defaultModel if (defaultModel) { const model = yield* result.model.get(defaultModel.providerID, defaultModel.modelID).pipe(Effect.option) if (Option.isSome(model) && model.value.enabled) return model @@ -295,13 +263,8 @@ export const layer = Layer.effect( ) }), - setDefault: Effect.fn("CatalogV2.model.setDefault")(function* (providerID, modelID) { - yield* result.model.get(providerID, modelID) - defaultModel = { providerID, modelID } - }), - small: Effect.fn("CatalogV2.model.small")(function* (providerID) { - const record = Option.getOrUndefined(HashMap.get(records, providerID)) + const record = state.get().providers.get(providerID) if (!record) return Option.none() if (providerID === ProviderV2.ID.opencode) { @@ -310,7 +273,7 @@ export const layer = Layer.effect( } const candidates = pipe( - globalThis.Array.from(record.models.values()), + Array.fromIterable(record.models.values()), Array.filter( (model) => model.providerID === providerID && diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts new file mode 100644 index 0000000000..c9e1396739 --- /dev/null +++ b/packages/core/src/config.ts @@ -0,0 +1,203 @@ +export * as Config from "./config" + +import path from "path" +import { type ParseError, parse } from "jsonc-parser" +import { Context, Effect, Layer, Option, Schema } from "effect" +import { AppFileSystem } from "./filesystem" +import { Global } from "./global" +import { Location } from "./location" +import { PermissionV2 } from "./permission" +import { Policy } from "./policy" +import { AbsolutePath } from "./schema" +import { ConfigAgent } from "./config/agent" +import { ConfigAttachments } from "./config/attachments" +import { ConfigCompaction } from "./config/compaction" +import { ConfigExperimental } from "./config/experimental" +import { ConfigFormatter } from "./config/formatter" +import { ConfigLSP } from "./config/lsp" +import { ConfigMCP } from "./config/mcp" +import { ConfigPlugin } from "./config/plugin" +import { ConfigProvider } from "./config/provider" +import { ConfigReference } from "./config/reference" +import { ConfigToolOutput } from "./config/tool-output" +import { ConfigWatcher } from "./config/watcher" + +export class Info extends Schema.Class("Config.Info")({ + $schema: Schema.optional(Schema.String).annotate({ + description: "JSON schema reference for configuration validation", + }), + shell: Schema.String.pipe(Schema.optional).annotate({ + description: "Default shell to use for terminal and shell tool execution", + }), + model: Schema.String.pipe(Schema.optional).annotate({ + description: "Default model to use when no session or agent model is selected", + }), + autoupdate: Schema.Union([Schema.Boolean, Schema.Literal("notify")]) + .pipe(Schema.optional) + .annotate({ + description: "Automatically update or notify when a new version is available", + }), + share: Schema.Literals(["manual", "auto", "disabled"]).pipe(Schema.optional).annotate({ + description: "Control whether sessions may be shared manually, automatically, or not at all", + }), + enterprise: Schema.Struct({ + url: Schema.String.pipe(Schema.optional), + }) + .pipe(Schema.optional) + .annotate({ + description: "Enterprise sharing service configuration", + }), + username: Schema.String.pipe(Schema.optional).annotate({ + description: "Username displayed in conversations and used for telemetry identity", + }), + permissions: PermissionV2.Ruleset.pipe(Schema.optional).annotate({ + description: "Ordered tool permission rules applied to agent tool use", + }), + agents: Schema.Record(Schema.String, ConfigAgent.Info).pipe(Schema.optional).annotate({ + description: "Named built-in agent overrides and custom agent definitions", + }), + snapshots: Schema.Boolean.pipe(Schema.optional).annotate({ + description: "Enable snapshots used for undo and revert behavior", + }), + watcher: ConfigWatcher.Info.pipe(Schema.optional).annotate({ + description: "Filesystem watcher configuration", + }), + formatter: ConfigFormatter.Info.pipe(Schema.optional).annotate({ + description: "Enable built-in formatters or configure formatter overrides", + }), + lsp: ConfigLSP.Info.pipe(Schema.optional).annotate({ + description: "Enable built-in language servers or configure server overrides", + }), + attachments: ConfigAttachments.Info.pipe(Schema.optional).annotate({ + description: "Attachment processing configuration", + }), + tool_output: ConfigToolOutput.Info.pipe(Schema.optional).annotate({ + description: "Tool output truncation thresholds", + }), + mcp: ConfigMCP.Info.pipe(Schema.optional).annotate({ + description: "MCP server configuration", + }), + compaction: ConfigCompaction.Info.pipe(Schema.optional).annotate({ + description: "Conversation compaction behavior", + }), + skills: Schema.String.pipe(Schema.Array, Schema.optional).annotate({ + description: "Additional paths or URLs to discover skills from", + }), + instructions: Schema.String.pipe(Schema.Array, Schema.optional).annotate({ + description: "Additional paths or URLs supplying ambient instructions", + }), + references: ConfigReference.Info.pipe(Schema.optional).annotate({ + description: "Named local directories or Git repositories available as external context", + }), + plugins: ConfigPlugin.Plugins.pipe(Schema.optional).annotate({ + description: "Ordered external plugin packages to load", + }), + experimental: ConfigExperimental.Experimental.pipe(Schema.optional), + providers: Schema.Record(Schema.String, ConfigProvider.Info).pipe(Schema.optional), +}) {} + +export const FileSource = Schema.Struct({ + type: Schema.Literal("file"), + path: Schema.String, +}).annotate({ identifier: "Config.FileSource" }) +export type FileSource = typeof FileSource.Type + +export const MemorySource = Schema.Struct({ + type: Schema.Literal("memory"), +}).annotate({ identifier: "Config.MemorySource" }) +export type MemorySource = typeof MemorySource.Type + +export const Source = Schema.Union([FileSource, MemorySource]).pipe(Schema.toTaggedUnion("type")) +export type Source = typeof Source.Type + +export class Loaded extends Schema.Class("Config.Loaded")({ + source: Source, + info: Info, +}) {} + +export interface Interface { + /** Returns supplemental config directories from lowest to highest priority. */ + readonly directories: () => Effect.Effect + /** Loads location config files from lowest to highest priority. */ + readonly get: () => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/v2/Config") {} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const global = yield* Global.Service + const location = yield* Location.Service + const policy = yield* Policy.Service + const names = ["config.json", "opencode.json", "opencode.jsonc"] + + const loadFile = Effect.fnUntraced(function* (filepath: string) { + const text = yield* fs.readFileStringSafe(filepath) + if (!text) return + + const errors: ParseError[] = [] + const input: unknown = parse(text, errors, { allowTrailingComma: true }) + if (errors.length) return + + // Accept legacy fields while v2 is migrated incrementally; recognized + // fields still have to satisfy the v2 schema. + const info = Option.getOrUndefined( + Schema.decodeUnknownOption(Info)(input, { errors: "all", onExcessProperty: "ignore" }), + ) + if (!info) return + return new Loaded({ source: { type: "file", path: filepath }, info }) + }) + + const loadDirectory = Effect.fnUntraced(function* (directory: AbsolutePath) { + return yield* Effect.forEach(names, (file) => loadFile(path.join(directory, file))).pipe( + Effect.map((configs) => configs.filter((config): config is Loaded => config !== undefined)), + ) + }) + + const globalDirectory = AbsolutePath.make(global.config) + const locationIsGlobal = path.resolve(location.directory) === path.resolve(global.config) + // Read configuration once when this location opens. Later calls reuse these + // values until the location is reopened. + const directories = locationIsGlobal + ? [globalDirectory] + : [ + globalDirectory, + ...(yield* fs + .up({ targets: [".opencode"], start: location.directory, stop: location.project.directory }) + .pipe(Effect.orDie)) + .toReversed() + .map((directory) => AbsolutePath.make(directory)), + ] + // A config closer to the opened directory should win over one higher up. + // Search starts nearby, so reverse the results before applying them. + const directPaths = locationIsGlobal + ? [] + : (yield* fs + .up({ targets: names.toReversed(), start: location.directory, stop: location.project.directory }) + .pipe(Effect.orDie)).toReversed() + const direct = yield* Effect.forEach(directPaths, loadFile).pipe( + Effect.orDie, + Effect.map((configs) => configs.filter((config): config is Loaded => config !== undefined)), + ) + const supplementary = yield* Effect.forEach(directories, loadDirectory).pipe(Effect.orDie) + // Apply general settings first and more specific settings last: + // global config, project files, then `.opencode` files. + const configs = [...(supplementary[0] ?? []), ...direct, ...supplementary.slice(1).flat()] + // Rules use the opposite order so a user-global rule can override a + // repository rule. Statement order inside each file stays unchanged. + yield* policy.load(configs.toReversed().flatMap((config) => config.info.experimental?.policies ?? [])) + + return Service.of({ + directories: Effect.fn("Config.directories")(function* () { + return directories + }), + get: Effect.fn("Config.get")(function* () { + return configs + }), + }) + }), +) + +export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Global.defaultLayer)) diff --git a/packages/core/src/config/agent.ts b/packages/core/src/config/agent.ts new file mode 100644 index 0000000000..40d2bc94b5 --- /dev/null +++ b/packages/core/src/config/agent.ts @@ -0,0 +1,25 @@ +export * as ConfigAgent from "./agent" + +import { Schema } from "effect" +import { PermissionV2 } from "../permission" +import { ConfigProvider } from "./provider" +import { PositiveInt } from "../schema" + +export const Color = Schema.Union([ + Schema.String.check(Schema.isPattern(/^#[0-9a-fA-F]{6}$/)), + Schema.Literals(["primary", "secondary", "accent", "success", "warning", "error", "info"]), +]) + +export class Info extends Schema.Class("ConfigV2.Agent")({ + model: Schema.String.pipe(Schema.optional), + variant: Schema.String.pipe(Schema.optional), + options: ConfigProvider.Options.pipe(Schema.optional), + system: Schema.String.pipe(Schema.optional), + description: Schema.String.pipe(Schema.optional), + mode: Schema.Literals(["subagent", "primary", "all"]).pipe(Schema.optional), + hidden: Schema.Boolean.pipe(Schema.optional), + color: Color.pipe(Schema.optional), + steps: PositiveInt.pipe(Schema.optional), + disabled: Schema.Boolean.pipe(Schema.optional), + permissions: PermissionV2.Ruleset.pipe(Schema.optional), +}) {} diff --git a/packages/core/src/config/attachments.ts b/packages/core/src/config/attachments.ts new file mode 100644 index 0000000000..f14775ff3f --- /dev/null +++ b/packages/core/src/config/attachments.ts @@ -0,0 +1,15 @@ +export * as ConfigAttachments from "./attachments" + +import { Schema } from "effect" +import { PositiveInt } from "../schema" + +export class Image extends Schema.Class("ConfigV2.Attachments.Image")({ + auto_resize: Schema.Boolean.pipe(Schema.optional), + max_width: PositiveInt.pipe(Schema.optional), + max_height: PositiveInt.pipe(Schema.optional), + max_base64_bytes: PositiveInt.pipe(Schema.optional), +}) {} + +export class Info extends Schema.Class("ConfigV2.Attachments")({ + image: Image.pipe(Schema.optional), +}) {} diff --git a/packages/core/src/config/compaction.ts b/packages/core/src/config/compaction.ts new file mode 100644 index 0000000000..eef67ee26a --- /dev/null +++ b/packages/core/src/config/compaction.ts @@ -0,0 +1,16 @@ +export * as ConfigCompaction from "./compaction" + +import { Schema } from "effect" +import { NonNegativeInt } from "../schema" + +export class Keep extends Schema.Class("ConfigV2.Compaction.Keep")({ + turns: NonNegativeInt.pipe(Schema.optional), + tokens: NonNegativeInt.pipe(Schema.optional), +}) {} + +export class Info extends Schema.Class("ConfigV2.Compaction")({ + auto: Schema.Boolean.pipe(Schema.optional), + prune: Schema.Boolean.pipe(Schema.optional), + keep: Keep.pipe(Schema.optional), + buffer: NonNegativeInt.pipe(Schema.optional), +}) {} diff --git a/packages/core/src/config/experimental.ts b/packages/core/src/config/experimental.ts new file mode 100644 index 0000000000..12a02635db --- /dev/null +++ b/packages/core/src/config/experimental.ts @@ -0,0 +1,18 @@ +export * as ConfigExperimental from "./experimental" + +import { Schema } from "effect" +import { Catalog } from "../catalog" +import { Policy as PolicyV2 } from "../policy" + +// Each core domain exports the policy actions it supports. Adding an action to +// this union makes it valid in authored config while keeping Policy generic. +export const PolicyAction = Schema.Union([Catalog.PolicyActions]) + +export class Policy extends Schema.Class("ConfigV2.Experimental.Policy")({ + ...PolicyV2.Info.fields, + action: PolicyAction, +}) {} + +export class Experimental extends Schema.Class("ConfigV2.Experimental")({ + policies: Policy.pipe(Schema.Array, Schema.optional), +}) {} diff --git a/packages/core/src/config/formatter.ts b/packages/core/src/config/formatter.ts new file mode 100644 index 0000000000..e1f90302d1 --- /dev/null +++ b/packages/core/src/config/formatter.ts @@ -0,0 +1,12 @@ +export * as ConfigFormatter from "./formatter" + +import { Schema } from "effect" + +export class Entry extends Schema.Class("ConfigV2.Formatter.Entry")({ + disabled: Schema.Boolean.pipe(Schema.optional), + command: Schema.String.pipe(Schema.Array, Schema.optional), + environment: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional), + extensions: Schema.String.pipe(Schema.Array, Schema.optional), +}) {} + +export const Info = Schema.Union([Schema.Boolean, Schema.Record(Schema.String, Entry)]) diff --git a/packages/core/src/config/lsp.ts b/packages/core/src/config/lsp.ts new file mode 100644 index 0000000000..651597befd --- /dev/null +++ b/packages/core/src/config/lsp.ts @@ -0,0 +1,18 @@ +export * as ConfigLSP from "./lsp" + +import { Schema } from "effect" + +export const Disabled = Schema.Struct({ + disabled: Schema.Literal(true), +}) + +export class Server extends Schema.Class("ConfigV2.LSP.Server")({ + command: Schema.String.pipe(Schema.Array), + extensions: Schema.String.pipe(Schema.Array, Schema.optional), + disabled: Schema.Boolean.pipe(Schema.optional), + env: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional), + initialization: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), +}) {} + +export const Entry = Schema.Union([Disabled, Server]) +export const Info = Schema.Union([Schema.Boolean, Schema.Record(Schema.String, Entry)]) diff --git a/packages/core/src/config/mcp.ts b/packages/core/src/config/mcp.ts new file mode 100644 index 0000000000..fce853815b --- /dev/null +++ b/packages/core/src/config/mcp.ts @@ -0,0 +1,36 @@ +export * as ConfigMCP from "./mcp" + +import { Schema } from "effect" +import { PositiveInt } from "../schema" + +export class Local extends Schema.Class("ConfigV2.MCP.Local")({ + type: Schema.Literal("local"), + command: Schema.String.pipe(Schema.Array), + environment: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional), + disabled: Schema.Boolean.pipe(Schema.optional), + timeout: PositiveInt.pipe(Schema.optional), +}) {} + +export class OAuth extends Schema.Class("ConfigV2.MCP.OAuth")({ + client_id: Schema.String.pipe(Schema.optional), + client_secret: Schema.String.pipe(Schema.optional), + scope: Schema.String.pipe(Schema.optional), + callback_port: Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 })).pipe(Schema.optional), + redirect_uri: Schema.String.pipe(Schema.optional), +}) {} + +export class Remote extends Schema.Class("ConfigV2.MCP.Remote")({ + type: Schema.Literal("remote"), + url: Schema.String, + headers: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional), + oauth: Schema.Union([OAuth, Schema.Literal(false)]).pipe(Schema.optional), + disabled: Schema.Boolean.pipe(Schema.optional), + timeout: PositiveInt.pipe(Schema.optional), +}) {} + +export const Server = Schema.Union([Local, Remote]).pipe(Schema.toTaggedUnion("type")) + +export class Info extends Schema.Class("ConfigV2.MCP")({ + timeout: PositiveInt.pipe(Schema.optional), + servers: Schema.Record(Schema.String, Server).pipe(Schema.optional), +}) {} diff --git a/packages/core/src/config/plugin.ts b/packages/core/src/config/plugin.ts new file mode 100644 index 0000000000..e5fd6661ff --- /dev/null +++ b/packages/core/src/config/plugin.ts @@ -0,0 +1,13 @@ +export * as ConfigPlugin from "./plugin" + +import { Schema } from "effect" + +export class Entry extends Schema.Class("ConfigV2.Plugin.Entry")({ + package: Schema.String, + options: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), +}) {} + +export const Plugin = Schema.Union([Schema.String, Entry]) +export type Plugin = typeof Plugin.Type + +export const Plugins = Plugin.pipe(Schema.Array) diff --git a/packages/core/src/config/plugin/agent.ts b/packages/core/src/config/plugin/agent.ts new file mode 100644 index 0000000000..c05b0a578f --- /dev/null +++ b/packages/core/src/config/plugin/agent.ts @@ -0,0 +1,65 @@ +export * as ConfigAgentPlugin from "./agent" + +import { Effect } from "effect" +import { AgentV2 } from "../../agent" +import { Config } from "../../config" +import { ModelV2 } from "../../model" +import { PermissionV2 } from "../../permission" +import { PluginV2 } from "../../plugin" + +export const Plugin = PluginV2.define({ + id: PluginV2.ID.make("config-agent"), + effect: Effect.gen(function* () { + const agent = yield* AgentV2.Service + const config = yield* Config.Service + const files = yield* config.get() + + yield* agent.update((editor) => { + const permissions = new Map() + + for (const file of files) { + for (const [id, item] of Object.entries(file.info.agents ?? {})) { + const agentID = AgentV2.ID.make(id) + if (item.disabled) { + editor.remove(agentID) + permissions.delete(agentID) + continue + } + + editor.update(agentID, (agent) => { + if (item.model !== undefined) { + const model = ModelV2.parse(item.model) + agent.model = { id: model.modelID, providerID: model.providerID, variant: agent.model?.variant } + } + if (item.variant !== undefined && agent.model !== undefined) { + agent.model.variant = ModelV2.VariantID.make(item.variant) + } + if (item.options !== undefined) { + Object.assign(agent.options.headers, item.options.headers ?? {}) + Object.assign(agent.options.body, item.options.body ?? {}) + Object.assign(agent.options.aisdk.provider, item.options.aisdk?.provider ?? {}) + Object.assign(agent.options.aisdk.request, item.options.aisdk?.request ?? {}) + } + if (item.system !== undefined) agent.system = item.system + if (item.description !== undefined) agent.description = item.description + if (item.mode !== undefined) agent.mode = item.mode + if (item.hidden !== undefined) agent.hidden = item.hidden + if (item.color !== undefined) agent.color = item.color + if (item.steps !== undefined) agent.steps = item.steps + }) + + if (item.permissions !== undefined) { + permissions.set(agentID, [...(permissions.get(agentID) ?? []), ...item.permissions]) + } + } + } + + const global = files.flatMap((file) => file.info.permissions ?? []) + for (const current of editor.list()) { + editor.update(current.id, (agent) => { + agent.permissions.push(...global, ...(permissions.get(current.id) ?? [])) + }) + } + }) + }), +}) diff --git a/packages/core/src/config/plugin/provider.ts b/packages/core/src/config/plugin/provider.ts new file mode 100644 index 0000000000..fca2e53302 --- /dev/null +++ b/packages/core/src/config/plugin/provider.ts @@ -0,0 +1,95 @@ +export * as ConfigProviderPlugin from "./provider" + +import { Effect } from "effect" +import { Catalog } from "../../catalog" +import { Config } from "../../config" +import { ModelV2 } from "../../model" +import { PluginV2 } from "../../plugin" +import { ProviderV2 } from "../../provider" + +export const Plugin = PluginV2.define({ + id: PluginV2.ID.make("config-provider"), + effect: Effect.gen(function* () { + const catalog = yield* Catalog.Service + const config = yield* Config.Service + const transform = yield* catalog.transform() + const files = yield* config.get() + + yield* transform((catalog) => { + for (const file of files) { + for (const [id, item] of Object.entries(file.info.providers ?? {})) { + const providerID = ProviderV2.ID.make(id) + catalog.provider.update(providerID, (provider) => { + if (item.name !== undefined) provider.name = item.name + if (item.env !== undefined) provider.env = [...item.env] + provider.enabled = { via: "custom", data: {} } + if (item.endpoint !== undefined) provider.endpoint = { ...item.endpoint } + if (item.options !== undefined) { + Object.assign(provider.options.headers, item.options.headers ?? {}) + Object.assign(provider.options.body, item.options.body ?? {}) + Object.assign(provider.options.aisdk.provider, item.options.aisdk?.provider ?? {}) + Object.assign(provider.options.aisdk.request, item.options.aisdk?.request ?? {}) + } + }) + + for (const [id, config] of Object.entries(item.models ?? {})) { + catalog.model.update(providerID, ModelV2.ID.make(id), (model) => { + if (config.api_id !== undefined) model.apiID = config.api_id + if (config.family !== undefined) model.family = config.family + if (config.name !== undefined) model.name = config.name + if (config.endpoint !== undefined) model.endpoint = { ...config.endpoint } + if (config.capabilities !== undefined) { + model.capabilities = { + tools: config.capabilities.tools, + input: [...config.capabilities.input], + output: [...config.capabilities.output], + } + } + if (config.options !== undefined) { + Object.assign(model.options.headers, config.options.headers ?? {}) + Object.assign(model.options.body, config.options.body ?? {}) + Object.assign(model.options.aisdk.provider, config.options.aisdk?.provider ?? {}) + Object.assign(model.options.aisdk.request, config.options.aisdk?.request ?? {}) + if (config.options.variant !== undefined) model.options.variant = config.options.variant + } + if (config.variants !== undefined) { + for (const variant of config.variants) { + let existing = model.variants.find((item) => item.id === variant.id) + if (!existing) { + existing = { + id: variant.id, + headers: {}, + body: {}, + aisdk: { + provider: {}, + request: {}, + }, + } + model.variants.push(existing) + } + Object.assign(existing.headers, variant.headers ?? {}) + Object.assign(existing.body, variant.body ?? {}) + Object.assign(existing.aisdk.provider, variant.aisdk?.provider ?? {}) + Object.assign(existing.aisdk.request, variant.aisdk?.request ?? {}) + } + } + if (config.cost !== undefined) { + model.cost = (Array.isArray(config.cost) ? config.cost : [config.cost]).map((cost) => ({ + tier: cost.tier && { ...cost.tier }, + input: cost.input, + output: cost.output, + cache: { + read: cost.cache?.read ?? 0, + write: cost.cache?.write ?? 0, + }, + })) + } + if (config.disabled !== undefined) model.enabled = !config.disabled + if (config.limit !== undefined) model.limit = { ...model.limit, ...config.limit } + }) + } + } + } + }) + }), +}) diff --git a/packages/core/src/config/provider.ts b/packages/core/src/config/provider.ts new file mode 100644 index 0000000000..fbb0e1c3ef --- /dev/null +++ b/packages/core/src/config/provider.ts @@ -0,0 +1,62 @@ +export * as ConfigProvider from "./provider" + +import { Schema } from "effect" +import { ProviderV2 } from "../provider" +import { ModelV2 } from "../model" + +export class Options extends Schema.Class("ConfigV2.Provider.Options")({ + headers: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional), + body: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), + aisdk: Schema.Struct({ + provider: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), + request: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), + }).pipe(Schema.optional), +}) {} + +class Cache extends Schema.Class("ConfigV2.Model.Cost.Cache")({ + read: Schema.Finite.pipe(Schema.optional), + write: Schema.Finite.pipe(Schema.optional), +}) {} + +class Cost extends Schema.Class("ConfigV2.Model.Cost")({ + tier: Schema.Struct({ + type: Schema.Literal("context"), + size: Schema.Int, + }).pipe(Schema.optional), + input: Schema.Finite, + output: Schema.Finite, + cache: Cache.pipe(Schema.optional), +}) {} + +class Limit extends Schema.Class("ConfigV2.Model.Limit")({ + context: Schema.Int.pipe(Schema.optional), + input: Schema.Int.pipe(Schema.optional), + output: Schema.Int.pipe(Schema.optional), +}) {} + +class Model extends Schema.Class("ConfigV2.Model")({ + api_id: ModelV2.ID.pipe(Schema.optional), + family: ModelV2.Family.pipe(Schema.optional), + name: Schema.String.pipe(Schema.optional), + endpoint: ProviderV2.Endpoint.pipe(Schema.optional), + capabilities: ModelV2.Capabilities.pipe(Schema.optional), + options: Schema.Struct({ + ...Options.fields, + variant: Schema.String.pipe(Schema.optional), + }).pipe(Schema.optional), + variants: Schema.Struct({ + id: ModelV2.VariantID, + ...Options.fields, + }).pipe(Schema.Array, Schema.optional), + cost: Schema.Union([Cost, Cost.pipe(Schema.Array)]).pipe(Schema.optional), + disabled: Schema.Boolean.pipe(Schema.optional), + limit: Limit.pipe(Schema.optional), +}) {} + +export class Info extends Schema.Class("ConfigV2.Provider")({ + name: Schema.String.pipe(Schema.optional), + env: Schema.String.pipe(Schema.Array, Schema.optional), + endpoint: ProviderV2.Endpoint.pipe(Schema.optional), + options: Options.pipe(Schema.optional), + models: Schema.Record(Schema.String, Model).pipe(Schema.optional), +}) {} diff --git a/packages/core/src/config/reference.ts b/packages/core/src/config/reference.ts new file mode 100644 index 0000000000..dc9042e6f7 --- /dev/null +++ b/packages/core/src/config/reference.ts @@ -0,0 +1,17 @@ +export * as ConfigReference from "./reference" + +import { Schema } from "effect" + +export class Git extends Schema.Class("ConfigV2.Reference.Git")({ + repository: Schema.String, + branch: Schema.String.pipe(Schema.optional), +}) {} + +export class Local extends Schema.Class("ConfigV2.Reference.Local")({ + path: Schema.String, +}) {} + +export const Entry = Schema.Union([Schema.String, Git, Local]) +export type Entry = typeof Entry.Type + +export const Info = Schema.Record(Schema.String, Entry) diff --git a/packages/core/src/config/tool-output.ts b/packages/core/src/config/tool-output.ts new file mode 100644 index 0000000000..53e4d4d088 --- /dev/null +++ b/packages/core/src/config/tool-output.ts @@ -0,0 +1,9 @@ +export * as ConfigToolOutput from "./tool-output" + +import { Schema } from "effect" +import { PositiveInt } from "../schema" + +export class Info extends Schema.Class("ConfigV2.ToolOutput")({ + max_lines: PositiveInt.pipe(Schema.optional), + max_bytes: PositiveInt.pipe(Schema.optional), +}) {} diff --git a/packages/core/src/config/watcher.ts b/packages/core/src/config/watcher.ts new file mode 100644 index 0000000000..2df6c876bf --- /dev/null +++ b/packages/core/src/config/watcher.ts @@ -0,0 +1,7 @@ +export * as ConfigWatcher from "./watcher" + +import { Schema } from "effect" + +export class Info extends Schema.Class("ConfigV2.Watcher")({ + ignore: Schema.String.pipe(Schema.Array, Schema.optional), +}) {} diff --git a/packages/opencode/src/effect/service-use.ts b/packages/core/src/effect/service-use.ts similarity index 85% rename from packages/opencode/src/effect/service-use.ts rename to packages/core/src/effect/service-use.ts index a93cdecbb1..89d9acf98e 100644 --- a/packages/opencode/src/effect/service-use.ts +++ b/packages/core/src/effect/service-use.ts @@ -15,6 +15,7 @@ type ServiceUse = { } export const serviceUse = (tag: Context.Service) => { + const cache = new Map Effect.Effect>() // This is the only dynamic boundary: TypeScript knows the accessor shape, // but Proxy property names are runtime values. const access = new Proxy( @@ -22,7 +23,9 @@ export const serviceUse = (tag: Context.Service { if (typeof key !== "string") return undefined - return (...args: unknown[]) => + const cached = cache.get(key) + if (cached) return cached + const accessor = (...args: unknown[]) => tag.use((service) => { // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- Proxy keys are checked at runtime. const method = service[key as keyof Shape] @@ -30,6 +33,8 @@ export const serviceUse = (tag: Context.Service Effect.Effect)(...args) }) + cache.set(key, accessor) + return accessor }, }, ) diff --git a/packages/core/src/event.ts b/packages/core/src/event.ts index a4a5dd8595..339fbddecf 100644 --- a/packages/core/src/event.ts +++ b/packages/core/src/event.ts @@ -128,7 +128,7 @@ export const layer = Layer.effect( ...(options?.metadata ? { metadata: options.metadata } : {}), type: definition.type, ...(definition.version === undefined ? {} : { version: definition.version }), - ...(location ? { location } : {}), + ...(location ? { location: { directory: location.directory, workspaceID: location.workspaceID } } : {}), data, } as Payload return yield* publishEvent(event) diff --git a/packages/core/src/filesystem.ts b/packages/core/src/filesystem.ts index 8a1cc3a08f..d4bfe6dba4 100644 --- a/packages/core/src/filesystem.ts +++ b/packages/core/src/filesystem.ts @@ -3,9 +3,10 @@ import { dirname, join, relative, resolve as pathResolve } from "path" import { realpathSync } from "fs" import * as NFS from "fs/promises" import { lookup } from "mime-types" -import { Effect, FileSystem, Layer, Schema, Context } from "effect" +import { Context, Effect, FileSystem, Layer, Schema } from "effect" import type { PlatformError } from "effect/PlatformError" import { Glob } from "./util/glob" +import { serviceUse } from "./effect/service-use" export namespace AppFileSystem { export class FileSystemError extends Schema.TaggedErrorClass()("FileSystemError", { @@ -39,6 +40,8 @@ export namespace AppFileSystem { export class Service extends Context.Service()("@opencode/FileSystem") {} + export const use = serviceUse(Service) + export const layer = Layer.effect( Service, Effect.gen(function* () { diff --git a/packages/core/src/flag/flag.ts b/packages/core/src/flag/flag.ts index 1ac32562fb..d03e3b4f96 100644 --- a/packages/core/src/flag/flag.ts +++ b/packages/core/src/flag/flag.ts @@ -8,6 +8,10 @@ function truthy(key: string) { const KILO_EXPERIMENTAL = truthy("KILO_EXPERIMENTAL") const copy = process.env["KILO_EXPERIMENTAL_DISABLE_COPY_ON_SELECT"] +function enabledByExperimental(key: string) { + return process.env[key] === undefined ? KILO_EXPERIMENTAL : truthy(key) +} + export const Flag = { OTEL_EXPORTER_OTLP_ENDPOINT: process.env["OTEL_EXPORTER_OTLP_ENDPOINT"], OTEL_EXPORTER_OTLP_HEADERS: process.env["OTEL_EXPORTER_OTLP_HEADERS"], @@ -42,7 +46,8 @@ export const Flag = { KILO_DB: process.env["KILO_DB"], KILO_WORKSPACE_ID: process.env["KILO_WORKSPACE_ID"], - KILO_EXPERIMENTAL_WORKSPACES: KILO_EXPERIMENTAL || truthy("KILO_EXPERIMENTAL_WORKSPACES"), + KILO_EXPERIMENTAL_WORKSPACES: enabledByExperimental("KILO_EXPERIMENTAL_WORKSPACES"), + KILO_EXPERIMENTAL_SESSION_SWITCHER: enabledByExperimental("KILO_EXPERIMENTAL_SESSION_SWITCHER"), // Evaluated at access time (not module load) because tests, the CLI, and // external tooling set these env vars at runtime. diff --git a/packages/core/src/git.ts b/packages/core/src/git.ts new file mode 100644 index 0000000000..a574523368 --- /dev/null +++ b/packages/core/src/git.ts @@ -0,0 +1,114 @@ +export * as Git from "./git" + +import path from "path" +import { Context, Effect, Layer } from "effect" +import { ChildProcess } from "effect/unstable/process" +import { AbsolutePath } from "./schema" +import { AppFileSystem } from "./filesystem" +import { AppProcess } from "./process" + +export interface Repo { + /** + * The root directory of the working tree that contains the input path. + * + * For `/home/me/app/src/file.ts` in a normal clone, this is `/home/me/app`. + * For `/home/me/app-feature/src/file.ts` in a linked worktree, this is + * `/home/me/app-feature`. + */ + readonly directory: AbsolutePath + /** + * The shared Git storage directory used by this repo and any linked worktrees. + * + * For a normal clone at `/home/me/app`, this is usually `/home/me/app/.git`. + * For a linked worktree at `/home/me/app-feature` whose main checkout is + * `/home/me/app`, this is usually `/home/me/app/.git`. + */ + readonly store: AbsolutePath +} + +export interface Interface { + readonly find: (input: AbsolutePath) => Effect.Effect + readonly remote: (repo: Repo, name?: string) => Effect.Effect + readonly roots: (repo: Repo) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/GitV2") {} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const proc = yield* AppProcess.Service + + const find = Effect.fn("Git.find")(function* (input: AbsolutePath) { + const dotgit = yield* fs.up({ targets: [".git"], start: input }).pipe( + Effect.map((matches) => matches[0]), + Effect.catch(() => Effect.succeed(undefined)), + ) + if (!dotgit) return undefined + + const cwd = path.dirname(dotgit) + const git = run(cwd, proc) + const topLevel = yield* git(["rev-parse", "--show-toplevel"]) + const commonDir = yield* git(["rev-parse", "--git-common-dir"]) + if (commonDir.exitCode !== 0) return undefined + + return { + directory: AbsolutePath.make(topLevel.exitCode === 0 ? resolvePath(cwd, topLevel.text) : cwd), + store: AbsolutePath.make(resolvePath(cwd, commonDir.text)), + } satisfies Repo + }) + + const remote = Effect.fn("Git.remote")(function* (repo: Repo, name = "origin") { + const result = yield* run(repo.directory, proc)(["remote", "get-url", name]) + if (result.exitCode !== 0) return undefined + return result.text.trim() || undefined + }) + + const roots = Effect.fn("Git.roots")(function* (repo: Repo) { + const result = yield* run(repo.directory, proc)(["rev-list", "--max-parents=0", "HEAD"]) + if (result.exitCode !== 0) return [] + return result.text + .split("\n") + .map((item) => item.trim()) + .filter(Boolean) + .toSorted() + }) + + return Service.of({ find, remote, roots }) + }), +) + +export const defaultLayer = layer.pipe( + Layer.provide(AppFileSystem.defaultLayer), + Layer.provide(AppProcess.defaultLayer), +) + +interface Result { + readonly exitCode: number + readonly text: string +} + +function run(cwd: string, proc: AppProcess.Interface) { + return (args: string[]) => + proc + .run( + ChildProcess.make("git", args, { + cwd, + extendEnv: true, + stdin: "ignore", + }), + ) + .pipe( + Effect.map((result) => ({ exitCode: result.exitCode, text: result.stdout.toString("utf8") }) satisfies Result), + Effect.catch(() => Effect.succeed({ exitCode: 1, text: "" } satisfies Result)), + ) +} + +function resolvePath(cwd: string, value: string) { + const trimmed = value.replace(/[\r\n]+$/, "") + if (!trimmed) return cwd + const normalized = AppFileSystem.windowsPath(trimmed) + if (path.isAbsolute(normalized)) return path.normalize(normalized) + return path.resolve(cwd, normalized) +} diff --git a/packages/core/src/location-layer.ts b/packages/core/src/location-layer.ts index c40a940430..67293f7c5f 100644 --- a/packages/core/src/location-layer.ts +++ b/packages/core/src/location-layer.ts @@ -2,12 +2,17 @@ import { Layer, LayerMap } from "effect" import { Location } from "./location" import { Catalog } from "./catalog" import { PluginBoot } from "./plugin/boot" +import { Policy } from "./policy" +import { Config } from "./config" export class LocationServiceMap extends LayerMap.Service()("@opencode/example/LocationServiceMap", { - lookup: (ref: Location.Ref) => - Layer.mergeAll(Catalog.defaultLayer, PluginBoot.defaultLayer).pipe( - Layer.provide([Layer.succeed(Location.Service, Location.Service.of(ref))]), - ), - idleTimeToLive: "5 minutes", + lookup: (ref: Location.Ref) => { + const result = Layer.mergeAll(Catalog.defaultLayer, PluginBoot.defaultLayer, Config.defaultLayer).pipe( + Layer.provideMerge(Policy.defaultLayer), + Layer.provideMerge(Location.defaultLayer(ref)), + ) + return result + }, + idleTimeToLive: "60 minutes", dependencies: [], }) {} diff --git a/packages/core/src/location.ts b/packages/core/src/location.ts index 00ff9cd3ea..68c9a8f791 100644 --- a/packages/core/src/location.ts +++ b/packages/core/src/location.ts @@ -1,11 +1,40 @@ -import { Context, Schema } from "effect" +import { Context, Effect, Layer, Schema } from "effect" +import { Project } from "./project" +import { AbsolutePath } from "./schema" export * as Location from "./location" export const Ref = Schema.Struct({ - directory: Schema.String, + directory: AbsolutePath, workspaceID: Schema.optional(Schema.String), }).annotate({ identifier: "Location.Ref" }) export type Ref = typeof Ref.Type -export class Service extends Context.Service()("@opencode/Location") {} +export interface Interface { + readonly directory: AbsolutePath + readonly workspaceID?: string + readonly project: { + readonly id: Project.ID + readonly directory: AbsolutePath + } + readonly vcs?: Project.Vcs +} + +export class Service extends Context.Service()("@opencode/Location") {} + +export const layer = (ref: Ref) => + Layer.effect( + Service, + Effect.gen(function* () { + const project = yield* Project.Service + const resolved = yield* project.resolve(ref.directory) + return Service.of({ + directory: ref.directory, + workspaceID: ref.workspaceID, + project: { id: resolved.id, directory: resolved.directory }, + vcs: resolved.vcs, + }) + }), + ) + +export const defaultLayer = (ref: Ref) => layer(ref).pipe(Layer.provide(Project.defaultLayer)) diff --git a/packages/core/src/model.ts b/packages/core/src/model.ts index 77b8c60ebe..8cf02ddfe0 100644 --- a/packages/core/src/model.ts +++ b/packages/core/src/model.ts @@ -36,7 +36,7 @@ export const Cost = Schema.Struct({ export const Ref = Schema.Struct({ id: ID, providerID: ProviderV2.ID, - variant: VariantID, + variant: VariantID.pipe(Schema.optional), }) export type Ref = typeof Ref.Type diff --git a/packages/core/src/plugin.ts b/packages/core/src/plugin.ts index ab2d4cbf7d..df8a40c1fa 100644 --- a/packages/core/src/plugin.ts +++ b/packages/core/src/plugin.ts @@ -2,17 +2,26 @@ export * as PluginV2 from "./plugin" import { createDraft, finishDraft, type Draft } from "immer" import type { LanguageModelV3 } from "@ai-sdk/provider" -import { Context, Effect, Exit, Layer, PubSub, Schema, Scope, Stream } from "effect" +import { Context, Effect, Exit, Layer, Schema, Scope } from "effect" import type { ModelV2 } from "./model" -import type { AgentV2 } from "./agent" import type { Catalog } from "./catalog" +import { EventV2 } from "./event" export const ID = Schema.String.pipe(Schema.brand("Plugin.ID")) export type ID = typeof ID.Type +export const Event = { + Added: EventV2.define({ + type: "plugin.added", + schema: { + id: ID, + }, + }), +} + type HookSpec = { "catalog.transform": { - input: Catalog.Context + input: Catalog.Editor output: {} } "account.switched": { @@ -43,27 +52,6 @@ type HookSpec = { sdk?: any } } - "agent.update": { - input: {} - output: { - agent: AgentV2.Info - cancel: boolean - } - } - "agent.remove": { - input: { - agent: AgentV2.Info - } - output: { - cancel: boolean - } - } - "agent.default": { - input: {} - output: { - agent?: AgentV2.ID - } - } } export type Hooks = { @@ -93,7 +81,6 @@ export interface Interface { effect: Effect.Effect }) => Effect.Effect readonly remove: (id: ID) => Effect.Effect - readonly added: () => Stream.Stream readonly triggerFor: ( id: ID, name: Name, @@ -117,9 +104,7 @@ export const layer = Layer.effect( hooks: HookFunctions scope: Scope.Closeable }[] = [] - const added = yield* PubSub.unbounded() - - yield* Effect.addFinalizer(() => PubSub.shutdown(added)) + const events = yield* EventV2.Service const svc = Service.of({ add: Effect.fn("Plugin.add")(function* (input) { @@ -135,9 +120,8 @@ export const layer = Layer.effect( scope, }, ] - yield* PubSub.publish(added, input.id) + yield* events.publish(Event.Added, { id: input.id }) }), - added: () => Stream.fromPubSub(added), trigger: Effect.fn("Plugin.trigger")(function* (name, input, output) { return yield* svc.triggerFor(ID.make("*"), name, input, output) }), @@ -185,7 +169,7 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer +export const defaultLayer = layer.pipe(Layer.provide(EventV2.defaultLayer)) // opencode // sdcok diff --git a/packages/core/src/plugin/account.ts b/packages/core/src/plugin/account.ts index d4d00c3ab6..4e77701f31 100644 --- a/packages/core/src/plugin/account.ts +++ b/packages/core/src/plugin/account.ts @@ -19,7 +19,7 @@ export const AccountPlugin = PluginV2.define({ return { "catalog.transform": Effect.fn(function* (evt) { - for (const item of evt.data) { + for (const item of evt.provider.list()) { const account = yield* accounts.active(AccountV2.ServiceID.make(item.provider.id)).pipe(Effect.orDie) if (!account) continue evt.provider.update(item.provider.id, (provider) => { diff --git a/packages/core/src/plugin/agent.ts b/packages/core/src/plugin/agent.ts new file mode 100644 index 0000000000..9baba75c38 --- /dev/null +++ b/packages/core/src/plugin/agent.ts @@ -0,0 +1,211 @@ +export * as AgentPlugin from "./agent" + +import path from "path" +import { Effect } from "effect" +import { AgentV2 } from "../agent" +import { Global } from "../global" +import { Location } from "../location" +import { PermissionV2 } from "../permission" +import { PluginV2 } from "../plugin" + +const TRUNCATION_GLOB = path.join(Global.Path.data, "tool-output", "*") + +const PROMPT_EXPLORE = `You are a file search specialist. You excel at thoroughly navigating and exploring codebases. + +Your strengths: +- Rapidly finding files using glob patterns +- Searching code and text with powerful regex patterns +- Reading and analyzing file contents + +Guidelines: +- Use Glob for broad file pattern matching +- Use Grep for searching file contents with regex +- Use Read when you know the specific file path you need to read +- Use Bash for file operations like copying, moving, or listing directory contents +- Adapt your search approach based on the thoroughness level specified by the caller +- Return file paths as absolute paths in your final response +- For clear communication, avoid using emojis +- Do not create any files, or run bash commands that modify the user's system state in any way + +Complete the user's search request efficiently and report your findings clearly.` + +const PROMPT_COMPACTION = `You are an anchored context summarization assistant for coding sessions. + +Summarize only the conversation history you are given. The newest turns may be kept verbatim outside your summary, so focus on the older context that still matters for continuing the work. + +If the prompt includes a block, treat it as the current anchored summary. Update it with the new history by preserving still-true details, removing stale details, and merging in new facts. + +Always follow the exact output structure requested by the user prompt. Keep every section, preserve exact file paths and identifiers when known, and prefer terse bullets over paragraphs. + +Do not answer the conversation itself. Do not mention that you are summarizing, compacting, or merging context. Respond in the same language as the conversation.` + +const PROMPT_TITLE = `You are a title generator. You output ONLY a thread title. Nothing else. + + +Generate a brief title that would help the user find this conversation later. + +Follow all rules in +Use the so you know what a good title looks like. +Your output must be: +- A single line +- <=50 characters +- No explanations + + + +- you MUST use the same language as the user message you are summarizing +- Title must be grammatically correct and read naturally - no word salad +- Never include tool names in the title (e.g. "read tool", "bash tool", "edit tool") +- Focus on the main topic or question the user needs to retrieve +- Vary your phrasing - avoid repetitive patterns like always starting with "Analyzing" +- When a file is mentioned, focus on WHAT the user wants to do WITH the file, not just that they shared it +- Keep exact: technical terms, numbers, filenames, HTTP codes +- Remove: the, this, my, a, an +- Never assume tech stack +- Never use tools +- NEVER respond to questions, just generate a title for the conversation +- The title should NEVER include "summarizing" or "generating" when generating a title +- DO NOT SAY YOU CANNOT GENERATE A TITLE OR COMPLAIN ABOUT THE INPUT +- Always output something meaningful, even if the input is minimal. +- If the user message is short or conversational (e.g. "hello", "lol", "what's up", "hey"): + -> create a title that reflects the user's tone or intent (such as Greeting, Quick check-in, Light chat, Intro message, etc.) + + + +"debug 500 errors in production" -> Debugging production 500 errors +"refactor user service" -> Refactoring user service +"why is app.js failing" -> app.js failure investigation +"implement rate limiting" -> Rate limiting implementation +"how do I connect postgres to my API" -> Postgres API connection +"best practices for React hooks" -> React hooks best practices +"@src/auth.ts can you add refresh token support" -> Auth refresh token support +"@utils/parser.ts this is broken" -> Parser bug fix +"look at @config.json" -> Config review +"@App.tsx add dark mode toggle" -> Dark mode toggle in App +` + +const PROMPT_SUMMARY = `Summarize what was done in this conversation. Write like a pull request description. + +Rules: +- 2-3 sentences max +- Describe the changes made, not the process +- Do not mention running tests, builds, or other validation steps +- Do not explain what the user asked for +- Write in first person (I added..., I fixed...) +- Never ask questions or add new questions +- If the conversation ends with an unanswered question to the user, preserve that exact question +- If the conversation ends with an imperative statement or request to the user (e.g. "Now please run the command and paste the console output"), always include that exact request in the summary` + +export const Plugin = PluginV2.define({ + id: PluginV2.ID.make("agent"), + effect: Effect.gen(function* () { + const agent = yield* AgentV2.Service + const location = yield* Location.Service + const worktree = location.directory + const whitelistedDirs = [TRUNCATION_GLOB, path.join(Global.Path.tmp, "*")] + const readonlyExternalDirectory: PermissionV2.Ruleset = [ + { permission: "external_directory", pattern: "*", action: "ask" }, + ...whitelistedDirs.map( + (pattern): PermissionV2.Rule => ({ permission: "external_directory", pattern, action: "allow" }), + ), + ] + const defaults: PermissionV2.Ruleset = [ + { permission: "*", pattern: "*", action: "allow" }, + ...readonlyExternalDirectory, + { permission: "question", pattern: "*", action: "deny" }, + { permission: "plan_enter", pattern: "*", action: "deny" }, + { permission: "plan_exit", pattern: "*", action: "deny" }, + { permission: "repo_clone", pattern: "*", action: "deny" }, + { permission: "repo_overview", pattern: "*", action: "deny" }, + { permission: "read", pattern: "*", action: "allow" }, + { permission: "read", pattern: "*.env", action: "ask" }, + { permission: "read", pattern: "*.env.*", action: "ask" }, + { permission: "read", pattern: "*.env.example", action: "allow" }, + ] + + yield* agent.update((editor) => { + editor.update(AgentV2.ID.make("build"), (item) => { + item.description = "The default agent. Executes tools based on configured permissions." + item.mode = "primary" + item.permissions.push( + ...PermissionV2.merge(defaults, [ + { permission: "question", pattern: "*", action: "allow" }, + { permission: "plan_enter", pattern: "*", action: "allow" }, + ]), + ) + }) + + editor.update(AgentV2.ID.make("plan"), (item) => { + item.description = "Plan mode. Disallows all edit tools." + item.mode = "primary" + item.permissions.push( + ...PermissionV2.merge(defaults, [ + { permission: "question", pattern: "*", action: "allow" }, + { permission: "plan_exit", pattern: "*", action: "allow" }, + { permission: "external_directory", pattern: path.join(Global.Path.data, "plans", "*"), action: "allow" }, + { permission: "edit", pattern: "*", action: "deny" }, + { permission: "edit", pattern: path.join(".opencode", "plans", "*.md"), action: "allow" }, + { + permission: "edit", + pattern: path.relative(worktree, path.join(Global.Path.data, "plans", "*.md")), + action: "allow", + }, + ]), + ) + }) + + editor.update(AgentV2.ID.make("general"), (item) => { + item.description = + "General-purpose agent for researching complex questions and executing multi-step tasks. Use this agent to execute multiple units of work in parallel." + item.mode = "subagent" + item.permissions.push( + ...PermissionV2.merge(defaults, [{ permission: "todowrite", pattern: "*", action: "deny" }]), + ) + }) + + editor.update(AgentV2.ID.make("explore"), (item) => { + item.description = + 'Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns (eg. "src/components/**/*.tsx"), search code for keywords (eg. "API endpoints"), or answer questions about the codebase (eg. "how do API endpoints work?"). When calling this agent, specify the desired thoroughness level: "quick" for basic searches, "medium" for moderate exploration, or "very thorough" for comprehensive analysis across multiple locations and naming conventions.' + item.system = PROMPT_EXPLORE + item.mode = "subagent" + item.permissions.push( + ...PermissionV2.merge( + defaults, + [ + { permission: "*", pattern: "*", action: "deny" }, + { permission: "grep", pattern: "*", action: "allow" }, + { permission: "glob", pattern: "*", action: "allow" }, + { permission: "list", pattern: "*", action: "allow" }, + { permission: "bash", pattern: "*", action: "allow" }, + { permission: "webfetch", pattern: "*", action: "allow" }, + { permission: "websearch", pattern: "*", action: "allow" }, + { permission: "read", pattern: "*", action: "allow" }, + ], + readonlyExternalDirectory, + ), + ) + }) + + editor.update(AgentV2.ID.make("compaction"), (item) => { + item.mode = "primary" + item.hidden = true + item.system = PROMPT_COMPACTION + item.permissions.push(...PermissionV2.merge(defaults, [{ permission: "*", pattern: "*", action: "deny" }])) + }) + + editor.update(AgentV2.ID.make("title"), (item) => { + item.mode = "primary" + item.hidden = true + item.system = PROMPT_TITLE + item.permissions.push(...PermissionV2.merge(defaults, [{ permission: "*", pattern: "*", action: "deny" }])) + }) + + editor.update(AgentV2.ID.make("summary"), (item) => { + item.mode = "primary" + item.hidden = true + item.system = PROMPT_SUMMARY + item.permissions.push(...PermissionV2.merge(defaults, [{ permission: "*", pattern: "*", action: "deny" }])) + }) + }) + }), +}) diff --git a/packages/core/src/plugin/boot.ts b/packages/core/src/plugin/boot.ts index 5624369e04..4456ac1908 100644 --- a/packages/core/src/plugin/boot.ts +++ b/packages/core/src/plugin/boot.ts @@ -4,10 +4,15 @@ import { Context, Deferred, Effect, Layer } from "effect" import { AccountV2 } from "../account" import { AgentV2 } from "../agent" import { Catalog } from "../catalog" +import { Config } from "../config" +import { ConfigAgentPlugin } from "../config/plugin/agent" import { EventV2 } from "../event" +import { Location } from "../location" import { Npm } from "../npm" import { PluginV2 } from "../plugin" import { AccountPlugin } from "./account" +import { AgentPlugin } from "./agent" +import { ConfigProviderPlugin } from "../config/plugin/provider" import { EnvPlugin } from "./env" import { ModelsDevPlugin } from "./models-dev" import { ProviderPlugins } from "./provider" @@ -15,7 +20,14 @@ import { ProviderPlugins } from "./provider" type Plugin = { id: PluginV2.ID effect: PluginV2.Effect< - Catalog.Service | AgentV2.Service | AccountV2.Service | Npm.Service | EventV2.Service | PluginV2.Service + | Catalog.Service + | AccountV2.Service + | AgentV2.Service + | Npm.Service + | EventV2.Service + | Location.Service + | PluginV2.Service + | Config.Service > } @@ -28,10 +40,12 @@ export class Service extends Context.Service()("@opencode/v2 export const layer = Layer.effect( Service, Effect.gen(function* () { - const agent = yield* AgentV2.Service const catalog = yield* Catalog.Service const plugin = yield* PluginV2.Service const accounts = yield* AccountV2.Service + const agents = yield* AgentV2.Service + const config = yield* Config.Service + const location = yield* Location.Service const npm = yield* Npm.Service const events = yield* EventV2.Service const done = yield* Deferred.make() @@ -41,8 +55,10 @@ export const layer = Layer.effect( id: input.id, effect: input.effect.pipe( Effect.provideService(Catalog.Service, catalog), - Effect.provideService(AgentV2.Service, agent), Effect.provideService(AccountV2.Service, accounts), + Effect.provideService(AgentV2.Service, agents), + Effect.provideService(Config.Service, config), + Effect.provideService(Location.Service, location), Effect.provideService(Npm.Service, npm), Effect.provideService(EventV2.Service, events), Effect.provideService(PluginV2.Service, plugin), @@ -53,10 +69,13 @@ export const layer = Layer.effect( const boot = Effect.gen(function* () { yield* add(EnvPlugin) yield* add(AccountPlugin) + yield* add(AgentPlugin.Plugin) for (const item of ProviderPlugins) { yield* add(item) } yield* add(ModelsDevPlugin) + yield* add(ConfigProviderPlugin.Plugin) + yield* add(ConfigAgentPlugin.Plugin) }).pipe(Effect.withSpan("PluginBoot.boot")) yield* boot.pipe( @@ -72,10 +91,11 @@ export const layer = Layer.effect( ) export const defaultLayer = layer.pipe( - Layer.provide(AgentV2.defaultLayer), Layer.provide(Catalog.defaultLayer), Layer.provide(EventV2.defaultLayer), Layer.provide(PluginV2.defaultLayer), Layer.provide(AccountV2.defaultLayer), + Layer.provide(AgentV2.defaultLayer), + Layer.provide(Config.defaultLayer), Layer.provide(Npm.defaultLayer), ) diff --git a/packages/core/src/plugin/env.ts b/packages/core/src/plugin/env.ts index 3d716fe6f3..35e6981a40 100644 --- a/packages/core/src/plugin/env.ts +++ b/packages/core/src/plugin/env.ts @@ -6,7 +6,7 @@ export const EnvPlugin = PluginV2.define({ effect: Effect.gen(function* () { return { "catalog.transform": Effect.fn(function* (evt) { - for (const item of evt.data) { + for (const item of evt.provider.list()) { const key = item.provider.env.find((env) => process.env[env]) if (!key) continue evt.provider.update(item.provider.id, (provider) => { diff --git a/packages/core/src/plugin/models-dev.ts b/packages/core/src/plugin/models-dev.ts index bde162d729..4733833fdb 100644 --- a/packages/core/src/plugin/models-dev.ts +++ b/packages/core/src/plugin/models-dev.ts @@ -57,10 +57,10 @@ export const ModelsDevPlugin = PluginV2.define({ const modelsDev = yield* ModelsDev.Service const events = yield* EventV2.Service const scope = yield* Scope.Scope - const load = yield* catalog.loader() + const transform = yield* catalog.transform() const refresh = Effect.fn("ModelsDevPlugin.refresh")(function* () { const data = yield* modelsDev.get() - yield* load((catalog) => { + yield* transform((catalog) => { for (const item of Object.values(data)) { const providerID = ProviderV2.ID.make(item.id) catalog.provider.update(providerID, (provider) => { diff --git a/packages/core/src/plugin/provider/amazon-bedrock.ts b/packages/core/src/plugin/provider/amazon-bedrock.ts index 44a5d44e2b..e7452ac2e9 100644 --- a/packages/core/src/plugin/provider/amazon-bedrock.ts +++ b/packages/core/src/plugin/provider/amazon-bedrock.ts @@ -51,7 +51,7 @@ export const AmazonBedrockPlugin = PluginV2.define({ effect: Effect.gen(function* () { return { "catalog.transform": Effect.fn(function* (evt) { - for (const item of evt.data) { + for (const item of evt.provider.list()) { if (item.provider.endpoint.type !== "aisdk") continue if (item.provider.endpoint.package !== "@ai-sdk/amazon-bedrock") continue evt.provider.update(item.provider.id, (provider) => { diff --git a/packages/core/src/plugin/provider/anthropic.ts b/packages/core/src/plugin/provider/anthropic.ts index 99fbf56ab3..026da36349 100644 --- a/packages/core/src/plugin/provider/anthropic.ts +++ b/packages/core/src/plugin/provider/anthropic.ts @@ -6,7 +6,7 @@ export const AnthropicPlugin = PluginV2.define({ effect: Effect.gen(function* () { return { "catalog.transform": Effect.fn(function* (evt) { - for (const item of evt.data) { + for (const item of evt.provider.list()) { if (item.provider.endpoint.type !== "aisdk") continue if (item.provider.endpoint.package !== "@ai-sdk/anthropic") continue evt.provider.update(item.provider.id, (provider) => { diff --git a/packages/core/src/plugin/provider/azure.ts b/packages/core/src/plugin/provider/azure.ts index ea5c34d7c0..bea98cf211 100644 --- a/packages/core/src/plugin/provider/azure.ts +++ b/packages/core/src/plugin/provider/azure.ts @@ -15,7 +15,7 @@ export const AzurePlugin = PluginV2.define({ effect: Effect.gen(function* () { return { "catalog.transform": Effect.fn(function* (evt) { - for (const item of evt.data) { + for (const item of evt.provider.list()) { if (item.provider.endpoint.type !== "aisdk") continue if (item.provider.endpoint.package !== "@ai-sdk/azure") continue const configured = item.provider.options.aisdk.provider.resourceName @@ -58,7 +58,7 @@ export const AzureCognitiveServicesPlugin = PluginV2.define({ "catalog.transform": Effect.fn(function* (evt) { const resourceName = process.env.AZURE_COGNITIVE_SERVICES_RESOURCE_NAME if (!resourceName) return - for (const item of evt.data) { + for (const item of evt.provider.list()) { if (item.provider.endpoint.type !== "aisdk") continue if (item.provider.endpoint.package !== "@ai-sdk/openai-compatible") continue if (!item.provider.id.includes("azure-cognitive-services")) continue diff --git a/packages/core/src/plugin/provider/cerebras.ts b/packages/core/src/plugin/provider/cerebras.ts index 12da385920..b18884cb6e 100644 --- a/packages/core/src/plugin/provider/cerebras.ts +++ b/packages/core/src/plugin/provider/cerebras.ts @@ -6,7 +6,7 @@ export const CerebrasPlugin = PluginV2.define({ effect: Effect.gen(function* () { return { "catalog.transform": Effect.fn(function* (ctx) { - for (const item of ctx.data) { + for (const item of ctx.provider.list()) { if (item.provider.endpoint.type !== "aisdk") continue if (item.provider.endpoint.package !== "@ai-sdk/cerebras") continue ctx.provider.update(item.provider.id, (provider) => { diff --git a/packages/core/src/plugin/provider/cloudflare-workers-ai.ts b/packages/core/src/plugin/provider/cloudflare-workers-ai.ts index d10f8e79e4..32cfb059f4 100644 --- a/packages/core/src/plugin/provider/cloudflare-workers-ai.ts +++ b/packages/core/src/plugin/provider/cloudflare-workers-ai.ts @@ -11,7 +11,7 @@ export const CloudflareWorkersAIPlugin = PluginV2.define({ effect: Effect.gen(function* () { return { "catalog.transform": Effect.fn(function* (evt) { - const item = evt.data.find((record) => record.provider.id === providerID) + const item = evt.provider.get(providerID) if (!item) return evt.provider.update(item.provider.id, (provider) => { if (provider.endpoint.type !== "aisdk") return diff --git a/packages/core/src/plugin/provider/github-copilot.ts b/packages/core/src/plugin/provider/github-copilot.ts index dc984cdef9..20b1ad6d6c 100644 --- a/packages/core/src/plugin/provider/github-copilot.ts +++ b/packages/core/src/plugin/provider/github-copilot.ts @@ -31,7 +31,7 @@ export const GithubCopilotPlugin = PluginV2.define({ : evt.sdk.chat(evt.model.apiID) }), "catalog.transform": Effect.fn(function* (evt) { - const item = evt.data.find((record) => record.provider.id === ProviderV2.ID.githubCopilot) + const item = evt.provider.get(ProviderV2.ID.githubCopilot) if (!item || !item.models.has(ModelV2.ID.make("gpt-5-chat-latest"))) return evt.model.update(item.provider.id, ModelV2.ID.make("gpt-5-chat-latest"), (model) => { // This chat-only alias conflicts with the Copilot GPT-5 Responses route, diff --git a/packages/core/src/plugin/provider/google-vertex.ts b/packages/core/src/plugin/provider/google-vertex.ts index 3da7caf4e7..ae1692b933 100644 --- a/packages/core/src/plugin/provider/google-vertex.ts +++ b/packages/core/src/plugin/provider/google-vertex.ts @@ -43,9 +43,9 @@ function authFetch(fetchWithRuntimeOptions?: unknown) { // do not, so inject a Google access token into their fetch path. return async (input: Parameters[0], init?: RequestInit) => { const { GoogleAuth } = await import("google-auth-library") - const auth = new GoogleAuth() - const client = await auth.getApplicationDefault() - const token = await client.credential.getAccessToken() + const auth = new GoogleAuth({ scopes: ["https://www.googleapis.com/auth/cloud-platform"] }) + const client = await auth.getClient() + const token = await client.getAccessToken() const headers = new Headers(init?.headers) headers.set("Authorization", `Bearer ${token.token}`) return typeof fetchWithRuntimeOptions === "function" @@ -59,7 +59,7 @@ export const GoogleVertexPlugin = PluginV2.define({ effect: Effect.gen(function* () { return { "catalog.transform": Effect.fn(function* (evt) { - for (const item of evt.data) { + for (const item of evt.provider.list()) { if (item.provider.endpoint.type !== "aisdk") continue if ( item.provider.endpoint.package !== "@ai-sdk/google-vertex" && @@ -110,7 +110,7 @@ export const GoogleVertexAnthropicPlugin = PluginV2.define({ effect: Effect.gen(function* () { return { "catalog.transform": Effect.fn(function* (evt) { - for (const item of evt.data) { + for (const item of evt.provider.list()) { if (item.provider.endpoint.type !== "aisdk") continue if (item.provider.endpoint.package !== "@ai-sdk/google-vertex/anthropic") continue const project = diff --git a/packages/core/src/plugin/provider/kilo.ts b/packages/core/src/plugin/provider/kilo.ts index 17436d9f96..098e5576c4 100644 --- a/packages/core/src/plugin/provider/kilo.ts +++ b/packages/core/src/plugin/provider/kilo.ts @@ -6,7 +6,7 @@ export const KiloPlugin = PluginV2.define({ effect: Effect.gen(function* () { return { "catalog.transform": Effect.fn(function* (evt) { - for (const item of evt.data) { + for (const item of evt.provider.list()) { if (item.provider.endpoint.type !== "aisdk") continue if (item.provider.endpoint.package !== "@ai-sdk/openai-compatible") continue if (item.provider.endpoint.url !== "https://api.kilo.ai/api/gateway") continue diff --git a/packages/core/src/plugin/provider/llmgateway.ts b/packages/core/src/plugin/provider/llmgateway.ts index de1872935c..8a971f0a0d 100644 --- a/packages/core/src/plugin/provider/llmgateway.ts +++ b/packages/core/src/plugin/provider/llmgateway.ts @@ -6,7 +6,7 @@ export const LLMGatewayPlugin = PluginV2.define({ effect: Effect.gen(function* () { return { "catalog.transform": Effect.fn(function* (evt) { - for (const item of evt.data) { + for (const item of evt.provider.list()) { if (item.provider.enabled === false) continue if (item.provider.endpoint.type !== "aisdk") continue if (item.provider.endpoint.package !== "@ai-sdk/openai-compatible") continue diff --git a/packages/core/src/plugin/provider/nvidia.ts b/packages/core/src/plugin/provider/nvidia.ts index 0c1301c842..f9c2a0420b 100644 --- a/packages/core/src/plugin/provider/nvidia.ts +++ b/packages/core/src/plugin/provider/nvidia.ts @@ -6,7 +6,7 @@ export const NvidiaPlugin = PluginV2.define({ effect: Effect.gen(function* () { return { "catalog.transform": Effect.fn(function* (evt) { - for (const item of evt.data) { + for (const item of evt.provider.list()) { if (item.provider.endpoint.type !== "aisdk") continue if (item.provider.endpoint.package !== "@ai-sdk/openai-compatible") continue if (item.provider.endpoint.url !== "https://integrate.api.nvidia.com/v1") continue diff --git a/packages/core/src/plugin/provider/openai.ts b/packages/core/src/plugin/provider/openai.ts index d76d7417f1..2d33fbcbbe 100644 --- a/packages/core/src/plugin/provider/openai.ts +++ b/packages/core/src/plugin/provider/openai.ts @@ -17,7 +17,7 @@ export const OpenAIPlugin = PluginV2.define({ evt.language = evt.sdk.responses(evt.model.apiID) }), "catalog.transform": Effect.fn(function* (evt) { - for (const item of evt.data) { + for (const item of evt.provider.list()) { if (item.provider.endpoint.type !== "aisdk") continue if (item.provider.endpoint.package !== "@ai-sdk/openai") continue if (!item.models.has(ModelV2.ID.make("gpt-5-chat-latest"))) continue diff --git a/packages/core/src/plugin/provider/opencode.ts b/packages/core/src/plugin/provider/opencode.ts index 411cbe091e..64d20f8bd4 100644 --- a/packages/core/src/plugin/provider/opencode.ts +++ b/packages/core/src/plugin/provider/opencode.ts @@ -8,7 +8,7 @@ export const OpencodePlugin = PluginV2.define({ let hasKey = false return { "catalog.transform": Effect.fn(function* (evt) { - const item = evt.data.find((record) => record.provider.id === ProviderV2.ID.opencode) + const item = evt.provider.get(ProviderV2.ID.opencode) if (!item) return hasKey = Boolean( process.env.OPENCODE_API_KEY || diff --git a/packages/core/src/plugin/provider/openrouter.ts b/packages/core/src/plugin/provider/openrouter.ts index 317f48158a..dd3e16070b 100644 --- a/packages/core/src/plugin/provider/openrouter.ts +++ b/packages/core/src/plugin/provider/openrouter.ts @@ -7,7 +7,7 @@ export const OpenRouterPlugin = PluginV2.define({ effect: Effect.gen(function* () { return { "catalog.transform": Effect.fn(function* (evt) { - for (const item of evt.data) { + for (const item of evt.provider.list()) { if (item.provider.endpoint.type !== "aisdk") continue if (item.provider.endpoint.package !== "@openrouter/ai-sdk-provider") continue evt.provider.update(item.provider.id, (provider) => { diff --git a/packages/core/src/plugin/provider/vercel.ts b/packages/core/src/plugin/provider/vercel.ts index fa368d10d7..1da00989b3 100644 --- a/packages/core/src/plugin/provider/vercel.ts +++ b/packages/core/src/plugin/provider/vercel.ts @@ -6,7 +6,7 @@ export const VercelPlugin = PluginV2.define({ effect: Effect.gen(function* () { return { "catalog.transform": Effect.fn(function* (evt) { - for (const item of evt.data) { + for (const item of evt.provider.list()) { if (item.provider.endpoint.type !== "aisdk") continue if (item.provider.endpoint.package !== "@ai-sdk/vercel") continue evt.provider.update(item.provider.id, (provider) => { diff --git a/packages/core/src/plugin/provider/zenmux.ts b/packages/core/src/plugin/provider/zenmux.ts index 3b0fcff165..01c3bd8adb 100644 --- a/packages/core/src/plugin/provider/zenmux.ts +++ b/packages/core/src/plugin/provider/zenmux.ts @@ -6,7 +6,7 @@ export const ZenmuxPlugin = PluginV2.define({ effect: Effect.gen(function* () { return { "catalog.transform": Effect.fn(function* (evt) { - for (const item of evt.data) { + for (const item of evt.provider.list()) { if (item.provider.endpoint.type !== "aisdk") continue if (item.provider.endpoint.package !== "@ai-sdk/openai-compatible") continue if (item.provider.endpoint.url !== "https://zenmux.ai/api/v1") continue diff --git a/packages/core/src/policy.ts b/packages/core/src/policy.ts new file mode 100644 index 0000000000..78bd74f1cd --- /dev/null +++ b/packages/core/src/policy.ts @@ -0,0 +1,44 @@ +export * as Policy from "./policy" + +import { Context, Effect as EffectRuntime, Layer, Schema } from "effect" +import { Wildcard } from "./util/wildcard" +import { Location } from "./location" + +export const Effect = Schema.Literals(["allow", "deny"]).annotate({ identifier: "Policy.Effect" }) +export type Effect = typeof Effect.Type + +export class Info extends Schema.Class("Policy.Info")({ + action: Schema.String, + effect: Effect, + resource: Schema.String, +}) {} + +export interface Interface { + readonly load: (statements: Info[]) => EffectRuntime.Effect + readonly evaluate: (action: string, resource: string, fallback: Effect) => EffectRuntime.Effect +} + +export class Service extends Context.Service()("@opencode/v2/Policy") {} + +export const layer = Layer.effect( + Service, + EffectRuntime.gen(function* () { + let statements: Info[] = [] + yield* Location.Service + + return Service.of({ + load: EffectRuntime.fn("Policy.load")(function* (input) { + statements = input + }), + evaluate: EffectRuntime.fn("Policy.evaluate")(function* (action, resource, fallback) { + return ( + statements.findLast( + (statement) => Wildcard.match(action, statement.action) && Wildcard.match(resource, statement.resource), + )?.effect ?? fallback + ) + }), + }) + }), +) + +export const defaultLayer = layer diff --git a/packages/core/src/project.ts b/packages/core/src/project.ts new file mode 100644 index 0000000000..90638873d7 --- /dev/null +++ b/packages/core/src/project.ts @@ -0,0 +1,128 @@ +export * as Project from "./project" + +import { Context, Effect, Layer, Schema } from "effect" +import path from "path" +import { AbsolutePath, withStatics } from "./schema" +import { AppFileSystem } from "./filesystem" +import { Git } from "./git" +import { Hash } from "./util/hash" + +export const ID = Schema.String.pipe( + Schema.brand("Project.ID"), + withStatics((schema) => ({ + global: schema.make("global"), + })), +) +export type ID = typeof ID.Type + +export const Vcs = Schema.Union([ + Schema.Struct({ + type: Schema.Literal("git"), + store: AbsolutePath, + }), +]) +export type Vcs = typeof Vcs.Type + +export class Info extends Schema.Class("Project.Info")({ + id: ID, +}) {} + +export interface Interface { + readonly resolve: (input: AbsolutePath) => Effect.Effect< + { + previous?: ID + id: ID + directory: AbsolutePath + vcs?: Vcs + }, + never + > + /** + * Temporary bridge method for writing the resolved project ID to the repo-local cache. + * + * This exists while the old opencode project service and this core project + * service work together: core resolves the ID, while the old service still owns + * database migration and persistence. The old service should call this after it + * finishes migrating from `resolve().previous` to `resolve().id`; once project + * persistence moves into core, this separate bridge method can go away. + */ + readonly commit: (input: { store: AbsolutePath; id: ID }) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/ProjectV2") {} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const git = yield* Git.Service + + const cached = Effect.fnUntraced(function* (dir: string) { + return yield* fs.readFileString(path.join(dir, "opencode")).pipe( + Effect.map((value) => value.trim()), + Effect.map((value) => (value ? ID.make(value) : undefined)), + Effect.catch(() => Effect.succeed(undefined)), + ) + }) + + const remote = Effect.fnUntraced(function* (repo: Git.Repo) { + const origin = yield* git.remote(repo) + if (!origin) return undefined + const normalized = url(origin) + if (!normalized) return undefined + return ID.make(Hash.fast(`git-remote:${normalized}`)) + }) + + function url(input: string) { + const value = input.trim() + if (!value) return undefined + + try { + const parsed = new URL(value) + if (parsed.protocol === "file:") return undefined + return parts(parsed.hostname, parsed.pathname) + } catch { + const scp = value.match(/^([^@/:]+@)?([^/:]+):(.+)$/) + if (scp) return parts(scp[2], scp[3]) + return undefined + } + } + + function parts(host: string, name: string) { + const pathname = name + .replace(/^\/+/, "") + .replace(/\.git\/?$/, "") + .replace(/\/+$/, "") + if (!host || !pathname) return undefined + return `${host.toLowerCase()}/${pathname}` + } + + const root = Effect.fnUntraced(function* (repo: Git.Repo) { + const root = (yield* git.roots(repo))[0] + return root ? ID.make(root) : undefined + }) + + const resolve = Effect.fn("Project.resolve")(function* (input: AbsolutePath) { + const repo = yield* git.find(input) + if (!repo) return { id: ID.global, directory: AbsolutePath.make(path.parse(input).root), vcs: undefined } + + const previous = yield* cached(repo.store) + const id = (yield* remote(repo)) ?? previous ?? (yield* root(repo)) + + return { + previous, + id: id ?? ID.global, + directory: repo.directory, + vcs: { type: "git" as const, store: repo.store }, + } + }) + + const commit = Effect.fn("Project.commit")(function* (input: { store: AbsolutePath; id: ID }) { + yield* fs.writeFileString(path.join(input.store, "opencode"), input.id).pipe(Effect.ignore) + }) + + return Service.of({ resolve, commit }) + }), +) + +export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Git.defaultLayer)) diff --git a/packages/core/src/schema.ts b/packages/core/src/schema.ts index 5b4042c736..523a4eace5 100644 --- a/packages/core/src/schema.ts +++ b/packages/core/src/schema.ts @@ -1,5 +1,11 @@ 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. */ diff --git a/packages/core/src/state.ts b/packages/core/src/state.ts new file mode 100644 index 0000000000..b764699e08 --- /dev/null +++ b/packages/core/src/state.ts @@ -0,0 +1,63 @@ +export * as State from "./state" + +import { Effect, Scope, Semaphore } from "effect" +import { createDraft, finishDraft, type Draft, type Objectish } from "immer" + +export type Transform = (editor: Editor) => void +export type MakeEditor = (draft: Draft) => Editor + +export interface Options { + readonly initial: () => State + readonly editor: MakeEditor + /** Completes every committed edit; reason identifies exceptional update origins. */ + readonly finalize?: (editor: Editor, reason?: string) => Effect.Effect +} + +export interface Interface { + readonly get: () => State + readonly transform: () => Effect.Effect<(transform: Transform) => Effect.Effect, never, Scope.Scope> + readonly update: (update: (editor: Editor) => Effect.Effect, reason?: string) => Effect.Effect +} + +export function create(options: Options): Interface { + let state = options.initial() + let transforms: { update: Transform }[] = [] + const semaphore = Semaphore.makeUnsafe(1) + + const commit = Effect.fn("State.commit")(function* (draft: Draft, reason?: string) { + const api = options.editor(draft) + if (options.finalize) yield* options.finalize(api, reason) + state = finishDraft(draft) as State + }) + + const rebuild = Effect.fn("State.rebuild")(function* () { + const draft = createDraft(options.initial()) + const api = options.editor(draft) + for (const transform of transforms) transform.update(api) + yield* commit(draft) + }, semaphore.withPermit) + + return { + get: () => state, + transform: Effect.fn("State.transform")(function* () { + const transform = { update: (_editor: Editor) => {} } + transforms = [...transforms, transform] + const scope = yield* Scope.Scope + yield* Scope.addFinalizer( + scope, + Effect.sync(() => { + transforms = transforms.filter((item) => item !== transform) + }).pipe(Effect.andThen(rebuild())), + ) + return Effect.fnUntraced(function* (update: Transform) { + transform.update = update + yield* rebuild() + }) + }), + update: Effect.fn("State.update")(function* (update, reason) { + const draft = createDraft(state) + yield* update(options.editor(draft)) + yield* commit(draft, reason) + }, semaphore.withPermit), + } +} diff --git a/packages/core/test/account.test.ts b/packages/core/test/account.test.ts index cf60740b1e..1e50fd340a 100644 --- a/packages/core/test/account.test.ts +++ b/packages/core/test/account.test.ts @@ -19,12 +19,11 @@ const it = testEffect(PluginV2.defaultLayer) function context( records: { provider: ProviderV2.Info; models: Map }[], updates: Array<{ id: ProviderV2.ID; enabled: ProviderV2.Info["enabled"]; apiKey?: string }>, -): Catalog.Context { +): Catalog.Editor { return { - data: records, - updateProvider: (providerID, fn) => context(records, updates).provider.update(providerID, fn), - updateModel: (providerID, modelID, fn) => context(records, updates).model.update(providerID, modelID, fn), provider: { + list: () => records, + get: (providerID) => records.find((item) => item.provider.id === providerID), update: (providerID, fn) => { const record = records.find((item) => item.provider.id === providerID) const provider = produce(record?.provider ?? ProviderV2.Info.empty(providerID), fn) @@ -45,8 +44,13 @@ function context( }, }, model: { + get: () => undefined, update: () => {}, remove: () => {}, + default: { + get: () => undefined, + set: () => {}, + }, }, } } @@ -192,7 +196,7 @@ describe("AccountV2", () => { ] const updates: Array<{ id: ProviderV2.ID; enabled: ProviderV2.Info["enabled"]; apiKey?: string }> = [] const catalog = Catalog.Service.of({ - loader: () => Effect.die("unexpected catalog.loader"), + transform: () => Effect.die("unexpected catalog.transform"), provider: { get: () => Effect.die("unexpected provider.get"), all: () => Effect.succeed([]), @@ -203,7 +207,6 @@ describe("AccountV2", () => { all: () => Effect.succeed([]), available: () => Effect.succeed([]), default: () => Effect.succeed(Option.none()), - setDefault: () => Effect.die("unexpected model.setDefault"), small: () => Effect.succeed(Option.none()), }, }) diff --git a/packages/core/test/agent.test.ts b/packages/core/test/agent.test.ts new file mode 100644 index 0000000000..c59449e938 --- /dev/null +++ b/packages/core/test/agent.test.ts @@ -0,0 +1,101 @@ +import { describe, expect } from "bun:test" +import { Effect, Exit, Scope } from "effect" +import { AgentV2 } from "@opencode-ai/core/agent" +import { testEffect } from "./lib/effect" + +const it = testEffect(AgentV2.defaultLayer) + +describe("AgentV2", () => { + it.effect("starts without agents", () => + Effect.gen(function* () { + const agent = yield* AgentV2.Service + + expect(yield* agent.all()).toEqual([]) + expect(yield* agent.get(AgentV2.ID.make("build"))).toBeUndefined() + }), + ) + + it.effect("materializes replayable agent transforms", () => + Effect.gen(function* () { + const agent = yield* AgentV2.Service + const id = AgentV2.ID.make("reviewer") + const transform = yield* agent.transform() + + yield* transform((editor) => + editor.update(id, (info) => { + info.description = "Reviews code" + info.mode = "subagent" + }), + ) + + expect(yield* agent.get(id)).toMatchObject({ id, description: "Reviews code", mode: "subagent" }) + expect((yield* agent.all()).map((info) => info.id)).toEqual([id]) + }), + ) + + it.effect("rebuilds state when a transform is replaced", () => + Effect.gen(function* () { + const agent = yield* AgentV2.Service + const id = AgentV2.ID.make("reviewer") + const transform = yield* agent.transform() + + yield* transform((editor) => + editor.update(id, (info) => { + info.description = "Old description" + info.hidden = true + }), + ) + yield* transform((editor) => + editor.update(id, (info) => { + info.description = "New description" + }), + ) + + expect(yield* agent.get(id)).toMatchObject({ description: "New description", hidden: false }) + }), + ) + + it.effect("removes a transform contribution when its scope closes", () => + Effect.gen(function* () { + const agent = yield* AgentV2.Service + const id = AgentV2.ID.make("scoped") + const scope = yield* Scope.make() + const transform = yield* agent.transform().pipe(Scope.provide(scope)) + + yield* transform((editor) => editor.update(id, () => {})) + expect(yield* agent.get(id)).toBeDefined() + + yield* Scope.close(scope, Exit.void) + expect(yield* agent.get(id)).toBeUndefined() + }), + ) + + it.effect("applies direct agent updates", () => + Effect.gen(function* () { + const agent = yield* AgentV2.Service + const id = AgentV2.ID.make("build") + + yield* agent.update((editor) => + editor.update(id, (info) => { + info.mode = "primary" + info.hidden = true + }), + ) + + expect(yield* agent.get(id)).toMatchObject({ id, mode: "primary", hidden: true }) + }), + ) + + it.effect("creates agents with runtime defaults and supports direct removal", () => + Effect.gen(function* () { + const agent = yield* AgentV2.Service + const id = AgentV2.ID.make("custom") + + yield* agent.update((editor) => editor.update(id, () => {})) + expect(yield* agent.get(id)).toEqual(AgentV2.Info.empty(id)) + + yield* agent.update((editor) => editor.remove(id)) + expect(yield* agent.get(id)).toBeUndefined() + }), + ) +}) diff --git a/packages/core/test/catalog.test.ts b/packages/core/test/catalog.test.ts index 97f816d005..9736e2a739 100644 --- a/packages/core/test/catalog.test.ts +++ b/packages/core/test/catalog.test.ts @@ -5,14 +5,21 @@ import { EventV2 } from "@opencode-ai/core/event" import { Location } from "@opencode-ai/core/location" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { Policy } from "@opencode-ai/core/policy" import { ProviderV2 } from "@opencode-ai/core/provider" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { location } from "./fixture/location" import { testEffect } from "./lib/effect" -const locationLayer = Layer.succeed(Location.Service, Location.Service.of({ directory: "test" })) +const locationLayer = Layer.succeed( + Location.Service, + Location.Service.of(location({ directory: AbsolutePath.make("test") })), +) const it = testEffect( Catalog.layer.pipe( Layer.provideMerge(EventV2.defaultLayer), Layer.provideMerge(PluginV2.defaultLayer), + Layer.provideMerge(Policy.defaultLayer), Layer.provideMerge(locationLayer), ), ) @@ -22,9 +29,9 @@ describe("CatalogV2", () => { Effect.gen(function* () { const catalog = yield* Catalog.Service const providerID = ProviderV2.ID.make("test") - const load = yield* catalog.loader() + const transform = yield* catalog.transform() - yield* load((catalog) => + yield* transform((catalog) => catalog.provider.update(providerID, (provider) => { provider.endpoint = { type: "aisdk", @@ -48,9 +55,9 @@ describe("CatalogV2", () => { const catalog = yield* Catalog.Service const providerID = ProviderV2.ID.make("test") const modelID = ModelV2.ID.make("model") - const load = yield* catalog.loader() + const transform = yield* catalog.transform() - yield* load((catalog) => { + yield* transform((catalog) => { catalog.provider.update(providerID, (provider) => { provider.endpoint = { type: "aisdk", @@ -77,9 +84,9 @@ describe("CatalogV2", () => { const catalog = yield* Catalog.Service const providerID = ProviderV2.ID.make("test") const modelID = ModelV2.ID.make("model") - const load = yield* catalog.loader() + const transform = yield* catalog.transform() - yield* load((catalog) => { + yield* transform((catalog) => { catalog.provider.update(providerID, (provider) => { provider.endpoint = { type: "aisdk", @@ -104,14 +111,14 @@ describe("CatalogV2", () => { const plugin = yield* PluginV2.Service const providerID = ProviderV2.ID.make("test") const seen: unknown[] = [] - const load = yield* catalog.loader() + const transform = yield* catalog.transform() yield* plugin.add({ id: PluginV2.ID.make("test"), effect: Effect.succeed({ "catalog.transform": (evt) => Effect.sync(() => { - const item = evt.data.find((record) => record.provider.id === providerID) + const item = evt.provider.get(providerID) if (!item) return seen.push(item.provider.endpoint.type) if (item?.provider.endpoint.type === "aisdk") seen.push(item.provider.endpoint.url) @@ -119,7 +126,7 @@ describe("CatalogV2", () => { }), }), }) - yield* load((catalog) => + yield* transform((catalog) => catalog.provider.update(providerID, (provider) => { provider.endpoint = { type: "aisdk", package: "@ai-sdk/openai-compatible" } provider.options.aisdk.provider.baseURL = "https://provider.example.com" @@ -135,9 +142,9 @@ describe("CatalogV2", () => { const catalog = yield* Catalog.Service const plugin = yield* PluginV2.Service const providerID = ProviderV2.ID.make("test") - const load = yield* catalog.loader() + const transform = yield* catalog.transform() - yield* load((catalog) => + yield* transform((catalog) => catalog.provider.update(providerID, (provider) => { provider.name = "Before" }), @@ -164,9 +171,9 @@ describe("CatalogV2", () => { const catalog = yield* Catalog.Service const providerID = ProviderV2.ID.make("test") const modelID = ModelV2.ID.make("model") - const load = yield* catalog.loader() + const transform = yield* catalog.transform() - yield* load((catalog) => { + yield* transform((catalog) => { catalog.provider.update(providerID, (provider) => { provider.options.headers.provider = "provider" provider.options.headers.shared = "provider" @@ -194,9 +201,9 @@ describe("CatalogV2", () => { Effect.gen(function* () { const catalog = yield* Catalog.Service const providerID = ProviderV2.ID.make("test") - const load = yield* catalog.loader() + const transform = yield* catalog.transform() - yield* load((catalog) => { + yield* transform((catalog) => { catalog.provider.update(providerID, (provider) => { provider.enabled = { via: "custom", data: {} } }) @@ -212,13 +219,44 @@ describe("CatalogV2", () => { }), ) + it.effect("uses a transform-provided default model until that transform is replaced", () => + Effect.gen(function* () { + const catalog = yield* Catalog.Service + const providerID = ProviderV2.ID.make("test") + const old = ModelV2.ID.make("old") + const newest = ModelV2.ID.make("new") + const transform = yield* catalog.transform() + + const models = (catalog: Catalog.Editor) => { + catalog.provider.update(providerID, (provider) => { + provider.enabled = { via: "custom", data: {} } + }) + catalog.model.update(providerID, old, (model) => { + model.time.released = DateTime.makeUnsafe(1000) + }) + catalog.model.update(providerID, newest, (model) => { + model.time.released = DateTime.makeUnsafe(2000) + }) + } + + yield* transform((catalog) => { + models(catalog) + catalog.model.default.set(providerID, old) + }) + expect(Option.getOrUndefined(yield* catalog.model.default())?.id).toBe(old) + + yield* transform(models) + expect(Option.getOrUndefined(yield* catalog.model.default())?.id).toBe(newest) + }), + ) + it.effect("small model prefers small keyword candidates before cost scoring", () => Effect.gen(function* () { const catalog = yield* Catalog.Service const providerID = ProviderV2.ID.make("test") - const load = yield* catalog.loader() + const transform = yield* catalog.transform() - yield* load((catalog) => { + yield* transform((catalog) => { catalog.provider.update(providerID, () => {}) catalog.model.update(providerID, ModelV2.ID.make("cheap-large"), (model) => { model.capabilities.input = ["text"] @@ -237,4 +275,23 @@ describe("CatalogV2", () => { expect(Option.getOrUndefined(yield* catalog.model.small(providerID))?.id).toMatch("expensive-mini") }), ) + + it.effect("removes providers denied by policy after loading", () => + Effect.gen(function* () { + const catalog = yield* Catalog.Service + const policy = yield* Policy.Service + const providerID = ProviderV2.ID.make("blocked") + const transform = yield* catalog.transform() + + yield* policy.load([new Policy.Info({ effect: "deny", action: "provider.use", resource: "blocked" })]) + yield* transform((catalog) => { + catalog.provider.update(providerID, () => {}) + catalog.model.update(providerID, ModelV2.ID.make("model"), () => {}) + }) + + expect(yield* catalog.provider.all()).toEqual([]) + expect(yield* catalog.model.all()).toEqual([]) + expect(yield* catalog.provider.get(providerID).pipe(Effect.option)).toEqual(Option.none()) + }), + ) }) diff --git a/packages/core/test/config/agent.test.ts b/packages/core/test/config/agent.test.ts new file mode 100644 index 0000000000..0d8562313b --- /dev/null +++ b/packages/core/test/config/agent.test.ts @@ -0,0 +1,186 @@ +import { describe, expect } from "bun:test" +import { Effect, Schema } from "effect" +import { AgentV2 } from "@opencode-ai/core/agent" +import { Config } from "@opencode-ai/core/config" +import { ConfigAgentPlugin } from "@opencode-ai/core/config/plugin/agent" +import { PermissionV2 } from "@opencode-ai/core/permission" +import { testEffect } from "../lib/effect" + +const it = testEffect(AgentV2.defaultLayer) +const decode = Schema.decodeUnknownSync(Config.Info) + +describe("ConfigAgentPlugin.Plugin", () => { + it.effect("applies global permissions between built-in and agent-specific permissions", () => + Effect.gen(function* () { + const agents = yield* AgentV2.Service + const build = AgentV2.ID.make("build") + const defaults = yield* agents.transform() + + yield* defaults((editor) => + editor.update(build, (agent) => { + agent.mode = "primary" + agent.permissions.push({ permission: "bash", pattern: "*", action: "allow" }) + }), + ) + + const config = Config.Service.of({ + directories: () => Effect.succeed([]), + get: () => + Effect.succeed([ + new Config.Loaded({ + source: { type: "memory" }, + info: decode({ + permissions: [{ permission: "bash", pattern: "*", action: "ask" }], + agents: { + build: { + permissions: [{ permission: "bash", pattern: "git *", action: "allow" }], + }, + reviewer: { + model: "openrouter/openai/gpt-5", + description: "Review changes", + mode: "subagent", + permissions: [{ permission: "edit", pattern: "*", action: "deny" }], + }, + removed: { description: "Removed later" }, + }, + }), + }), + new Config.Loaded({ + source: { type: "memory" }, + info: decode({ + agents: { + reviewer: { variant: "high", hidden: true }, + removed: { disabled: true }, + }, + }), + }), + ]), + }) + + yield* ConfigAgentPlugin.Plugin.effect.pipe( + Effect.provideService(Config.Service, config), + Effect.provideService(AgentV2.Service, agents), + ) + + const buildAgent = yield* agents.get(build) + if (!buildAgent) throw new Error("expected configured build agent") + expect(buildAgent.permissions).toEqual([ + { permission: "bash", pattern: "*", action: "allow" }, + { permission: "bash", pattern: "*", action: "ask" }, + { permission: "bash", pattern: "git *", action: "allow" }, + ]) + expect(PermissionV2.evaluate("bash", "git status", buildAgent.permissions).action).toBe("allow") + expect(PermissionV2.evaluate("bash", "bun test", buildAgent.permissions).action).toBe("ask") + + const reviewer = yield* agents.get(AgentV2.ID.make("reviewer")) + if (!reviewer) throw new Error("expected configured reviewer agent") + expect(reviewer).toMatchObject({ + description: "Review changes", + mode: "subagent", + hidden: true, + model: { providerID: "openrouter", id: "openai/gpt-5", variant: "high" }, + }) + expect(reviewer.permissions).toEqual([ + { permission: "bash", pattern: "*", action: "ask" }, + { permission: "edit", pattern: "*", action: "deny" }, + ]) + expect(yield* agents.get(AgentV2.ID.make("removed"))).toBeUndefined() + }), + ) + + it.effect("maps configured agent fields and preserves an unspecified model variant", () => + Effect.gen(function* () { + const agents = yield* AgentV2.Service + const config = Config.Service.of({ + directories: () => Effect.succeed([]), + get: () => + Effect.succeed([ + new Config.Loaded({ + source: { type: "memory" }, + info: decode({ + agents: { + reviewer: { + model: "anthropic/claude-sonnet", + system: "Review carefully.", + description: "Reviews changes", + mode: "subagent", + hidden: true, + color: "warning", + steps: 12, + options: { + headers: { first: "one", shared: "first" }, + body: { enabled: true }, + aisdk: { provider: { profile: "review" }, request: { effort: "medium" } }, + }, + }, + }, + }), + }), + new Config.Loaded({ + source: { type: "memory" }, + info: decode({ + agents: { + reviewer: { + options: { + headers: { shared: "last", second: "two" }, + body: { retries: 2 }, + aisdk: { request: { effort: "high" } }, + }, + }, + }, + }), + }), + ]), + }) + + yield* ConfigAgentPlugin.Plugin.effect.pipe( + Effect.provideService(Config.Service, config), + Effect.provideService(AgentV2.Service, agents), + ) + + const reviewer = yield* agents.get(AgentV2.ID.make("reviewer")) + if (!reviewer) throw new Error("expected configured reviewer agent") + expect(reviewer).toMatchObject({ + system: "Review carefully.", + description: "Reviews changes", + mode: "subagent", + hidden: true, + color: "warning", + steps: 12, + model: { providerID: "anthropic", id: "claude-sonnet", variant: undefined }, + }) + expect(reviewer.options).toEqual({ + headers: { first: "one", shared: "last", second: "two" }, + body: { enabled: true, retries: 2 }, + aisdk: { provider: { profile: "review" }, request: { effort: "high" } }, + }) + }), + ) + + it.effect("removes a built-in agent disabled by configuration", () => + Effect.gen(function* () { + const agents = yield* AgentV2.Service + const build = AgentV2.ID.make("build") + const defaults = yield* agents.transform() + yield* defaults((editor) => editor.update(build, () => {})) + + const config = Config.Service.of({ + directories: () => Effect.succeed([]), + get: () => + Effect.succeed([ + new Config.Loaded({ + source: { type: "memory" }, + info: decode({ agents: { build: { disabled: true } } }), + }), + ]), + }) + + yield* ConfigAgentPlugin.Plugin.effect.pipe( + Effect.provideService(Config.Service, config), + Effect.provideService(AgentV2.Service, agents), + ) + + expect(yield* agents.get(build)).toBeUndefined() + }), + ) +}) diff --git a/packages/core/test/config/config.test.ts b/packages/core/test/config/config.test.ts new file mode 100644 index 0000000000..21f9e3e3d1 --- /dev/null +++ b/packages/core/test/config/config.test.ts @@ -0,0 +1,460 @@ +import path from "path" +import fs from "fs/promises" +import { describe, expect } from "bun:test" +import { Effect, Layer } from "effect" +import { Config } from "@opencode-ai/core/config" +import { ConfigProvider } from "@opencode-ai/core/config/provider" +import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { Global } from "@opencode-ai/core/global" +import { Location } from "@opencode-ai/core/location" +import { Policy } from "@opencode-ai/core/policy" +import { Project } from "@opencode-ai/core/project" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { location } from "../fixture/location" +import { tmpdir } from "../fixture/tmpdir" +import { testEffect } from "../lib/effect" + +const it = testEffect(Layer.empty) + +function testLayer( + directory: string, + globalDirectory = path.join(directory, "global"), + projectDirectory = directory, + vcs?: Project.Vcs, +) { + return Config.layer.pipe( + Layer.provide(AppFileSystem.defaultLayer), + Layer.provide(Global.layerWith({ config: globalDirectory })), + Layer.provideMerge(Policy.defaultLayer), + Layer.provide( + Layer.succeed( + Location.Service, + Location.Service.of( + location( + { directory: AbsolutePath.make(directory) }, + { projectDirectory: AbsolutePath.make(projectDirectory), vcs }, + ), + ), + ), + ), + ) +} + +const provider = { + endpoint: { type: "unknown" }, + options: { + headers: {}, + body: {}, + aisdk: { + provider: {}, + request: {}, + }, + }, + models: {}, +} + +describe("Config", () => { + it.live("returns an empty configuration when directory files do not exist", () => + Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ).pipe( + Effect.flatMap((tmp) => + Effect.gen(function* () { + const config = yield* Config.Service + const documents = yield* config.get() + + expect(documents).toEqual([]) + }).pipe(Effect.provide(testLayer(tmp.path))), + ), + ), + ) + + it.live("loads JSON and JSONC files from lowest to highest priority", () => + Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ).pipe( + Effect.flatMap((tmp) => + Effect.gen(function* () { + yield* Effect.promise(() => + Promise.all([ + fs.writeFile( + path.join(tmp.path, "config.json"), + JSON.stringify({ $schema: "base", providers: { base: provider } }), + ), + fs.writeFile( + path.join(tmp.path, "opencode.json"), + JSON.stringify({ $schema: "middle", providers: { middle: provider } }), + ), + fs.writeFile( + path.join(tmp.path, "opencode.jsonc"), + `{ + // Later global files override scalar fields while retaining providers. + "$schema": "last", + "providers": { "last": ${JSON.stringify(provider)} }, + }`, + ), + ]), + ) + return yield* Effect.gen(function* () { + const config = yield* Config.Service + const documents = yield* config.get() + + expect(documents).toHaveLength(3) + expect(documents.map((document) => document.source.type)).toEqual(["file", "file", "file"]) + expect(documents.map((document) => document.info.$schema)).toEqual(["base", "middle", "last"]) + expect(documents[0]).toBeInstanceOf(Config.Loaded) + expect(documents[0]?.source.type === "file" ? documents[0].source.path : undefined).toBe( + path.join(tmp.path, "config.json"), + ) + expect(documents[2]?.info.providers?.last).toBeInstanceOf(ConfigProvider.Info) + + yield* Effect.promise(() => + fs.writeFile(path.join(tmp.path, "opencode.jsonc"), JSON.stringify({ $schema: "changed" })), + ) + expect((yield* config.get()).map((document) => document.info.$schema)).toEqual(["base", "middle", "last"]) + }).pipe(Effect.provide(testLayer(tmp.path))) + }), + ), + ), + ) + + it.live("accepts $schema metadata without writing it into config files", () => + Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ).pipe( + Effect.flatMap((tmp) => + Effect.gen(function* () { + const file = path.join(tmp.path, "opencode.json") + const contents = JSON.stringify({ + shell: "/bin/zsh", + experimental: { policies: [{ effect: "deny", action: "provider.use", resource: "openai" }] }, + providers: { local: provider }, + }) + yield* Effect.promise(() => fs.writeFile(file, contents)) + + return yield* Effect.gen(function* () { + const config = yield* Config.Service + const documents = yield* config.get() + + expect(documents[0]?.info.$schema).toBeUndefined() + expect(documents[0]?.info.shell).toBe("/bin/zsh") + expect(documents[0]?.info.experimental?.policies?.[0]).toEqual({ + effect: "deny", + action: "provider.use", + resource: "openai", + }) + expect(yield* Effect.promise(() => fs.readFile(file, "utf8"))).toBe(contents) + }).pipe(Effect.provide(testLayer(tmp.path))) + }), + ), + ), + ) + + it.live("loads supported scalar and resource configuration", () => + Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ).pipe( + Effect.flatMap((tmp) => + Effect.gen(function* () { + yield* Effect.promise(() => + fs.writeFile( + path.join(tmp.path, "opencode.json"), + JSON.stringify({ + shell: "/bin/bash", + model: "anthropic/claude", + autoupdate: "notify", + share: "disabled", + enterprise: { url: "https://share.example.com" }, + username: "test-user", + permissions: [ + { permission: "bash", pattern: "*", action: "ask" }, + { permission: "bash", pattern: "git status", action: "allow" }, + ], + agents: { + reviewer: { + model: "openrouter/openai/gpt-5", + variant: "high", + options: { + headers: { "x-agent": "reviewer" }, + aisdk: { request: { reasoningEffort: "high" } }, + }, + description: "Review changes for correctness", + system: "Find regressions.", + mode: "subagent", + hidden: false, + color: "warning", + steps: 12, + disabled: false, + permissions: [{ permission: "edit", pattern: "*", action: "deny" }], + }, + }, + snapshots: false, + watcher: { ignore: ["node_modules/**", "dist/**", ".git"] }, + formatter: { + prettier: { disabled: true }, + custom: { command: ["custom-fmt", "$FILE"], extensions: [".foo"] }, + }, + lsp: { typescript: { disabled: true }, custom: { command: ["custom-lsp"], extensions: [".foo"] } }, + attachments: { + image: { auto_resize: false, max_width: 1200, max_height: 900, max_base64_bytes: 1048576 }, + }, + tool_output: { max_lines: 1000, max_bytes: 32768 }, + mcp: { + timeout: 5000, + servers: { + local: { + type: "local", + command: ["node", "./mcp/server.js"], + environment: { API_KEY: "secret" }, + disabled: false, + timeout: 10000, + }, + remote: { + type: "remote", + url: "https://mcp.example.com/mcp", + headers: { Authorization: "Bearer token" }, + oauth: { client_id: "client", scope: "read write", callback_port: 19876 }, + disabled: true, + }, + }, + }, + compaction: { + auto: true, + prune: false, + keep: { turns: 3, tokens: 2000 }, + buffer: 10000, + }, + skills: ["./skills", "~/shared-skills", "https://example.com/.well-known/skills/"], + instructions: ["CONTRIBUTING.md", ".cursor/rules/*.md", "https://example.com/shared-rules.md"], + references: { + local: { path: "../library" }, + sdk: { repository: "github.com/example/sdk", branch: "main" }, + shorthand: "github.com/example/docs", + }, + plugins: [ + "opencode-helicone-session", + { package: "@my-org/audit-plugin", options: { endpoint: "https://audit.example.com" } }, + ], + }), + ), + ) + + return yield* Effect.gen(function* () { + const config = yield* Config.Service + const documents = yield* config.get() + + expect(documents).toHaveLength(1) + expect(documents[0]?.info.shell).toBe("/bin/bash") + expect(documents[0]?.info.model).toBe("anthropic/claude") + expect(documents[0]?.info.autoupdate).toBe("notify") + expect(documents[0]?.info.share).toBe("disabled") + expect(documents[0]?.info.enterprise).toEqual({ url: "https://share.example.com" }) + expect(documents[0]?.info.username).toBe("test-user") + expect(documents[0]?.info.permissions).toEqual([ + { permission: "bash", pattern: "*", action: "ask" }, + { permission: "bash", pattern: "git status", action: "allow" }, + ]) + expect(documents[0]?.info.agents?.reviewer).toEqual({ + model: "openrouter/openai/gpt-5", + variant: "high", + options: { + headers: { "x-agent": "reviewer" }, + aisdk: { request: { reasoningEffort: "high" } }, + }, + description: "Review changes for correctness", + system: "Find regressions.", + mode: "subagent", + hidden: false, + color: "warning", + steps: 12, + disabled: false, + permissions: [{ permission: "edit", pattern: "*", action: "deny" }], + }) + expect(documents[0]?.info.snapshots).toBe(false) + expect(documents[0]?.info.watcher).toEqual({ ignore: ["node_modules/**", "dist/**", ".git"] }) + expect(documents[0]?.info.formatter).toEqual({ + prettier: { disabled: true }, + custom: { command: ["custom-fmt", "$FILE"], extensions: [".foo"] }, + }) + expect(documents[0]?.info.lsp).toEqual({ + typescript: { disabled: true }, + custom: { command: ["custom-lsp"], extensions: [".foo"] }, + }) + expect(documents[0]?.info.attachments).toEqual({ + image: { auto_resize: false, max_width: 1200, max_height: 900, max_base64_bytes: 1048576 }, + }) + expect(documents[0]?.info.tool_output).toEqual({ max_lines: 1000, max_bytes: 32768 }) + expect(documents[0]?.info.mcp).toEqual({ + timeout: 5000, + servers: { + local: { + type: "local", + command: ["node", "./mcp/server.js"], + environment: { API_KEY: "secret" }, + disabled: false, + timeout: 10000, + }, + remote: { + type: "remote", + url: "https://mcp.example.com/mcp", + headers: { Authorization: "Bearer token" }, + oauth: { client_id: "client", scope: "read write", callback_port: 19876 }, + disabled: true, + }, + }, + }) + expect(documents[0]?.info.compaction).toEqual({ + auto: true, + prune: false, + keep: { turns: 3, tokens: 2000 }, + buffer: 10000, + }) + expect(documents[0]?.info.skills).toEqual([ + "./skills", + "~/shared-skills", + "https://example.com/.well-known/skills/", + ]) + expect(documents[0]?.info.instructions).toEqual([ + "CONTRIBUTING.md", + ".cursor/rules/*.md", + "https://example.com/shared-rules.md", + ]) + expect(documents[0]?.info.references).toEqual({ + local: { path: "../library" }, + sdk: { repository: "github.com/example/sdk", branch: "main" }, + shorthand: "github.com/example/docs", + }) + expect(documents[0]?.info.plugins).toEqual([ + "opencode-helicone-session", + { package: "@my-org/audit-plugin", options: { endpoint: "https://audit.example.com" } }, + ]) + }).pipe(Effect.provide(testLayer(tmp.path))) + }), + ), + ), + ) + + it.live("ignores invalid files while loading valid config values", () => + Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ).pipe( + Effect.flatMap((tmp) => + Effect.gen(function* () { + yield* Effect.promise(() => + Promise.all([ + fs.writeFile(path.join(tmp.path, "config.json"), JSON.stringify({ $schema: "base" })), + fs.writeFile(path.join(tmp.path, "opencode.json"), "{ invalid"), + fs.writeFile(path.join(tmp.path, "opencode.jsonc"), JSON.stringify({ providers: { invalid: true } })), + ]), + ) + return yield* Effect.gen(function* () { + const config = yield* Config.Service + const documents = yield* config.get() + + expect(documents.map((document) => document.info.$schema)).toEqual(["base"]) + }).pipe(Effect.provide(testLayer(tmp.path))) + }), + ), + ), + ) + + it.live("loads policy statements in reverse config order", () => + Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ).pipe( + Effect.flatMap((tmp) => { + const global = path.join(tmp.path, "global") + return Effect.gen(function* () { + yield* Effect.promise(async () => { + await fs.mkdir(global, { recursive: true }) + await fs.writeFile( + path.join(global, "opencode.json"), + JSON.stringify({ + experimental: { policies: [{ effect: "deny", action: "provider.use", resource: "openai" }] }, + }), + ) + await fs.writeFile( + path.join(tmp.path, "opencode.json"), + JSON.stringify({ + experimental: { policies: [{ effect: "allow", action: "provider.use", resource: "openai" }] }, + }), + ) + }) + + return yield* Effect.gen(function* () { + const policy = yield* Policy.Service + + expect(yield* policy.evaluate("provider.use", "openai", "allow")).toBe("deny") + }).pipe(Effect.provide(testLayer(tmp.path, global))) + }) + }), + ), + ) + + it.live("loads global, ancestor, and .opencode configuration up to the project boundary", () => + Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ).pipe( + Effect.flatMap((tmp) => { + const global = path.join(tmp.path, "global") + const root = path.join(tmp.path, "repo") + const parent = path.join(root, "packages") + const directory = path.join(parent, "app") + return Effect.gen(function* () { + yield* Effect.promise(async () => { + await fs.mkdir(global, { recursive: true }) + await fs.mkdir(directory, { recursive: true }) + await fs.mkdir(path.join(root, ".opencode"), { recursive: true }) + await fs.mkdir(path.join(directory, ".opencode"), { recursive: true }) + await Promise.all([ + fs.writeFile(path.join(tmp.path, "opencode.json"), JSON.stringify({ $schema: "outside" })), + fs.writeFile(path.join(global, "opencode.json"), JSON.stringify({ $schema: "global" })), + fs.writeFile(path.join(root, "opencode.json"), JSON.stringify({ $schema: "root" })), + fs.writeFile(path.join(parent, "opencode.jsonc"), JSON.stringify({ $schema: "parent" })), + fs.writeFile(path.join(directory, "config.json"), JSON.stringify({ $schema: "directory" })), + fs.writeFile(path.join(root, ".opencode", "opencode.json"), JSON.stringify({ $schema: "root-dot" })), + fs.writeFile( + path.join(directory, ".opencode", "opencode.jsonc"), + JSON.stringify({ $schema: "directory-dot" }), + ), + ]) + }) + + return yield* Effect.gen(function* () { + const config = yield* Config.Service + const directories = yield* config.directories() + const documents = yield* config.get() + + expect(directories).toEqual([ + AbsolutePath.make(global), + AbsolutePath.make(path.join(root, ".opencode")), + AbsolutePath.make(path.join(directory, ".opencode")), + ]) + expect(documents.map((document) => document.info.$schema)).toEqual([ + "global", + "root", + "parent", + "directory", + "root-dot", + "directory-dot", + ]) + }).pipe( + Effect.provide( + testLayer(directory, global, root, { + type: "git", + store: AbsolutePath.make(path.join(root, ".git")), + }), + ), + ) + }) + }), + ), + ) +}) diff --git a/packages/core/test/config/provider.test.ts b/packages/core/test/config/provider.test.ts new file mode 100644 index 0000000000..01b8c9c9aa --- /dev/null +++ b/packages/core/test/config/provider.test.ts @@ -0,0 +1,131 @@ +import { describe, expect } from "bun:test" +import { Effect, Schema } from "effect" +import { Catalog } from "@opencode-ai/core/catalog" +import { Config } from "@opencode-ai/core/config" +import { ConfigProviderPlugin } from "@opencode-ai/core/config/plugin/provider" +import { ModelV2 } from "@opencode-ai/core/model" +import { PluginV2 } from "@opencode-ai/core/plugin" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { it } from "../plugin/provider-helper" + +function options(headers: Record, variant?: string) { + return { + headers, + variant, + } +} + +const decode = Schema.decodeUnknownSync(Config.Info) + +describe("ConfigProviderPlugin.Plugin", () => { + it.effect("loads configured providers and applies later model overrides", () => + Effect.gen(function* () { + const catalog = yield* Catalog.Service + const plugin = yield* PluginV2.Service + const providerID = ProviderV2.ID.make("custom") + const modelID = ModelV2.ID.make("chat") + const config = Config.Service.of({ + directories: () => Effect.succeed([]), + get: () => + Effect.succeed([ + new Config.Loaded({ + source: { type: "memory" }, + info: decode({ + providers: { + custom: { + name: "Configured", + env: ["CUSTOM_API_KEY"], + endpoint: { type: "unknown" }, + options: options({ first: "first", shared: "first" }), + models: { + chat: { + name: "First", + capabilities: { tools: true, input: ["text"], output: ["text"] }, + disabled: true, + limit: { context: 100, output: 50 }, + cost: { input: 1, output: 2 }, + options: options({ first: "first", shared: "first" }, "retained"), + variants: [ + { + id: "fast", + headers: { first: "first", shared: "first" }, + }, + ], + }, + }, + }, + }, + }), + }), + new Config.Loaded({ + source: { type: "memory" }, + info: decode({ + providers: { + custom: { + endpoint: { type: "aisdk", package: "custom-sdk", url: "https://example.test" }, + options: options({ last: "last", shared: "last" }), + models: { + chat: { + api_id: "api-chat", + name: "Last", + limit: { output: 75 }, + options: options({ last: "last", shared: "last" }), + variants: [ + { + id: "fast", + headers: { last: "last", shared: "last" }, + }, + { + id: "slow", + headers: { slow: "slow" }, + }, + ], + }, + }, + }, + }, + }), + }), + new Config.Loaded({ + source: { type: "memory" }, + info: decode({ + providers: { + custom: { name: "Renamed" }, + }, + }), + }), + ]), + }) + + yield* plugin.add({ + ...ConfigProviderPlugin.Plugin, + effect: ConfigProviderPlugin.Plugin.effect.pipe( + Effect.provideService(Config.Service, config), + Effect.provideService(Catalog.Service, catalog), + ), + }) + + const provider = yield* catalog.provider.get(providerID) + const model = yield* catalog.model.get(providerID, modelID) + expect(provider.name).toBe("Renamed") + expect(provider.env).toEqual(["CUSTOM_API_KEY"]) + expect(provider.enabled).toEqual({ via: "custom", data: {} }) + expect(provider.endpoint).toEqual({ type: "aisdk", package: "custom-sdk", url: "https://example.test" }) + expect(provider.options.headers).toEqual({ first: "first", shared: "last", last: "last" }) + expect(model.apiID).toBe(ModelV2.ID.make("api-chat")) + expect(model.name).toBe("Last") + expect(model.capabilities).toEqual({ tools: true, input: ["text"], output: ["text"] }) + expect(model.enabled).toBe(false) + expect(model.limit).toEqual({ context: 100, output: 75 }) + expect(model.cost).toEqual([{ input: 1, output: 2, cache: { read: 0, write: 0 }, tier: undefined }]) + expect(model.options.headers).toEqual({ first: "first", shared: "last", last: "last" }) + expect(model.options.variant).toBe("retained") + expect(model.variants.map((variant) => variant.id)).toEqual([ + ModelV2.VariantID.make("fast"), + ModelV2.VariantID.make("slow"), + ]) + expect(model.variants[0]?.headers).toEqual({ first: "first", shared: "last", last: "last" }) + expect(model.variants[1]?.headers).toEqual({ slow: "slow" }) + }), + ) +}) diff --git a/packages/core/test/event.test.ts b/packages/core/test/event.test.ts index b67b2897a1..f5cce54de4 100644 --- a/packages/core/test/event.test.ts +++ b/packages/core/test/event.test.ts @@ -2,11 +2,13 @@ import { describe, expect } from "bun:test" import { Effect, Fiber, Layer, Schema, Stream } from "effect" import { EventV2 } from "@opencode-ai/core/event" import { Location } from "@opencode-ai/core/location" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { location } from "./fixture/location" import { testEffect } from "./lib/effect" const locationLayer = Layer.succeed( Location.Service, - Location.Service.of({ directory: "project", workspaceID: "workspace" }), + Location.Service.of(location({ directory: AbsolutePath.make("project"), workspaceID: "workspace" })), ) const it = testEffect(EventV2.layer.pipe(Layer.provideMerge(locationLayer))) const itWithoutLocation = testEffect(EventV2.layer) @@ -46,7 +48,7 @@ describe("EventV2", () => { expect(event.type).toBe("test.message") expect(event).not.toHaveProperty("version") expect(event.data).toEqual({ text: "hello" }) - expect(event.location).toEqual({ directory: "project", workspaceID: "workspace" }) + expect(event.location).toEqual({ directory: AbsolutePath.make("project"), workspaceID: "workspace" }) }), ) diff --git a/packages/core/test/fixture/location.ts b/packages/core/test/fixture/location.ts new file mode 100644 index 0000000000..00b3ffbd13 --- /dev/null +++ b/packages/core/test/fixture/location.ts @@ -0,0 +1,12 @@ +import { Location } from "@opencode-ai/core/location" +import { Project } from "@opencode-ai/core/project" +import { AbsolutePath } from "@opencode-ai/core/schema" + +export function location(ref: Location.Ref, input: { projectDirectory?: AbsolutePath; vcs?: Project.Vcs } = {}) { + return { + directory: ref.directory, + workspaceID: ref.workspaceID, + project: { id: Project.ID.global, directory: input.projectDirectory ?? ref.directory }, + vcs: input.vcs, + } satisfies Location.Interface +} diff --git a/packages/core/test/location.test.ts b/packages/core/test/location.test.ts new file mode 100644 index 0000000000..305083bfed --- /dev/null +++ b/packages/core/test/location.test.ts @@ -0,0 +1,38 @@ +import { describe, expect } from "bun:test" +import { Effect, Layer } from "effect" +import { Location } from "@opencode-ai/core/location" +import { Project } from "@opencode-ai/core/project" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { testEffect } from "./lib/effect" + +const ref = { directory: AbsolutePath.make("/repo/packages/app"), workspaceID: "workspace" } +const projectLayer = Layer.succeed( + Project.Service, + Project.Service.of({ + resolve: () => + Effect.succeed({ + id: Project.ID.make("project"), + directory: AbsolutePath.make("/repo"), + vcs: { type: "git", store: AbsolutePath.make("/repo/.git") }, + }), + commit: () => Effect.void, + }), +) +const it = testEffect(Location.layer(ref).pipe(Layer.provide(projectLayer))) + +describe("Location", () => { + it.effect("resolves the current project and vcs information", () => + Effect.gen(function* () { + const location = yield* Location.Service + + expect(location.directory).toBe(AbsolutePath.make("/repo/packages/app")) + expect(location.workspaceID).toBe("workspace") + expect(location.project.id).toBe(Project.ID.make("project")) + expect(location.project.directory).toBe(AbsolutePath.make("/repo")) + expect(location.vcs).toEqual({ + type: "git", + store: AbsolutePath.make("/repo/.git"), + }) + }), + ) +}) diff --git a/packages/core/test/model.test.ts b/packages/core/test/model.test.ts new file mode 100644 index 0000000000..fe97acc25a --- /dev/null +++ b/packages/core/test/model.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, test } from "bun:test" +import { Schema } from "effect" +import { ModelV2 } from "@opencode-ai/core/model" +import { ProviderV2 } from "@opencode-ai/core/provider" + +const decode = Schema.decodeUnknownSync(ModelV2.Ref) + +describe("ModelV2.Ref", () => { + test("accepts a model selection without a variant", () => { + expect(decode({ id: "claude-sonnet", providerID: "anthropic" })).toEqual({ + id: ModelV2.ID.make("claude-sonnet"), + providerID: ProviderV2.ID.make("anthropic"), + }) + }) + + test("preserves an explicit model variant", () => { + expect(decode({ id: "claude-sonnet", providerID: "anthropic", variant: "high" })).toEqual({ + id: ModelV2.ID.make("claude-sonnet"), + providerID: ProviderV2.ID.make("anthropic"), + variant: ModelV2.VariantID.make("high"), + }) + }) +}) diff --git a/packages/core/test/plugin/provider-amazon-bedrock.test.ts b/packages/core/test/plugin/provider-amazon-bedrock.test.ts index f2034eb1e3..602e85624b 100644 --- a/packages/core/test/plugin/provider-amazon-bedrock.test.ts +++ b/packages/core/test/plugin/provider-amazon-bedrock.test.ts @@ -24,8 +24,8 @@ describe("AmazonBedrockPlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(AmazonBedrockPlugin) - const load = yield* catalog.loader() - yield* load((catalog) => { + const transform = yield* catalog.transform() + yield* transform((catalog) => { const bedrock = provider("amazon-bedrock", { endpoint: { type: "aisdk", package: "@ai-sdk/amazon-bedrock" }, options: { diff --git a/packages/core/test/plugin/provider-anthropic.test.ts b/packages/core/test/plugin/provider-anthropic.test.ts index 0a1d5662d3..6cae612fd2 100644 --- a/packages/core/test/plugin/provider-anthropic.test.ts +++ b/packages/core/test/plugin/provider-anthropic.test.ts @@ -12,8 +12,8 @@ describe("AnthropicPlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(AnthropicPlugin) - const load = yield* catalog.loader() - yield* load((catalog) => { + const transform = yield* catalog.transform() + yield* transform((catalog) => { const item = provider("anthropic", { endpoint: { type: "aisdk", package: "@ai-sdk/anthropic" }, options: { headers: { Existing: "1" }, body: {}, aisdk: { provider: {}, request: {} } }, @@ -35,8 +35,8 @@ describe("AnthropicPlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(AnthropicPlugin) - const load = yield* catalog.loader() - yield* load((catalog) => catalog.provider.update(provider("openai").id, () => {})) + const transform = yield* catalog.transform() + yield* transform((catalog) => catalog.provider.update(provider("openai").id, () => {})) expect((yield* catalog.provider.get(ProviderV2.ID.openai)).options.headers["anthropic-beta"]).toBeUndefined() }), ) diff --git a/packages/core/test/plugin/provider-azure-cognitive-services.test.ts b/packages/core/test/plugin/provider-azure-cognitive-services.test.ts index 8b8baacb7e..a3837a66ad 100644 --- a/packages/core/test/plugin/provider-azure-cognitive-services.test.ts +++ b/packages/core/test/plugin/provider-azure-cognitive-services.test.ts @@ -13,8 +13,8 @@ describe("AzureCognitiveServicesPlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(AzureCognitiveServicesPlugin) - const load = yield* catalog.loader() - yield* load((catalog) => { + const transform = yield* catalog.transform() + yield* transform((catalog) => { catalog.provider.update(ProviderV2.ID.make("azure-cognitive-services"), (item) => { item.endpoint = { type: "aisdk", package: "@ai-sdk/openai-compatible" } }) @@ -37,8 +37,8 @@ describe("AzureCognitiveServicesPlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(AzureCognitiveServicesPlugin) - const load = yield* catalog.loader() - yield* load((catalog) => { + const transform = yield* catalog.transform() + yield* transform((catalog) => { const azure = provider("azure-cognitive-services", { endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible" }, }) diff --git a/packages/core/test/plugin/provider-azure.test.ts b/packages/core/test/plugin/provider-azure.test.ts index 8c8995a372..18670d6901 100644 --- a/packages/core/test/plugin/provider-azure.test.ts +++ b/packages/core/test/plugin/provider-azure.test.ts @@ -5,9 +5,12 @@ import { Catalog } from "@opencode-ai/core/catalog" import { EventV2 } from "@opencode-ai/core/event" import { Location } from "@opencode-ai/core/location" import { PluginV2 } from "@opencode-ai/core/plugin" +import { Policy } from "@opencode-ai/core/policy" import { AccountPlugin } from "@opencode-ai/core/plugin/account" import { AzurePlugin } from "@opencode-ai/core/plugin/provider/azure" import { ProviderV2 } from "@opencode-ai/core/provider" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { location } from "../fixture/location" import { testEffect } from "../lib/effect" import { fakeSelectorSdk, it, model, npmLayer, provider, withEnv } from "./provider-helper" @@ -16,7 +19,10 @@ const itWithAccount = testEffect( Layer.provideMerge(PluginV2.defaultLayer), Layer.provideMerge(AccountV2.defaultLayer), Layer.provideMerge(EventV2.defaultLayer), - Layer.provideMerge(Layer.succeed(Location.Service, Location.Service.of({ directory: "test" }))), + Layer.provide(Policy.defaultLayer), + Layer.provideMerge( + Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("test") }))), + ), Layer.provideMerge(npmLayer), ), ) @@ -28,8 +34,8 @@ describe("AzurePlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(AzurePlugin) - const load = yield* catalog.loader() - yield* load((catalog) => { + const transform = yield* catalog.transform() + yield* transform((catalog) => { catalog.provider.update(ProviderV2.ID.azure, (item) => { item.endpoint = { type: "aisdk", package: "@ai-sdk/azure" } }) @@ -45,8 +51,8 @@ describe("AzurePlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(AzurePlugin) - const load = yield* catalog.loader() - yield* load((catalog) => { + const transform = yield* catalog.transform() + yield* transform((catalog) => { const azure = provider("azure", { endpoint: { type: "aisdk", package: "@ai-sdk/azure" }, options: { headers: {}, body: {}, aisdk: { provider: { resourceName: "from-config" }, request: {} } }, @@ -94,8 +100,8 @@ describe("AzurePlugin", () => { ), }) yield* plugin.add(AzurePlugin) - const load = yield* catalog.loader() - yield* load((catalog) => { + const transform = yield* catalog.transform() + yield* transform((catalog) => { catalog.provider.update(ProviderV2.ID.azure, (item) => { item.endpoint = { type: "aisdk", package: "@ai-sdk/azure" } }) @@ -113,8 +119,8 @@ describe("AzurePlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(AzurePlugin) - const load = yield* catalog.loader() - yield* load((catalog) => { + const transform = yield* catalog.transform() + yield* transform((catalog) => { const azure = provider("azure", { endpoint: { type: "aisdk", package: "@ai-sdk/azure" }, options: { headers: {}, body: {}, aisdk: { provider: { resourceName: "" }, request: {} } }, @@ -135,8 +141,8 @@ describe("AzurePlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(AzurePlugin) - const load = yield* catalog.loader() - yield* load((catalog) => { + const transform = yield* catalog.transform() + yield* transform((catalog) => { const azure = provider("azure", { endpoint: { type: "aisdk", package: "@ai-sdk/azure" }, options: { headers: {}, body: {}, aisdk: { provider: { resourceName: " " }, request: {} } }, diff --git a/packages/core/test/plugin/provider-cerebras.test.ts b/packages/core/test/plugin/provider-cerebras.test.ts index dd8d34c7df..982b587a71 100644 --- a/packages/core/test/plugin/provider-cerebras.test.ts +++ b/packages/core/test/plugin/provider-cerebras.test.ts @@ -24,8 +24,8 @@ describe("CerebrasPlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(CerebrasPlugin) - const load = yield* catalog.loader() - yield* load((catalog) => { + const transform = yield* catalog.transform() + yield* transform((catalog) => { catalog.provider.update(ProviderV2.ID.make("cerebras"), (item) => { item.endpoint = { type: "aisdk", package: "@ai-sdk/cerebras" } item.options.headers.Existing = "1" @@ -43,8 +43,8 @@ describe("CerebrasPlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(CerebrasPlugin) - const load = yield* catalog.loader() - yield* load((catalog) => catalog.provider.update(ProviderV2.ID.make("groq"), () => {})) + const transform = yield* catalog.transform() + yield* transform((catalog) => catalog.provider.update(ProviderV2.ID.make("groq"), () => {})) expect((yield* catalog.provider.get(ProviderV2.ID.make("groq"))).options.headers).toEqual({}) }), ) diff --git a/packages/core/test/plugin/provider-cloudflare-workers-ai.test.ts b/packages/core/test/plugin/provider-cloudflare-workers-ai.test.ts index d1db7b27a1..e6c6618599 100644 --- a/packages/core/test/plugin/provider-cloudflare-workers-ai.test.ts +++ b/packages/core/test/plugin/provider-cloudflare-workers-ai.test.ts @@ -6,9 +6,12 @@ import { Location } from "@opencode-ai/core/location" import { EventV2 } from "@opencode-ai/core/event" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { Policy } from "@opencode-ai/core/policy" import { AccountPlugin } from "@opencode-ai/core/plugin/account" import { CloudflareWorkersAIPlugin } from "@opencode-ai/core/plugin/provider/cloudflare-workers-ai" import { ProviderV2 } from "@opencode-ai/core/provider" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { location } from "../fixture/location" import { testEffect } from "../lib/effect" import { fakeSelectorSdk, it, model, npmLayer, withEnv } from "./provider-helper" @@ -17,7 +20,10 @@ const itWithAccount = testEffect( Layer.provideMerge(PluginV2.defaultLayer), Layer.provideMerge(AccountV2.defaultLayer), Layer.provideMerge(EventV2.defaultLayer), - Layer.provideMerge(Layer.succeed(Location.Service, Location.Service.of({ directory: "test" }))), + Layer.provide(Policy.defaultLayer), + Layer.provideMerge( + Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("test") }))), + ), Layer.provideMerge(npmLayer), ), ) @@ -48,8 +54,8 @@ describe("CloudflareWorkersAIPlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(CloudflareWorkersAIPlugin) - const load = yield* catalog.loader() - yield* load((catalog) => + const transform = yield* catalog.transform() + yield* transform((catalog) => catalog.provider.update(ProviderV2.ID.make("cloudflare-workers-ai"), (provider) => { provider.endpoint = { type: "aisdk", package: "test-provider" } }), @@ -80,8 +86,8 @@ describe("CloudflareWorkersAIPlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(CloudflareWorkersAIPlugin) - const load = yield* catalog.loader() - yield* load((catalog) => + const transform = yield* catalog.transform() + yield* transform((catalog) => catalog.provider.update(ProviderV2.ID.make("cloudflare-workers-ai"), (provider) => { provider.endpoint = { type: "aisdk", package: "test-provider", url: "https://proxy.example/v1" } }), @@ -146,8 +152,8 @@ describe("CloudflareWorkersAIPlugin", () => { ), }) yield* plugin.add(CloudflareWorkersAIPlugin) - const load = yield* catalog.loader() - yield* load((catalog) => + const transform = yield* catalog.transform() + yield* transform((catalog) => catalog.provider.update(ProviderV2.ID.make("cloudflare-workers-ai"), (provider) => { provider.endpoint = { type: "aisdk", package: "test-provider" } }), @@ -167,8 +173,8 @@ describe("CloudflareWorkersAIPlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(CloudflareWorkersAIPlugin) - const load = yield* catalog.loader() - yield* load((catalog) => + const transform = yield* catalog.transform() + yield* transform((catalog) => catalog.provider.update(ProviderV2.ID.make("cloudflare-workers-ai"), (provider) => { provider.endpoint = { type: "aisdk", package: "test-provider" } provider.options.aisdk.provider.accountId = "configured-acct" diff --git a/packages/core/test/plugin/provider-github-copilot.test.ts b/packages/core/test/plugin/provider-github-copilot.test.ts index 51822c26ac..c07f70597a 100644 --- a/packages/core/test/plugin/provider-github-copilot.test.ts +++ b/packages/core/test/plugin/provider-github-copilot.test.ts @@ -152,8 +152,8 @@ describe("GithubCopilotPlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(GithubCopilotPlugin) - const load = yield* catalog.loader() - yield* load((catalog) => { + const transform = yield* catalog.transform() + yield* transform((catalog) => { catalog.provider.update(ProviderV2.ID.make("github-copilot"), () => {}) catalog.model.update(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5-chat-latest"), () => {}) }) @@ -168,8 +168,8 @@ describe("GithubCopilotPlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(GithubCopilotPlugin) - const load = yield* catalog.loader() - yield* load((catalog) => { + const transform = yield* catalog.transform() + yield* transform((catalog) => { catalog.provider.update(ProviderV2.ID.make("custom-copilot"), () => {}) catalog.model.update(ProviderV2.ID.make("custom-copilot"), ModelV2.ID.make("gpt-5-chat-latest"), () => {}) }) diff --git a/packages/core/test/plugin/provider-gitlab.test.ts b/packages/core/test/plugin/provider-gitlab.test.ts index e785fbbb7f..f9efa294f5 100644 --- a/packages/core/test/plugin/provider-gitlab.test.ts +++ b/packages/core/test/plugin/provider-gitlab.test.ts @@ -5,9 +5,11 @@ import { Catalog } from "@opencode-ai/core/catalog" import { EventV2 } from "@opencode-ai/core/event" import { Location } from "@opencode-ai/core/location" import { PluginV2 } from "@opencode-ai/core/plugin" +import { Policy } from "@opencode-ai/core/policy" import { AccountPlugin } from "@opencode-ai/core/plugin/account" import { GitLabPlugin } from "@opencode-ai/core/plugin/provider/gitlab" import { ProviderV2 } from "@opencode-ai/core/provider" +import { AbsolutePath } from "@opencode-ai/core/schema" import { testEffect } from "../lib/effect" import { it, model, npmLayer, withEnv } from "./provider-helper" @@ -27,12 +29,15 @@ void mock.module("gitlab-ai-provider", () => ({ })) const itWithAccount = testEffect( - Catalog.layer.pipe( - Layer.provideMerge(PluginV2.defaultLayer), - Layer.provideMerge(AccountV2.defaultLayer), - Layer.provideMerge(EventV2.defaultLayer), - Layer.provideMerge(Layer.succeed(Location.Service, Location.Service.of({ directory: "test" }))), - Layer.provideMerge(npmLayer), + Layer.mergeAll( + Catalog.defaultLayer, + PluginV2.defaultLayer, + AccountV2.defaultLayer, + EventV2.defaultLayer, + npmLayer, + ).pipe( + Layer.provide(Policy.defaultLayer), + Layer.provide(Location.defaultLayer({ directory: AbsolutePath.make("/") })), ), ) @@ -179,8 +184,8 @@ describe("GitLabPlugin", () => { ), }) yield* plugin.add(GitLabPlugin) - const load = yield* catalog.loader() - yield* load((catalog) => catalog.provider.update(ProviderV2.ID.make("gitlab"), () => {})) + const transform = yield* catalog.transform() + yield* transform((catalog) => catalog.provider.update(ProviderV2.ID.make("gitlab"), () => {})) const provider = yield* catalog.provider.get(ProviderV2.ID.make("gitlab")) yield* plugin.trigger( "aisdk.sdk", @@ -227,8 +232,8 @@ describe("GitLabPlugin", () => { ), }) yield* plugin.add(GitLabPlugin) - const load = yield* catalog.loader() - yield* load((catalog) => catalog.provider.update(ProviderV2.ID.make("gitlab"), () => {})) + const transform = yield* catalog.transform() + yield* transform((catalog) => catalog.provider.update(ProviderV2.ID.make("gitlab"), () => {})) const provider = yield* catalog.provider.get(ProviderV2.ID.make("gitlab")) yield* plugin.trigger( "aisdk.sdk", diff --git a/packages/core/test/plugin/provider-google-vertex-anthropic.test.ts b/packages/core/test/plugin/provider-google-vertex-anthropic.test.ts index 7cb2b24ffd..9d23dfefb3 100644 --- a/packages/core/test/plugin/provider-google-vertex-anthropic.test.ts +++ b/packages/core/test/plugin/provider-google-vertex-anthropic.test.ts @@ -22,8 +22,8 @@ describe("GoogleVertexAnthropicPlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(GoogleVertexAnthropicPlugin) - const load = yield* catalog.loader() - yield* load((catalog) => + const transform = yield* catalog.transform() + yield* transform((catalog) => catalog.provider.update(ProviderV2.ID.make("google-vertex-anthropic"), (provider) => { provider.endpoint = { type: "aisdk", package: "@ai-sdk/google-vertex/anthropic" } }), @@ -41,8 +41,8 @@ describe("GoogleVertexAnthropicPlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(GoogleVertexAnthropicPlugin) - const load = yield* catalog.loader() - yield* load((catalog) => + const transform = yield* catalog.transform() + yield* transform((catalog) => catalog.provider.update(ProviderV2.ID.make("google-vertex-anthropic"), (provider) => { provider.endpoint = { type: "aisdk", package: "@ai-sdk/google-vertex/anthropic" } provider.options.aisdk.provider.project = "configured-project" diff --git a/packages/core/test/plugin/provider-google-vertex.test.ts b/packages/core/test/plugin/provider-google-vertex.test.ts index 2a9a18875c..2abb342cc3 100644 --- a/packages/core/test/plugin/provider-google-vertex.test.ts +++ b/packages/core/test/plugin/provider-google-vertex.test.ts @@ -7,6 +7,7 @@ import { ProviderV2 } from "@opencode-ai/core/provider" import { fakeSelectorSdk, it, model, withEnv } from "./provider-helper" const vertexOptions: Record[] = [] +const googleAuthOptions: Record[] = [] void mock.module("@ai-sdk/google-vertex", () => ({ createVertex: (options: Record) => { @@ -19,12 +20,14 @@ void mock.module("@ai-sdk/google-vertex", () => ({ void mock.module("google-auth-library", () => ({ GoogleAuth: class { - async getApplicationDefault() { + constructor(options: Record) { + googleAuthOptions.push(options) + } + + async getClient() { return { - credential: { - async getAccessToken() { - return { token: "vertex-token" } - }, + async getAccessToken() { + return { token: "vertex-token" } }, } } @@ -47,8 +50,8 @@ describe("GoogleVertexPlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(GoogleVertexPlugin) - const load = yield* catalog.loader() - yield* load((catalog) => + const transform = yield* catalog.transform() + yield* transform((catalog) => catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => { provider.endpoint = { type: "aisdk", @@ -86,8 +89,8 @@ describe("GoogleVertexPlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(GoogleVertexPlugin) - const load = yield* catalog.loader() - yield* load((catalog) => + const transform = yield* catalog.transform() + yield* transform((catalog) => catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => { provider.endpoint = { type: "aisdk", @@ -136,8 +139,8 @@ describe("GoogleVertexPlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(GoogleVertexPlugin) - const load = yield* catalog.loader() - yield* load((catalog) => + const transform = yield* catalog.transform() + yield* transform((catalog) => catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => { provider.endpoint = { type: "aisdk", @@ -165,8 +168,8 @@ describe("GoogleVertexPlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(GoogleVertexPlugin) - const load = yield* catalog.loader() - yield* load((catalog) => + const transform = yield* catalog.transform() + yield* transform((catalog) => catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => { provider.endpoint = { type: "aisdk", @@ -201,8 +204,8 @@ describe("GoogleVertexPlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(GoogleVertexPlugin) - const load = yield* catalog.loader() - yield* load((catalog) => + const transform = yield* catalog.transform() + yield* transform((catalog) => catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => { provider.endpoint = { type: "aisdk", package: "@ai-sdk/google-vertex" } provider.options.aisdk.provider.project = "config-project" @@ -247,6 +250,7 @@ describe("GoogleVertexPlugin", () => { it.effect("keeps Google auth fetch for OpenAI-compatible Vertex endpoints", () => Effect.gen(function* () { + googleAuthOptions.length = 0 const fetchCalls: { input: Parameters[0]; init?: RequestInit }[] = [] const plugin = yield* PluginV2.Service yield* plugin.add(GoogleVertexPlugin) @@ -292,6 +296,7 @@ describe("GoogleVertexPlugin", () => { }), ) expect(fetchCalls).toHaveLength(1) + expect(googleAuthOptions).toEqual([{ scopes: ["https://www.googleapis.com/auth/cloud-platform"] }]) expect(fetchCalls[0].input).toBe("https://vertex.example") expect(new Headers(fetchCalls[0].init?.headers).get("authorization")).toBe("Bearer vertex-token") expect(new Headers(fetchCalls[0].init?.headers).get("x-test")).toBe("1") diff --git a/packages/core/test/plugin/provider-helper.ts b/packages/core/test/plugin/provider-helper.ts index 1b8f1c65a0..a6d25ac187 100644 --- a/packages/core/test/plugin/provider-helper.ts +++ b/packages/core/test/plugin/provider-helper.ts @@ -7,11 +7,17 @@ import { EventV2 } from "@opencode-ai/core/event" import { Location } from "@opencode-ai/core/location" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { Policy } from "@opencode-ai/core/policy" import { ProviderV2 } from "@opencode-ai/core/provider" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { location } from "../fixture/location" import { testEffect } from "../lib/effect" export const fixtureProvider = new URL("./fixtures/provider-factory.ts", import.meta.url).href -const locationLayer = Layer.succeed(Location.Service, Location.Service.of({ directory: "test" })) +const locationLayer = Layer.succeed( + Location.Service, + Location.Service.of(location({ directory: AbsolutePath.make("test") })), +) export const npmLayer = Layer.succeed( Npm.Service, @@ -25,7 +31,7 @@ export const npmLayer = Layer.succeed( export const catalogLayer = Layer.succeed( Catalog.Service, Catalog.Service.of({ - loader: () => Effect.die("unexpected catalog.loader"), + transform: () => Effect.die("unexpected catalog.transform"), provider: { get: () => Effect.die("unexpected provider.get"), all: () => Effect.succeed([]), @@ -36,7 +42,6 @@ export const catalogLayer = Layer.succeed( all: () => Effect.succeed([]), available: () => Effect.succeed([]), default: () => Effect.succeed(Option.none()), - setDefault: () => Effect.die("unexpected model.setDefault"), small: () => Effect.succeed(Option.none()), }, }), @@ -46,6 +51,7 @@ export const it = testEffect( Catalog.layer.pipe( Layer.provideMerge(PluginV2.defaultLayer), Layer.provideMerge(EventV2.defaultLayer), + Layer.provide(Policy.defaultLayer), Layer.provideMerge(locationLayer), Layer.provideMerge(npmLayer), ), diff --git a/packages/core/test/plugin/provider-kilo.test.ts b/packages/core/test/plugin/provider-kilo.test.ts index cafe4c10f6..ac3cc172a2 100644 --- a/packages/core/test/plugin/provider-kilo.test.ts +++ b/packages/core/test/plugin/provider-kilo.test.ts @@ -22,8 +22,8 @@ describe("KiloPlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(KiloPlugin) - const load = yield* catalog.loader() - yield* load((catalog) => { + const transform = yield* catalog.transform() + yield* transform((catalog) => { const kilo = provider("kilo", { endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.kilo.ai/api/gateway" }, options: { headers: { Existing: "value" }, body: {}, aisdk: { provider: {}, request: {} } }, @@ -48,8 +48,8 @@ describe("KiloPlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(KiloPlugin) - const load = yield* catalog.loader() - yield* load((catalog) => { + const transform = yield* catalog.transform() + yield* transform((catalog) => { const item = provider("kilo", { endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.kilo.ai/api/gateway" }, }) @@ -74,8 +74,8 @@ describe("KiloPlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(KiloPlugin) - const load = yield* catalog.loader() - yield* load((catalog) => { + const transform = yield* catalog.transform() + yield* transform((catalog) => { const kilo = provider("kilo", { endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.kilo.ai/api/gateway" }, }) diff --git a/packages/core/test/plugin/provider-llmgateway.test.ts b/packages/core/test/plugin/provider-llmgateway.test.ts index f34b7b3fcc..980abe2258 100644 --- a/packages/core/test/plugin/provider-llmgateway.test.ts +++ b/packages/core/test/plugin/provider-llmgateway.test.ts @@ -22,8 +22,8 @@ describe("LLMGatewayPlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(LLMGatewayPlugin) - const load = yield* catalog.loader() - yield* load((catalog) => { + const transform = yield* catalog.transform() + yield* transform((catalog) => { const llmgateway = provider("llmgateway", { enabled: { via: "env", name: "LLMGATEWAY_API_KEY" }, endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.llmgateway.io/v1" }, @@ -56,8 +56,8 @@ describe("LLMGatewayPlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(LLMGatewayPlugin) - const load = yield* catalog.loader() - yield* load((catalog) => { + const transform = yield* catalog.transform() + yield* transform((catalog) => { const item = provider("llmgateway", { endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.llmgateway.io/v1" }, }) diff --git a/packages/core/test/plugin/provider-nvidia.test.ts b/packages/core/test/plugin/provider-nvidia.test.ts index 8bd9051bd8..dea0000717 100644 --- a/packages/core/test/plugin/provider-nvidia.test.ts +++ b/packages/core/test/plugin/provider-nvidia.test.ts @@ -22,8 +22,8 @@ describe("NvidiaPlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(NvidiaPlugin) - const load = yield* catalog.loader() - yield* load((catalog) => { + const transform = yield* catalog.transform() + yield* transform((catalog) => { const nvidia = provider("nvidia", { endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://integrate.api.nvidia.com/v1" }, options: { headers: { Existing: "value" }, body: {}, aisdk: { provider: {}, request: {} } }, @@ -49,8 +49,8 @@ describe("NvidiaPlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(NvidiaPlugin) - const load = yield* catalog.loader() - yield* load((catalog) => { + const transform = yield* catalog.transform() + yield* transform((catalog) => { const item = provider("nvidia", { endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://integrate.api.nvidia.com/v1" }, options: { headers: {}, body: {}, aisdk: { provider: {}, request: {} } }, @@ -74,8 +74,8 @@ describe("NvidiaPlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(NvidiaPlugin) - const load = yield* catalog.loader() - yield* load((catalog) => { + const transform = yield* catalog.transform() + yield* transform((catalog) => { const item = provider("nvidia", { endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://integrate.api.nvidia.com/v1" }, options: { diff --git a/packages/core/test/plugin/provider-openai.test.ts b/packages/core/test/plugin/provider-openai.test.ts index 93e451e018..b65beb3c71 100644 --- a/packages/core/test/plugin/provider-openai.test.ts +++ b/packages/core/test/plugin/provider-openai.test.ts @@ -77,8 +77,8 @@ describe("OpenAIPlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(OpenAIPlugin) - const load = yield* catalog.loader() - yield* load((catalog) => { + const transform = yield* catalog.transform() + yield* transform((catalog) => { const item = provider("openai", { endpoint: { type: "aisdk", package: "@ai-sdk/openai" } }) catalog.provider.update(item.id, (draft) => { draft.endpoint = item.endpoint @@ -96,8 +96,8 @@ describe("OpenAIPlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(OpenAIPlugin) - const load = yield* catalog.loader() - yield* load((catalog) => { + const transform = yield* catalog.transform() + yield* transform((catalog) => { const item = provider("custom-openai") catalog.provider.update(item.id, () => {}) catalog.model.update(item.id, ModelV2.ID.make("gpt-5-chat-latest"), () => {}) diff --git a/packages/core/test/plugin/provider-opencode.test.ts b/packages/core/test/plugin/provider-opencode.test.ts index 3f59a34977..4f4dd6abef 100644 --- a/packages/core/test/plugin/provider-opencode.test.ts +++ b/packages/core/test/plugin/provider-opencode.test.ts @@ -5,11 +5,17 @@ import { Location } from "@opencode-ai/core/location" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" import { OpencodePlugin } from "@opencode-ai/core/plugin/provider/opencode" +import { Policy } from "@opencode-ai/core/policy" import { ProviderV2 } from "@opencode-ai/core/provider" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { location } from "../fixture/location" import { it, model, provider, withEnv } from "./provider-helper" const cost = (input: number, output = 0) => [{ input, output, cache: { read: 0, write: 0 } }] -const locationLayer = Layer.succeed(Location.Service, Location.Service.of({ directory: "test" })) +const locationLayer = Layer.succeed( + Location.Service, + Location.Service.of(location({ directory: AbsolutePath.make("test") })), +) describe("OpencodePlugin", () => { it.effect("uses a public key and disables paid models without credentials", () => @@ -18,8 +24,8 @@ describe("OpencodePlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(OpencodePlugin) - const load = yield* catalog.loader() - yield* load((catalog) => { + const transform = yield* catalog.transform() + yield* transform((catalog) => { const item = provider("opencode") catalog.provider.update(item.id, () => {}) const paid = model("opencode", "paid", { cost: cost(1) }) @@ -39,8 +45,8 @@ describe("OpencodePlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(OpencodePlugin) - const load = yield* catalog.loader() - yield* load((catalog) => { + const transform = yield* catalog.transform() + yield* transform((catalog) => { const item = provider("opencode") catalog.provider.update(item.id, () => {}) const free = model("opencode", "free", { cost: cost(0) }) @@ -60,8 +66,8 @@ describe("OpencodePlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(OpencodePlugin) - const load = yield* catalog.loader() - yield* load((catalog) => { + const transform = yield* catalog.transform() + yield* transform((catalog) => { const item = provider("opencode") catalog.provider.update(item.id, () => {}) const outputOnly = model("opencode", "output-only", { cost: cost(0, 1) }) @@ -81,8 +87,8 @@ describe("OpencodePlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(OpencodePlugin) - const load = yield* catalog.loader() - yield* load((catalog) => { + const transform = yield* catalog.transform() + yield* transform((catalog) => { const item = provider("opencode") catalog.provider.update(item.id, () => {}) const paid = model("opencode", "paid", { cost: cost(1) }) @@ -102,8 +108,8 @@ describe("OpencodePlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(OpencodePlugin) - const load = yield* catalog.loader() - yield* load((catalog) => { + const transform = yield* catalog.transform() + yield* transform((catalog) => { const item = provider("opencode", { env: ["CUSTOM_OPENCODE_API_KEY"] }) catalog.provider.update(item.id, (draft) => { draft.env = [...item.env] @@ -125,8 +131,8 @@ describe("OpencodePlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(OpencodePlugin) - const load = yield* catalog.loader() - yield* load((catalog) => { + const transform = yield* catalog.transform() + yield* transform((catalog) => { const item = provider("opencode", { options: { headers: {}, @@ -157,8 +163,8 @@ describe("OpencodePlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(OpencodePlugin) - const load = yield* catalog.loader() - yield* load((catalog) => { + const transform = yield* catalog.transform() + yield* transform((catalog) => { const item = provider("opencode", { enabled: { via: "account", service: "opencode" } }) catalog.provider.update(item.id, (draft) => { draft.enabled = item.enabled @@ -180,8 +186,8 @@ describe("OpencodePlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(OpencodePlugin) - const load = yield* catalog.loader() - yield* load((catalog) => { + const transform = yield* catalog.transform() + yield* transform((catalog) => { const item = provider("openai") catalog.provider.update(item.id, () => {}) const paid = model("openai", "paid", { cost: cost(1) }) @@ -200,8 +206,8 @@ describe("OpencodePlugin", () => { const catalog = yield* Catalog.Service const providerID = ProviderV2.ID.opencode - const load = yield* catalog.loader() - yield* load((catalog) => { + const transform = yield* catalog.transform() + yield* transform((catalog) => { catalog.provider.update(providerID, () => {}) catalog.model.update(providerID, ModelV2.ID.make("cheap-mini"), (model) => { model.capabilities.input = ["text"] @@ -220,6 +226,8 @@ describe("OpencodePlugin", () => { const selected = yield* catalog.model.small(providerID) expect(Option.getOrUndefined(selected)?.id).toBe(ModelV2.ID.make("gpt-5-nano")) - }).pipe(Effect.provide(Catalog.defaultLayer.pipe(Layer.provide(locationLayer)))), + }).pipe( + Effect.provide(Catalog.defaultLayer.pipe(Layer.provide(Policy.defaultLayer), Layer.provide(locationLayer))), + ), ) }) diff --git a/packages/core/test/plugin/provider-openrouter.test.ts b/packages/core/test/plugin/provider-openrouter.test.ts index a60323b8d3..d19575a9bb 100644 --- a/packages/core/test/plugin/provider-openrouter.test.ts +++ b/packages/core/test/plugin/provider-openrouter.test.ts @@ -23,8 +23,8 @@ describe("OpenRouterPlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(OpenRouterPlugin) - const load = yield* catalog.loader() - yield* load((catalog) => { + const transform = yield* catalog.transform() + yield* transform((catalog) => { const openrouter = provider("openrouter", { endpoint: { type: "aisdk", package: "@openrouter/ai-sdk-provider" }, options: { headers: { Existing: "value" }, body: {}, aisdk: { provider: {}, request: {} } }, @@ -75,8 +75,8 @@ describe("OpenRouterPlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(OpenRouterPlugin) - const load = yield* catalog.loader() - yield* load((catalog) => { + const transform = yield* catalog.transform() + yield* transform((catalog) => { const openrouter = provider("openrouter", { endpoint: { type: "aisdk", package: "@openrouter/ai-sdk-provider" }, }) @@ -108,8 +108,8 @@ describe("OpenRouterPlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(OpenRouterPlugin) - const load = yield* catalog.loader() - yield* load((catalog) => { + const transform = yield* catalog.transform() + yield* transform((catalog) => { catalog.provider.update(ProviderV2.ID.make("custom-openrouter"), () => {}) catalog.model.update(ProviderV2.ID.make("custom-openrouter"), ModelV2.ID.make("gpt-5-chat-latest"), () => {}) }) diff --git a/packages/core/test/plugin/provider-vercel.test.ts b/packages/core/test/plugin/provider-vercel.test.ts index f6d9efd192..ba34281196 100644 --- a/packages/core/test/plugin/provider-vercel.test.ts +++ b/packages/core/test/plugin/provider-vercel.test.ts @@ -12,8 +12,8 @@ describe("VercelPlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(VercelPlugin) - const load = yield* catalog.loader() - yield* load((catalog) => { + const transform = yield* catalog.transform() + yield* transform((catalog) => { const item = provider("vercel", { endpoint: { type: "aisdk", package: "@ai-sdk/vercel" }, options: { headers: { Existing: "1" }, body: {}, aisdk: { provider: {}, request: {} } }, @@ -36,8 +36,8 @@ describe("VercelPlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(VercelPlugin) - const load = yield* catalog.loader() - yield* load((catalog) => { + const transform = yield* catalog.transform() + yield* transform((catalog) => { const item = provider("vercel", { endpoint: { type: "aisdk", package: "@ai-sdk/vercel" } }) catalog.provider.update(item.id, (draft) => { draft.endpoint = item.endpoint @@ -69,8 +69,8 @@ describe("VercelPlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(VercelPlugin) - const load = yield* catalog.loader() - yield* load((catalog) => catalog.provider.update(provider("gateway").id, () => {})) + const transform = yield* catalog.transform() + yield* transform((catalog) => catalog.provider.update(provider("gateway").id, () => {})) expect((yield* catalog.provider.get(ProviderV2.ID.make("gateway"))).options.headers).toEqual({}) }), ) diff --git a/packages/core/test/plugin/provider-zenmux.test.ts b/packages/core/test/plugin/provider-zenmux.test.ts index 71067a5a1e..c5899627cf 100644 --- a/packages/core/test/plugin/provider-zenmux.test.ts +++ b/packages/core/test/plugin/provider-zenmux.test.ts @@ -22,8 +22,8 @@ describe("ZenmuxPlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(ZenmuxPlugin) - const load = yield* catalog.loader() - yield* load((catalog) => { + const transform = yield* catalog.transform() + yield* transform((catalog) => { const item = provider("zenmux", { endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://zenmux.ai/api/v1" }, }) @@ -42,8 +42,8 @@ describe("ZenmuxPlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(ZenmuxPlugin) - const load = yield* catalog.loader() - yield* load((catalog) => { + const transform = yield* catalog.transform() + yield* transform((catalog) => { const item = provider("zenmux", { endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://zenmux.ai/api/v1" }, options: { headers: { Existing: "value" }, body: {}, aisdk: { provider: {}, request: {} } }, @@ -67,8 +67,8 @@ describe("ZenmuxPlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(ZenmuxPlugin) - const load = yield* catalog.loader() - yield* load((catalog) => { + const transform = yield* catalog.transform() + yield* transform((catalog) => { const item = provider("zenmux", { endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://zenmux.ai/api/v1" }, options: { @@ -95,8 +95,8 @@ describe("ZenmuxPlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* plugin.add(ZenmuxPlugin) - const load = yield* catalog.loader() - yield* load((catalog) => { + const transform = yield* catalog.transform() + yield* transform((catalog) => { const item = provider("openrouter", { options: { headers: { "HTTP-Referer": "https://example.com/", "X-Title": "custom-title" }, diff --git a/packages/core/test/policy.test.ts b/packages/core/test/policy.test.ts new file mode 100644 index 0000000000..c331b5585f --- /dev/null +++ b/packages/core/test/policy.test.ts @@ -0,0 +1,83 @@ +import { describe, expect } from "bun:test" +import { Effect, Layer } from "effect" +import { Location } from "@opencode-ai/core/location" +import { Policy } from "@opencode-ai/core/policy" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { location } from "./fixture/location" +import { testEffect } from "./lib/effect" + +const it = testEffect( + Policy.defaultLayer.pipe( + Layer.provide( + Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("test") }))), + ), + ), +) + +describe("Policy", () => { + it.effect("returns the caller's fallback when no statement matches", () => + Effect.gen(function* () { + const policy = yield* Policy.Service + + expect(yield* policy.evaluate("provider.use", "anthropic", "allow")).toBe("allow") + expect(yield* policy.evaluate("provider.use", "anthropic", "deny")).toBe("deny") + }), + ) + + it.effect("evaluates wildcard provider rules in written order", () => + Effect.gen(function* () { + const policy = yield* Policy.Service + yield* policy.load([ + new Policy.Info({ + effect: "deny", + action: "provider.*", + resource: "*", + }), + new Policy.Info({ + effect: "allow", + action: "provider.use", + resource: "anthropic", + }), + ]) + + expect(yield* policy.evaluate("provider.use", "anthropic", "allow")).toBe("allow") + expect(yield* policy.evaluate("provider.use", "openai", "allow")).toBe("deny") + }), + ) + + it.effect("matches action and resource independently", () => + Effect.gen(function* () { + const policy = yield* Policy.Service + yield* policy.load([ + new Policy.Info({ + effect: "deny", + action: "provider.*", + resource: "company-*", + }), + ]) + + expect(yield* policy.evaluate("provider.use", "company-stable", "allow")).toBe("deny") + expect(yield* policy.evaluate("plugin.load", "company-stable", "allow")).toBe("allow") + }), + ) + + it.effect("uses the last matching loaded statement", () => + Effect.gen(function* () { + const policy = yield* Policy.Service + yield* policy.load([ + new Policy.Info({ + effect: "allow", + action: "provider.use", + resource: "openai", + }), + new Policy.Info({ + effect: "deny", + action: "provider.use", + resource: "openai", + }), + ]) + + expect(yield* policy.evaluate("provider.use", "openai", "allow")).toBe("deny") + }), + ) +}) diff --git a/packages/core/test/project.test.ts b/packages/core/test/project.test.ts new file mode 100644 index 0000000000..b1ae5b45c3 --- /dev/null +++ b/packages/core/test/project.test.ts @@ -0,0 +1,220 @@ +import { describe, expect } from "bun:test" +import { $ } from "bun" +import fs from "fs/promises" +import path from "path" +import { Effect } from "effect" +import { Project } 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) + +function remoteID(remote: string) { + return Project.ID.make(Hash.fast(`git-remote:${remote}`)) +} + +function abs(value: string) { + return AbsolutePath.make(value) +} + +function real(value: string) { + return Effect.promise(() => fs.realpath(value)).pipe(Effect.map((value) => AbsolutePath.make(value))) +} + +async function initRepo(dir: string, opts?: { commit?: boolean; remote?: string }) { + await $`git init`.cwd(dir).quiet() + await $`git config core.fsmonitor false`.cwd(dir).quiet() + await $`git config commit.gpgsign false`.cwd(dir).quiet() + await $`git config user.email test@opencode.test`.cwd(dir).quiet() + await $`git config user.name Test`.cwd(dir).quiet() + if (opts?.commit) await $`git commit --allow-empty -m root`.cwd(dir).quiet() + if (opts?.remote) await $`git remote add origin ${opts.remote}`.cwd(dir).quiet() +} + +async function rootCommit(dir: string) { + return (await $`git rev-list --max-parents=0 HEAD`.cwd(dir).text()).trim() +} + +describe("ProjectV2.resolve", () => { + it.live("returns global for non-git directory", () => + Effect.gen(function* () { + const tmp = yield* Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ) + const project = yield* Project.Service + + const result = yield* project.resolve(abs(tmp.path)) + + expect(result.id).toBe(Project.ID.make("global")) + expect(path.resolve(result.directory)).toBe(path.parse(tmp.path).root) + expect(result.previous).toBeUndefined() + expect(result.vcs).toBeUndefined() + }), + ) + + it.live("returns git global for repo with no commits and no remote", () => + Effect.gen(function* () { + const tmp = yield* Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ) + yield* Effect.promise(() => initRepo(tmp.path)) + const project = yield* Project.Service + + const result = yield* project.resolve(abs(tmp.path)) + + expect(result.id).toBe(Project.ID.make("global")) + expect(result.directory).toBe(yield* real(tmp.path)) + expect(result.previous).toBeUndefined() + expect(result.vcs?.type).toBe("git") + }), + ) + + it.live("falls back to root commit when origin is missing", () => + Effect.gen(function* () { + const tmp = yield* Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ) + yield* Effect.promise(() => initRepo(tmp.path, { commit: true })) + const project = yield* Project.Service + + const result = yield* project.resolve(abs(tmp.path)) + + expect(result.id).toBe(Project.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") + }), + ) + + it.live("prefers normalized origin over root commit", () => + Effect.gen(function* () { + const tmp = yield* Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (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 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.directory).toBe(yield* real(tmp.path)) + expect(result.vcs?.type).toBe("git") + }), + ) + + it.live("normalizes ssh and https remotes to the same id", () => + Effect.gen(function* () { + const ssh = yield* Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ) + const https = yield* Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ) + 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 a = yield* project.resolve(abs(ssh.path)) + const b = yield* project.resolve(abs(https.path)) + + expect(a.id).toBe(remoteID("github.com/owner/repo")) + expect(b.id).toBe(a.id) + }), + ) + + it.live("ignores file remotes and falls back to root commit", () => + Effect.gen(function* () { + const tmp = yield* Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ) + yield* Effect.promise(() => initRepo(tmp.path, { commit: true, remote: `file://${tmp.path}` })) + const project = yield* Project.Service + + const result = yield* project.resolve(abs(tmp.path)) + + expect(result.id).toBe(Project.ID.make(yield* Effect.promise(() => rootCommit(tmp.path)))) + }), + ) + + it.live("returns previous cached id from common dir", () => + Effect.gen(function* () { + const tmp = yield* Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ) + 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 result = yield* project.resolve(abs(tmp.path)) + + expect(result.previous).toBe(Project.ID.make("old-id")) + expect(result.id).toBe(remoteID("github.com/owner/repo")) + }), + ) + + it.live("does not write the cache while resolving", () => + Effect.gen(function* () { + const tmp = yield* Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (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 + + yield* project.resolve(abs(tmp.path)) + + expect(yield* Effect.promise(() => Bun.file(path.join(tmp.path, ".git", "opencode")).exists())).toBe(false) + }), + ) + + it.live("resolves from nested directories to repo root", () => + Effect.gen(function* () { + const tmp = yield* Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ) + 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 result = yield* project.resolve(abs(path.join(tmp.path, "a", "b"))) + + expect(result.directory).toBe(yield* real(tmp.path)) + }), + ) + + it.live("linked worktree returns opened worktree directory and previous from common dir", () => + Effect.gen(function* () { + const tmp = yield* Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ) + const worktree = `${tmp.path}-worktree` + yield* Effect.addFinalizer(() => + Effect.promise(() => $`rm -rf ${worktree}`.quiet().nothrow()).pipe(Effect.ignore), + ) + 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 result = yield* project.resolve(abs(worktree)) + + expect(result.directory).toBe(yield* real(worktree)) + expect(result.previous).toBe(Project.ID.make("old-id")) + expect(result.id).toBe(remoteID("github.com/owner/repo")) + expect(result.vcs?.type).toBe("git") + }), + ) +}) diff --git a/packages/llm/src/protocols/anthropic-messages.ts b/packages/llm/src/protocols/anthropic-messages.ts index 060e2e091a..234ccd5baf 100644 --- a/packages/llm/src/protocols/anthropic-messages.ts +++ b/packages/llm/src/protocols/anthropic-messages.ts @@ -335,7 +335,9 @@ const lowerToolResultContent = Effect.fn("AnthropicMessages.lowerToolResultConte // Text / json / error results stay as a 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("AnthropicMessages.lowerMessages")(function* ( diff --git a/packages/llm/src/protocols/openai-responses.ts b/packages/llm/src/protocols/openai-responses.ts index 88c65d187c..ad673a263f 100644 --- a/packages/llm/src/protocols/openai-responses.ts +++ b/packages/llm/src/protocols/openai-responses.ts @@ -57,6 +57,11 @@ const OpenAIResponsesReasoningItem = Schema.Struct({ encrypted_content: optionalNull(Schema.String), }) +const OpenAIResponsesItemReference = Schema.Struct({ + type: Schema.tag("item_reference"), + id: Schema.String, +}) + // `function_call_output.output` accepts either a plain string or an ordered // array of content items so tools can return images in addition to text. // https://platform.openai.com/docs/api-reference/responses/object @@ -72,6 +77,7 @@ const OpenAIResponsesInputItem = Schema.Union([ Schema.Struct({ role: Schema.tag("user"), content: Schema.Array(OpenAIResponsesInputContent) }), Schema.Struct({ role: Schema.tag("assistant"), content: Schema.Array(OpenAIResponsesOutputText) }), OpenAIResponsesReasoningItem, + OpenAIResponsesItemReference, Schema.Struct({ type: Schema.tag("function_call"), call_id: Schema.String, @@ -86,6 +92,15 @@ const OpenAIResponsesInputItem = Schema.Union([ ]) type OpenAIResponsesInputItem = Schema.Schema.Type +// Mutable counterpart of the schema reasoning item so `lowerMessages` can fold +// multiple streamed summary parts into the same item before flushing. +type OpenAIResponsesReasoningInput = { + type: "reasoning" + id: string + summary: Array<{ type: "summary_text"; text: string }> + encrypted_content?: string | null +} + const OpenAIResponsesTool = Schema.Struct({ type: Schema.tag("function"), name: Schema.String, @@ -112,7 +127,7 @@ const OpenAIResponsesCoreFields = { tool_choice: Schema.optional(OpenAIResponsesToolChoice), store: Schema.optional(Schema.Boolean), prompt_cache_key: Schema.optional(Schema.String), - include: optionalArray(Schema.Literal("reasoning.encrypted_content")), + include: optionalArray(OpenAIOptions.OpenAIResponseIncludable), reasoning: Schema.optional( Schema.Struct({ effort: Schema.optional(OpenAIOptions.OpenAIReasoningEffort), @@ -193,6 +208,7 @@ const OpenAIResponsesEvent = Schema.Struct({ type: Schema.String, delta: Schema.optional(Schema.String), item_id: Schema.optional(Schema.String), + summary_index: Schema.optional(Schema.Number), item: Schema.optional(OpenAIResponsesStreamItem), response: Schema.optional( Schema.StructWithRest( @@ -216,6 +232,18 @@ interface ParserState { readonly tools: ToolStream.State readonly hasFunctionCall: boolean readonly lifecycle: Lifecycle.State + readonly reasoningItems: Readonly> + readonly store: boolean | undefined +} + +type ReasoningSummaryStatus = "active" | "can-conclude" | "concluded" + +interface ReasoningStreamItem { + readonly encryptedContent: string | null | undefined + // Keyed by OpenAI's numeric `summary_index`. JS object keys coerce to + // strings, but typing the map as `Record` documents intent + // and matches the wire field. + readonly summaryParts: Readonly> } const invalid = ProviderShared.invalidRequest @@ -245,22 +273,21 @@ const lowerToolCall = (part: ToolCallPart): OpenAIResponsesInputItem => ({ arguments: ProviderShared.encodeJson(part.input), }) -const lowerReasoning = (part: ReasoningPart, store: boolean | undefined): OpenAIResponsesInputItem | undefined => { +const lowerReasoning = (part: ReasoningPart): OpenAIResponsesReasoningInput | undefined => { const openai = part.providerMetadata?.openai - if (!ProviderShared.isRecord(openai) || typeof openai.itemId !== "string") return undefined - // With store:false, OpenAI only accepts previous reasoning items when the - // encrypted state is present. Bare rs_* ids point to non-persisted items. - if (store === false && typeof openai.reasoningEncryptedContent !== "string") return undefined + if (!ProviderShared.isRecord(openai) || typeof openai.itemId !== "string" || openai.itemId.length === 0) + return undefined + const encryptedContent = + typeof openai.reasoningEncryptedContent === "string" + ? openai.reasoningEncryptedContent + : openai.reasoningEncryptedContent === null + ? null + : undefined return { type: "reasoning", id: openai.itemId, summary: part.text.length > 0 ? [{ type: "summary_text", text: part.text }] : [], - encrypted_content: - typeof openai.reasoningEncryptedContent === "string" - ? openai.reasoningEncryptedContent - : openai.reasoningEncryptedContent === null - ? null - : undefined, + encrypted_content: encryptedContent, } } @@ -310,6 +337,8 @@ const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (requ if (message.role === "assistant") { const content: TextPart[] = [] + const reasoningItems: Record = {} + const reasoningReferences = new Set() const flushText = () => { if (content.length === 0) return input.push({ role: "assistant", content: content.map((part) => ({ type: "output_text", text: part.text })) }) @@ -322,8 +351,22 @@ const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (requ } if (part.type === "reasoning") { flushText() - const reasoning = lowerReasoning(part, store) - if (reasoning) input.push(reasoning) + const reasoning = lowerReasoning(part) + if (!reasoning) continue + if (store !== false && reasoning.id) { + if (!reasoningReferences.has(reasoning.id)) input.push({ type: "item_reference", id: reasoning.id }) + reasoningReferences.add(reasoning.id) + continue + } + const existing = reasoningItems[reasoning.id] + if (existing) { + existing.summary.push(...reasoning.summary) + if (typeof reasoning.encrypted_content === "string") + existing.encrypted_content = reasoning.encrypted_content + continue + } + reasoningItems[reasoning.id] = reasoning + input.push(reasoning) continue } if (part.type === "tool-call") { @@ -352,7 +395,14 @@ const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (requ } } - return input + // With store:false, OpenAI only accepts previous reasoning items when the + // complete item has encrypted state. Summary blocks for one item may carry + // that state only on the last block, so filter after they have been joined. + return store === false + ? input.filter( + (item) => !("type" in item) || item.type !== "reasoning" || typeof item.encrypted_content === "string", + ) + : input }) const lowerOptions = Effect.fn("OpenAIResponses.lowerOptions")(function* (request: LLMRequest) { @@ -362,14 +412,14 @@ const lowerOptions = Effect.fn("OpenAIResponses.lowerOptions")(function* (reques if (effort && !OpenAIOptions.isReasoningEffort(effort)) return yield* invalid(`OpenAI Responses does not support reasoning effort ${effort}`) const summary = OpenAIOptions.reasoningSummary(request) - const encryptedState = OpenAIOptions.encryptedReasoning(request) + const include = OpenAIOptions.include(request) const verbosity = OpenAIOptions.textVerbosity(request) const instructions = OpenAIOptions.instructions(request) return { ...(instructions ? { instructions } : {}), ...(store !== undefined ? { store } : {}), ...(promptCacheKey ? { prompt_cache_key: promptCacheKey } : {}), - ...(encryptedState ? { include: ["reasoning.encrypted_content"] as const } : {}), + ...(include ? { include } : {}), ...(effort || summary ? { reasoning: { effort, summary } } : {}), ...(verbosity ? { text: { verbosity } } : {}), } @@ -517,24 +567,51 @@ const onOutputTextDelta = (state: ParserState, event: OpenAIResponsesEvent): Ste const onReasoningDelta = (state: ParserState, event: OpenAIResponsesEvent): StepResult => { if (!event.delta) return [state, NO_EVENTS] const events: LLMEvent[] = [] + const itemID = event.item_id ?? "reasoning-0" + const id = + event.summary_index !== undefined || state.reasoningItems[itemID] ? `${itemID}:${event.summary_index ?? 0}` : itemID return [ { ...state, - lifecycle: Lifecycle.reasoningDelta(state.lifecycle, events, event.item_id ?? "reasoning-0", event.delta), + lifecycle: Lifecycle.reasoningDelta(state.lifecycle, events, id, event.delta), }, events, ] } -// The summary done event does not carry encrypted continuation state. Finish the -// common reasoning block when the full reasoning item arrives in output_item.done. const onReasoningDone = (state: ParserState, _event: OpenAIResponsesEvent): StepResult => [state, NO_EVENTS] const reasoningMetadata = (item: OpenAIResponsesStreamItem & { id: string }) => openaiMetadata({ itemId: item.id, reasoningEncryptedContent: item.encrypted_content ?? null }) +// OpenAI Responses streams reasoning items in a stable order: +// `output_item.added` (reasoning) → +// `reasoning_summary_part.added` (index=0) → +// `reasoning_summary_text.delta` → +// `reasoning_summary_part.done` (index=0) → +// (repeat for index>0) → +// `output_item.done` (reasoning). +// The handlers below rely on this ordering: `onOutputItemAdded` seeds the +// per-item entry, `onReasoningSummaryPartAdded` for `summary_index === 0` +// short-circuits when the entry already exists, and higher-index handlers +// fold against the same entry. Behaviour for out-of-order events is +// best-effort, not guaranteed. const onOutputItemAdded = (state: ParserState, event: OpenAIResponsesEvent): StepResult => { const item = event.item + if (item && isReasoningItem(item)) { + const events: LLMEvent[] = [] + return [ + { + ...state, + lifecycle: Lifecycle.reasoningStart(state.lifecycle, events, `${item.id}:0`, reasoningMetadata(item)), + reasoningItems: { + ...state.reasoningItems, + [item.id]: { encryptedContent: item.encrypted_content, summaryParts: { 0: "active" } }, + }, + }, + events, + ] + } if (item?.type !== "function_call" || !item.id) return [state, NO_EVENTS] const providerMetadata = openaiMetadata({ itemId: item.id }) const events: LLMEvent[] = [] @@ -555,6 +632,103 @@ const onOutputItemAdded = (state: ParserState, event: OpenAIResponsesEvent): Ste ] } +const onReasoningSummaryPartAdded = (state: ParserState, event: OpenAIResponsesEvent): StepResult => { + if (!event.item_id || event.summary_index === undefined) return [state, NO_EVENTS] + const item = state.reasoningItems[event.item_id] ?? { encryptedContent: undefined, summaryParts: {} } + if (event.summary_index === 0) { + if (state.reasoningItems[event.item_id]) return [state, NO_EVENTS] + const events: LLMEvent[] = [] + return [ + { + ...state, + lifecycle: Lifecycle.reasoningStart( + state.lifecycle, + events, + `${event.item_id}:0`, + openaiMetadata({ itemId: event.item_id, reasoningEncryptedContent: null }), + ), + reasoningItems: { + ...state.reasoningItems, + [event.item_id]: { ...item, summaryParts: { 0: "active" } }, + }, + }, + events, + ] + } + + const events: LLMEvent[] = [] + const closed = Object.entries(item.summaryParts) + .filter((entry) => entry[1] === "can-conclude") + .reduce( + (lifecycle, entry) => + Lifecycle.reasoningEnd( + lifecycle, + events, + `${event.item_id}:${entry[0]}`, + openaiMetadata({ itemId: event.item_id }), + ), + state.lifecycle, + ) + return [ + { + ...state, + lifecycle: Lifecycle.reasoningStart( + closed, + events, + `${event.item_id}:${event.summary_index}`, + openaiMetadata({ itemId: event.item_id, reasoningEncryptedContent: item.encryptedContent ?? null }), + ), + reasoningItems: { + ...state.reasoningItems, + [event.item_id]: { + ...item, + summaryParts: { + ...Object.fromEntries( + Object.entries(item.summaryParts).map((entry) => + entry[1] === "can-conclude" ? [entry[0], "concluded" as const] : entry, + ), + ), + [event.summary_index]: "active", + }, + }, + }, + }, + events, + ] +} + +const onReasoningSummaryPartDone = (state: ParserState, event: OpenAIResponsesEvent): StepResult => { + if (!event.item_id || event.summary_index === undefined) return [state, NO_EVENTS] + const item = state.reasoningItems[event.item_id] + if (!item) return [state, NO_EVENTS] + const events: LLMEvent[] = [] + return [ + { + ...state, + lifecycle: + state.store !== false + ? Lifecycle.reasoningEnd( + state.lifecycle, + events, + `${event.item_id}:${event.summary_index}`, + openaiMetadata({ itemId: event.item_id }), + ) + : state.lifecycle, + reasoningItems: { + ...state.reasoningItems, + [event.item_id]: { + ...item, + summaryParts: { + ...item.summaryParts, + [event.summary_index]: state.store !== false ? "concluded" : "can-conclude", + }, + }, + }, + }, + events, + ] +} + const onFunctionCallArgumentsDelta = Effect.fn("OpenAIResponses.onFunctionCallArgumentsDelta")(function* ( state: ParserState, event: OpenAIResponsesEvent, @@ -615,6 +789,17 @@ const onOutputItemDone = Effect.fn("OpenAIResponses.onOutputItemDone")(function* if (isReasoningItem(item)) { const events: LLMEvent[] = [] const providerMetadata = reasoningMetadata(item) + const reasoningItem = state.reasoningItems[item.id] + if (reasoningItem) { + const lifecycle = Object.entries(reasoningItem.summaryParts) + .filter((entry) => entry[1] === "active" || entry[1] === "can-conclude") + .reduce( + (lifecycle, entry) => Lifecycle.reasoningEnd(lifecycle, events, `${item.id}:${entry[0]}`, providerMetadata), + state.lifecycle, + ) + const { [item.id]: _removed, ...reasoningItems } = state.reasoningItems + return [{ ...state, lifecycle, reasoningItems }, events] satisfies StepResult + } if (!state.lifecycle.reasoning.has(item.id)) { const lifecycle = Lifecycle.stepStart(state.lifecycle, events) events.push(LLMEvent.reasoningStart({ id: item.id, providerMetadata })) @@ -683,6 +868,10 @@ const step = (state: ParserState, event: OpenAIResponsesEvent) => { event.type === "response.reasoning_summary_text.done" ) return Effect.succeed(onReasoningDone(state, event)) + if (event.type === "response.reasoning_summary_part.added") + return Effect.succeed(onReasoningSummaryPartAdded(state, event)) + if (event.type === "response.reasoning_summary_part.done") + return Effect.succeed(onReasoningSummaryPartDone(state, event)) if (event.type === "response.output_item.added") return Effect.succeed(onOutputItemAdded(state, event)) if (event.type === "response.function_call_arguments.delta") return onFunctionCallArgumentsDelta(state, event) if (event.type === "response.output_item.done") return onOutputItemDone(state, event) @@ -709,7 +898,13 @@ export const protocol = Protocol.make({ }, stream: { event: Protocol.jsonEvent(OpenAIResponsesEvent), - initial: () => ({ hasFunctionCall: false, tools: ToolStream.empty(), lifecycle: Lifecycle.initial() }), + initial: (request) => ({ + hasFunctionCall: false, + tools: ToolStream.empty(), + lifecycle: Lifecycle.initial(), + reasoningItems: {}, + store: OpenAIOptions.store(request), + }), step, terminal: (event) => TERMINAL_TYPES.has(event.type), }, diff --git a/packages/llm/src/protocols/utils/lifecycle.ts b/packages/llm/src/protocols/utils/lifecycle.ts index c249d75cee..21301df47a 100644 --- a/packages/llm/src/protocols/utils/lifecycle.ts +++ b/packages/llm/src/protocols/utils/lifecycle.ts @@ -24,16 +24,24 @@ export const textDelta = (state: State, events: LLMEvent[], id: string, text: st return { ...stepped, text: new Set([...stepped.text, id]) } } -export const reasoningDelta = (state: State, events: LLMEvent[], id: string, text: string): State => { +export const reasoningStart = ( + state: State, + events: LLMEvent[], + id: string, + providerMetadata?: ProviderMetadata, +): State => { + if (state.reasoning.has(id)) return state const stepped = stepStart(state, events) - if (stepped.reasoning.has(id)) { - events.push(LLMEvent.reasoningDelta({ id, text })) - return stepped - } - events.push(LLMEvent.reasoningStart({ id }), LLMEvent.reasoningDelta({ id, text })) + events.push(LLMEvent.reasoningStart({ id, providerMetadata })) return { ...stepped, reasoning: new Set([...stepped.reasoning, id]) } } +export const reasoningDelta = (state: State, events: LLMEvent[], id: string, text: string): State => { + const started = reasoningStart(state, events, id) + events.push(LLMEvent.reasoningDelta({ id, text })) + return started +} + export const reasoningEnd = ( state: State, events: LLMEvent[], diff --git a/packages/llm/src/protocols/utils/openai-options.ts b/packages/llm/src/protocols/utils/openai-options.ts index 59e201421d..53c834be88 100644 --- a/packages/llm/src/protocols/utils/openai-options.ts +++ b/packages/llm/src/protocols/utils/openai-options.ts @@ -7,12 +7,28 @@ export const OpenAIReasoningEfforts = ReasoningEfforts.filter( ) export type OpenAIReasoningEffort = (typeof OpenAIReasoningEfforts)[number] +// Mirrors OpenAI's `ResponseIncludable` union from the official SDK. Keep this +// in lockstep with `openai-node/src/resources/responses/responses.ts`. +export const OpenAIResponseIncludables = [ + "file_search_call.results", + "web_search_call.results", + "web_search_call.action.sources", + "message.input_image.image_url", + "computer_call_output.output.image_url", + "code_interpreter_call.outputs", + "reasoning.encrypted_content", + "message.output_text.logprobs", +] as const +export type OpenAIResponseIncludable = (typeof OpenAIResponseIncludables)[number] + const REASONING_EFFORTS = new Set(ReasoningEfforts) const OPENAI_REASONING_EFFORTS = new Set(OpenAIReasoningEfforts) const TEXT_VERBOSITY = new Set(["low", "medium", "high"]) +const INCLUDABLES = new Set(OpenAIResponseIncludables) export const OpenAIReasoningEffort = Schema.Literals(OpenAIReasoningEfforts) export const OpenAITextVerbosity = TextVerbosity +export const OpenAIResponseIncludable = Schema.Literals(OpenAIResponseIncludables) const isAnyReasoningEffort = (effort: unknown): effort is ReasoningEffort => typeof effort === "string" && REASONING_EFFORTS.has(effort) @@ -35,12 +51,20 @@ export const reasoningEffort = (request: LLMRequest): ReasoningEffort | undefine return isAnyReasoningEffort(value) ? value : undefined } -export const reasoningSummary = (request: LLMRequest): "auto" | undefined => { - return options(request)?.reasoningSummary === "auto" ? "auto" : undefined -} +export const reasoningSummary = (request: LLMRequest): "auto" | undefined => + options(request)?.reasoningSummary === "auto" ? "auto" : undefined -export const encryptedReasoning = (request: LLMRequest) => - options(request)?.includeEncryptedReasoning === true ? true : undefined +// Resolve the OpenAI Responses `include` field. Filters out unknown +// includable values defensively so a typo in upstream config drops the +// invalid entry instead of poisoning the wire body. An empty array (either +// passed directly or produced by filtering) is treated as "no include" and +// returns undefined so the request body omits the field entirely. +export const include = (request: LLMRequest): ReadonlyArray | undefined => { + const value = options(request)?.include + if (!Array.isArray(value)) return undefined + const filtered = value.filter((entry): entry is OpenAIResponseIncludable => INCLUDABLES.has(entry)) + return filtered.length > 0 ? filtered : undefined +} export const promptCacheKey = (request: LLMRequest) => { const value = options(request)?.promptCacheKey diff --git a/packages/llm/src/providers/openai-options.ts b/packages/llm/src/providers/openai-options.ts index 512b94fdf7..329db08d78 100644 --- a/packages/llm/src/providers/openai-options.ts +++ b/packages/llm/src/providers/openai-options.ts @@ -1,5 +1,8 @@ import type { ProviderOptions, ReasoningEffort, TextVerbosity } from "../schema" import { mergeProviderOptions } from "../schema" +import type { OpenAIResponseIncludable } from "../protocols/utils/openai-options" + +export type { OpenAIResponseIncludable } from "../protocols/utils/openai-options" export interface OpenAIOptionsInput { readonly [key: string]: unknown @@ -7,7 +10,10 @@ export interface OpenAIOptionsInput { readonly promptCacheKey?: string readonly reasoningEffort?: ReasoningEffort readonly reasoningSummary?: "auto" - readonly includeEncryptedReasoning?: boolean + // OpenAI Responses `include` wire field. Mirrors the official SDK's + // `ResponseIncludable[]` union exactly so AI SDK callers and direct + // native-SDK callers share one shape and no translation is required. + readonly include?: ReadonlyArray readonly textVerbosity?: TextVerbosity } @@ -25,7 +31,7 @@ const openAIProviderOptions = (options: OpenAIOptionsInput | undefined): Provide promptCacheKey: options?.promptCacheKey, reasoningEffort: options?.reasoningEffort, reasoningSummary: options?.reasoningSummary, - includeEncryptedReasoning: options?.includeEncryptedReasoning, + include: options?.include, textVerbosity: options?.textVerbosity, }), ) @@ -42,6 +48,12 @@ export const gpt5DefaultOptions = ( return openAIProviderOptions({ reasoningEffort: "medium", reasoningSummary: "auto", + // GPT-5 reasoning models are configured stateless (`store: false`) by + // `openAIDefaultOptions` below, so the only way a follow-up turn can + // carry reasoning state is via the encrypted reasoning include. Without + // this, callers using the default model facade get reasoning summaries + // they cannot replay statelessly. + include: ["reasoning.encrypted_content"], textVerbosity: options.textVerbosity === true && id.includes("gpt-5.") && !id.includes("codex") && !id.includes("-chat") ? "low" diff --git a/packages/llm/src/providers/openai.ts b/packages/llm/src/providers/openai.ts index c4335f8411..098cad8493 100644 --- a/packages/llm/src/providers/openai.ts +++ b/packages/llm/src/providers/openai.ts @@ -5,7 +5,7 @@ import * as OpenAIChat from "../protocols/openai-chat" import * as OpenAIResponses from "../protocols/openai-responses" import { withOpenAIOptions, type OpenAIProviderOptionsInput } from "./openai-options" -export type { OpenAIOptionsInput } from "./openai-options" +export type { OpenAIOptionsInput, OpenAIResponseIncludable } from "./openai-options" export const id = ProviderID.make("openai") diff --git a/packages/llm/src/route/client.ts b/packages/llm/src/route/client.ts index 6b5c47f768..63993bc2b7 100644 --- a/packages/llm/src/route/client.ts +++ b/packages/llm/src/route/client.ts @@ -283,7 +283,7 @@ function makeFromTransport( ) return events.pipe( Stream.mapAccumEffect( - protocol.stream.initial, + () => protocol.stream.initial(request), protocol.stream.step, protocol.stream.onHalt ? { onHalt: protocol.stream.onHalt } : undefined, ), diff --git a/packages/llm/src/route/protocol.ts b/packages/llm/src/route/protocol.ts index 3ce0f7827d..acb1e78c67 100644 --- a/packages/llm/src/route/protocol.ts +++ b/packages/llm/src/route/protocol.ts @@ -52,8 +52,8 @@ export interface ProtocolBody { export interface ProtocolStream { /** Schema for one decoded streaming event, decoded from a transport frame. */ readonly event: Schema.Codec - /** Initial parser state. Called once per response. */ - readonly initial: () => State + /** Initial parser state. Called once per response with the resolved request. */ + readonly initial: (request: LLMRequest) => State /** Translate one event into emitted `LLMEvent`s plus the next state. */ readonly step: (state: State, event: Event) => Effect.Effect], LLMError> /** Optional request-completion signal for transports that do not end naturally. */ diff --git a/packages/llm/test/continuation-scenarios.ts b/packages/llm/test/continuation-scenarios.ts index 93677bd71b..1bb1848b55 100644 --- a/packages/llm/test/continuation-scenarios.ts +++ b/packages/llm/test/continuation-scenarios.ts @@ -97,7 +97,7 @@ export function continuationRequest(input: { tools: features.has("tool-call") ? [continuationTool] : [], cache: "none", providerOptions: features.has("encrypted-reasoning") - ? { openai: { store: false, includeEncryptedReasoning: true, reasoningSummary: "auto" } } + ? { openai: { store: false, include: ["reasoning.encrypted_content"], reasoningSummary: "auto" } } : undefined, generation: { maxTokens: 80, temperature: 0 }, }) diff --git a/packages/llm/test/fixtures/recordings/openai-responses/openai-responses-gpt-5-5-image-tool-result.json b/packages/llm/test/fixtures/recordings/openai-responses/openai-responses-gpt-5-5-image-tool-result.json index 1891d0c7e0..21fb8944f4 100644 --- a/packages/llm/test/fixtures/recordings/openai-responses/openai-responses-gpt-5-5-image-tool-result.json +++ b/packages/llm/test/fixtures/recordings/openai-responses/openai-responses-gpt-5-5-image-tool-result.json @@ -2,7 +2,7 @@ "version": 1, "metadata": { "name": "openai-responses/openai-responses-gpt-5-5-image-tool-result", - "recordedAt": "2026-05-22T01:56:45.892Z", + "recordedAt": "2026-05-23T23:19:19.231Z", "provider": "openai", "route": "openai-responses", "transport": "http", @@ -28,14 +28,14 @@ "headers": { "content-type": "application/json" }, - "body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Read images carefully. Reply only with the visible text, lowercase, no punctuation.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Use the read_screenshot tool, then reply with the words shown.\"}]},{\"type\":\"function_call\",\"call_id\":\"call_screenshot_1\",\"name\":\"read_screenshot\",\"arguments\":\"{}\"},{\"type\":\"function_call_output\",\"call_id\":\"call_screenshot_1\",\"output\":[{\"type\":\"input_text\",\"text\":\"Image read successfully\"},{\"type\":\"input_image\",\"image_url\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAnYAAACKCAYAAAAnmweyAAACKWlUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4KPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iWE1QIENvcmUgNi4wLjAiPgogPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4KICA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIgogICAgeG1sbnM6ZXhpZj0iaHR0cDovL25zLmFkb2JlLmNvbS9leGlmLzEuMC8iCiAgICB4bWxuczp0aWZmPSJodHRwOi8vbnMuYWRvYmUuY29tL3RpZmYvMS4wLyIKICAgZXhpZjpQaXhlbFhEaW1lbnNpb249IjYzMCIKICAgZXhpZjpVc2VyQ29tbWVudD0iU2NyZWVuc2hvdCIKICAgZXhpZjpQaXhlbFlEaW1lbnNpb249IjEzOCIKICAgdGlmZjpZUmVzb2x1dGlvbj0iMTQ0LzEiCiAgIHRpZmY6WFJlc29sdXRpb249IjE0NC8xIgogICB0aWZmOlJlc29sdXRpb25Vbml0PSIyIi8+CiA8L3JkZjpSREY+CjwveDp4bXBtZXRhPgo8P3hwYWNrZXQgZW5kPSJyIj8+at0SpgAACrhpQ0NQSUNDIFByb2ZpbGUAAEiJlZcHUFNZF8fvey+dhJYQASmh994CSAmhBVCQDjZCEiAQQkxBwa4sruBaUBHBsqKrIgo2qg0RxbYo9r4gi4iyLhZsqHwPGMLufvN933xn5s75zXnn/u+5d959cx4AFFOuRCKC1QHIFsul0SEBjMSkZAb+JcACTUACnoDK5ckkrKioCIDahP+7fbgLoFF/y25U69+f/1fT4AtkPACgKJRT+TJeNsonAIABTyKVA4CgDEwWyCWjfB9lmhQtEOWBUU4fY8yoDi11nGljObHRbJQtASCQuVxpOgBkVzTOyOWlozrkWJQdxXyhGOUClH2zs3P4KLehbInmSFAe1Wem/kUn/W+aqUpNLjddyeN7GTNCoFAmEXHz/s/j+N+WLVJMrGGBDnKGNDQa9Xrouf2elROuZHHqjMgJFvLH8sc4QxEaN8E8GTt5gmWiGM4E87mB4Uod0YyICU4TBitzhHJO7AQLZEExEyzNiVaumyZlsyaYK52sQZEVp4xnCDhK/fyM2IQJzhXGz1DWlhUTPpnDVsalimjlXgTikIDJdYOV55At+8vehRzlXHlGbKjyHLiT9QvErElNWaKyNr4gMGgyJ06ZL5EHKNeSiKKU+QJRiDIuy41RzpWjL+fk3CjlGWZyw6ImGMQAOVAAPhCCHMAAgaiXAQkQAS7IkwsWykc3xM6R5EmF6RlyBgu9dQIGR8yzt2U4Ozq7AzB6h8dfkXf0sbsJ0a9MxlZVAeDTNDIycnIyFnYDgKMpAJDqJmOWcwBQ7wPg0imeQpo7Hhu7a1j0y6AGaEAHGAATYAnsgDNwB97AHwSBMBAJYkESmAt4IANkAylYABaDFaAQFIMNYAsoB7vAHnAAHAbHQAM4Bc6Bi+AquAHugEegC/SCV2AQfADDEAThIQpEhXQgQ8gMsoGcISbkCwVBEVA0lASlQOmQGFJAi6FVUDFUApVDu6Eq6CjUBJ2DLkOd0AOoG+qH3kJfYAQmwzRYHzaHHWAmzILD4Vh4DpwOz4fz4QJ4HVwGV8KH4Hr4HHwVvgN3wa/gIQQgKggdMULsECbCRiKRZCQNkSJLkSKkFKlEapBmpB25hXQhA8hnDA5DxTAwdhhvTCgmDsPDzMcsxazFlGMOYOoxbZhbmG7MIOY7loLVw9pgvbAcbCI2HbsAW4gtxe7D1mEvYO9ge7EfcDgcHWeB88CF4pJwmbhFuLW4HbhaXAuuE9eDG8Lj8Tp4G7wPPhLPxcvxhfht+EP4s/ib+F78J4IKwZDgTAgmJBPEhJWEUsJBwhnCTUIfYZioTjQjehEjiXxiHnE9cS+xmXid2EscJmmQLEg+pFhSJmkFqYxUQ7pAekx6p6KiYqziqTJTRaiyXKVM5YjKJZVulc9kTbI1mU2eTVaQ15H3k1vID8jvKBSKOcWfkkyRU9ZRqijnKU8pn1SpqvaqHFW+6jLVCtV61Zuqr9WIamZqLLW5avlqpWrH1a6rDagT1c3V2epc9aXqFepN6vfUhzSoGk4akRrZGms1Dmpc1nihidc01wzS5GsWaO7RPK/ZQ0WoJlQ2lUddRd1LvUDtpeFoFjQOLZNWTDtM66ANamlquWrFay3UqtA6rdVFR+jmdA5dRF9PP0a/S/8yRX8Ka4pgypopNVNuTvmoPVXbX1ugXaRdq31H+4sOQydIJ0tno06DzhNdjK617kzdBbo7dS/oDkylTfWeyptaNPXY1Id6sJ61XrTeIr09etf0hvQN9EP0Jfrb9M/rDxjQDfwNMg02G5wx6DekGvoaCg03G541fMnQYrAYIkYZo40xaKRnFGqkMNpt1GE0bGxhHGe80rjW+IkJyYRpkmay2aTVZNDU0HS66WLTatOHZkQzplmG2VazdrOP5hbmCearzRvMX1hoW3As8i2qLR5bUiz9LOdbVlretsJZMa2yrHZY3bCGrd2sM6wrrK/bwDbuNkKbHTadtlhbT1uxbaXtPTuyHcsu167artuebh9hv9K+wf61g6lDssNGh3aH745ujiLHvY6PnDSdwpxWOjU7vXW2duY5VzjfdqG4BLssc2l0eeNq4ypw3el6343qNt1ttVur2zd3D3epe417v4epR4rHdo97TBozirmWeckT6xnguczzlOdnL3cvudcxrz+97byzvA96v5hmMU0wbe+0Hh9jH67Pbp8uX4Zviu/Pvl1+Rn5cv0q/Z/4m/nz/ff59LCtWJusQ63WAY4A0oC7gI9uLvYTdEogEhgQWBXYEaQbFBZUHPQ02Dk4Prg4eDHELWRTSEooNDQ/dGHqPo8/hcao4g2EeYUvC2sLJ4THh5eHPIqwjpBHN0+HpYdM3TX88w2yGeEZDJIjkRG6KfBJlETU/6uRM3MyomRUzn0c7RS+Obo+hxsyLORjzITYgdn3sozjLOEVca7xa/Oz4qviPCYEJJQldiQ6JSxKvJukmCZMak/HJ8cn7kodmBc3aMqt3ttvswtl351jMWTjn8lzduaK5p+epzePOO56CTUlIOZjylRvJreQOpXJSt6cO8ti8rbxXfH/+Zn6/wEdQIuhL80krSXuR7pO+Kb0/wy+jNGNAyBaWC99khmbuyvyYFZm1P2tElCCqzSZkp2Q3iTXFWeK2HIOchTmdEhtJoaRrvtf8LfMHpeHSfTJINkfWKKehzdI1haXiB0V3rm9uRe6nBfELji/UWCheeC3POm9NXl9+cP4vizCLeItaFxstXrG4ewlrye6l0NLUpa3LTJYVLOtdHrL8wArSiqwVv650XFmy8v2qhFXNBfoFywt6fgj5obpQtVBaeG+19+pdP2J+FP7YscZlzbY134v4RVeKHYtLi7+u5a298pPTT2U/jaxLW9ex3n39zg24DeINdzf6bTxQolGSX9Kzafqm+s2MzUWb32+Zt+VyqWvprq2krYqtXWURZY3bTLdt2Pa1PKP8TkVARe12ve1rtn/cwd9xc6f/zppd+ruKd335Wfjz/d0hu+srzStL9+D25O55vjd+b/svzF+q9unuK973bb94f9eB6ANtVR5VVQf1Dq6vhqsV1f2HZh+6cTjwcGONXc3uWnpt8RFwRHHk5dGUo3ePhR9rPc48XnPC7MT2OmpdUT1Un1c/2JDR0NWY1NjZFNbU2uzdXHfS/uT+U0anKk5rnV5/hnSm4MzI2fyzQy2SloFz6ed6Wue1PjqfeP5228y2jgvhFy5dDL54vp3VfvaSz6VTl70uN11hXmm46n61/prbtbpf3X6t63DvqL/ucb3xhueN5s5pnWdu+t08dyvw1sXbnNtX78y403k37u79e7Pvdd3n33/xQPTgzcPch8OPlj/GPi56ov6k9Kne08rfrH6r7XLvOt0d2H3tWcyzRz28nle/y37/2lvwnPK8tM+wr+qF84tT/cH9N17Oetn7SvJqeKDwD40/tr+2fH3iT/8/rw0mDva+kb4Zebv2nc67/e9d37cORQ09/ZD9Yfhj0SedTwc+Mz+3f0n40je84Cv+a9k3q2/N38O/Px7JHhmRcKXcsVYAQQeclgbA2/0AUJIAoKI9BGnWeI89ZtD4f8EYgf/E4334mKGdSw3qRtsjdgsAR9BhvhwANX8ARlujWH8Au7gox0Q/PNa7jxoO/Yup8UK0Vjk9ta0C/7Txvv4vdf/TA6Xq3/y/AOOhDyne6KAWAAAAimVYSWZNTQAqAAAACAAEARoABQAAAAEAAAA+ARsABQAAAAEAAABGASgAAwAAAAEAAgAAh2kABAAAAAEAAABOAAAAAAAAAJAAAAABAAAAkAAAAAEAA5KGAAcAAAASAAAAeKACAAQAAAABAAACdqADAAQAAAABAAAAigAAAABBU0NJSQAAAFNjAAAAAAAAAADxh4F4AAAAHGlET1QAAAACAAAAAAAAAEUAAAAoAAAARQAAAEUAAAbT33OL9AAABp9JREFUeAHs3F9olWUcB/DnLHCT/rgKQxbhtLwpkIqsLrxZQfQXKggEA/tjZuCFCRHR1Wg3XiyhoKgVeKFd1k1CFNGNRAhhkFAQFBlSkLhjbqtNbW3jeOB0dt6dHc905/d8dnXe5332nvf3+b7jfGXMUt+NG6aTLwIECBAgQIAAgY4XKCl2HZ+hAQgQIECAAAECcwKKnQeBAAECBAgQIBBEQLELEqQxCBAgQIAAAQKKnWeAAAECBAgQIBBEQLELEqQxCBAgQIAAAQKKnWeAAAECBAgQIBBEQLELEqQxCBAgQIAAAQKKnWeAAAECBAgQIBBEQLELEqQxCBAgQIAAAQKKnWeAAAECBAgQIBBEQLELEqQxCBAgQIAAAQKKnWeAAAECBAgQIBBEoPT+4NHp+WY58edPaeST1+c7ZY0AAQIECBAgQGAZCpQ+H/ln3mJXPnMy7R4eWIa37JYIECBAgAABAgTmE1Ds5lOxRoAAAQIECBDoQIFFF7uelb0dOKZbJkCAAAECBAjEF1Ds4mdsQgIECBAgQCATAcUuk6CNSYAAAQIECMQXUOziZ2xCAgQIECBAIBMBxS6ToI1JgAABAgQIxBdQ7OJnbEICBAgQIEAgEwHFLpOgjUmAAAECBAjEF1Ds4mdsQgIECBAgQCATAcUuk6CNSYAAAQIECMQXUOziZ2xCAgQIECBAIBMBxS6ToI1JgAABAgQIxBdQ7OJnbEICBAgQIEAgEwHFLpOgjUmAAAECBAjEF1Ds4mdsQgIECBAgQCATAcUuk6CNSYAAAQIECMQXUOziZ2xCAgQIECBAIBMBxS6ToI1JgAABAgQIxBdQ7OJnbEICBAgQIEAgEwHFLpOgjUmAAAECBAjEF1Ds4mdsQgIECBAgQCATAcUuk6CNSYAAAQIECMQXUOziZ2xCAgQIECBAIBMBxS6ToI1JgAABAgQIxBdQ7OJnbEICBAgQIEAgEwHFLpOgjUmAAAECBAjEF1Ds4mdsQgIECBAgQCATAcUuk6CNSYAAAQIECMQXUOziZ2xCAgQIECBAIBMBxS6ToI1JgAABAgQIxBdQ7OJnbEICBAgQIEAgEwHFLpOgjUmAAAECBAjEF1Ds4mdsQgIECBAgQCATAcUuk6CNSYAAAQIECMQXUOziZ2xCAgQIECBAIBMBxS6ToI1JgAABAgQIxBdQ7OJnbEICBAgQIEAgEwHFLpOgjUmAAAECBAjEF1Ds4mdsQgIECBAgQCATgYbFburcZNoxdFcdQ8/K3ro1CwQIECBAgAABApdfoGGx+3d6Oj03uLHuDhW7OhILBAgQIECAAIFlIaDYLYsY3AQBAgQIEMhLYOvWp5dk4IMHDyzJdTvloopdpyTlPgkQIECAQCABxW5pwlTslsbVVQkQIECAAIECAcWuAOciTil2F4HnWwkQIECAAIHWBBS71twW+i7FbiEh5wkQIECAAIG2Cyh2bSedu2DDYnf2/Nn0wht31r2rv4qtI7FAgAABAgQILFJAsVskWJPbGxa78pmTaffwQN1lFLs6EgsECBAgQIDAIgUUu0WCNbldsatA3XrbxnTfA4/VsH343r7098REzVrRQXd3d3p+58upq+uK6rYD+99N5dFT1WMvFhboX3dzevTxLdWN4+Nn0v6Rt9P0zP+t6IsAAQKdIuAzoTgpxa7Yp9Wzil1FbuD+B9OLu16pcdzxzJPpr9Ona9aKDtb2r0t7931Qs2Xv0Gvp6LdHatYcFAs89MgTadv2XTWbtm15OE1OTtasOSBAgMByFvCZUJyOYlfs0+pZxa4ip9g1foRW9fam/nW3NN4wc+b8uXPp2PffFe5p9qRi16yUfQQILGcBxa44HcWu2KfVs4pdRU6xa/wI3X3v5rTn1cHGGypn3hoeSl8f/mrBfQttUOwWEnKeAIFOEFDsilNS7Ip9Wj2r2FXk/l/spqYm085nn0oTE+NN20b9IW622I2882b68otDTXs12qjYNZKxXiSwevUNqdTVlaZmfmVfLo8WbXWOwCURiPqZ0C48xa5dkrXXUewqHrO/buzru6mqMzrzBw9//H6ietzMi+6enrR+/Yaarb8d/yWNjY3VrLXjYLb8XHnV1dVLfXbo44t6n7X969OmezZXr3f815/TkW8Ozx3ffsemtP2lPdVzF15cs2pVWrGi+8Jhalexu/a669OaNX3V686++PGHYx3xxxNFjjUDOWirQKlUSvs/+jTN/gyWy6fm/lHW1jdwMQItCFzKz4QWbu+yf4titzQR/AcAAP//YCg3bwAAJAVJREFU7V0HvNXE0x1p0osivYMgIB2xIXb5oyKIoCAovUuvgvTee5WOFBEQULBgAxERQUBAlN5BQOkKCvrNCW7e3tzklpd738v73gy/R5LdzWZzNjc5Ozsze9unb1/7l2zkwuVz1H7U4345KVNl9EuThLhFIGu27DRuyjvmRU8cP0LdOzanv//+20wLd6dVu+5U6bGnzdMmjxtK679aax7b7aRPn4Gmz11uZr09eTR9vna1eZwYd2KDY2LEKdL3nCVrNho/dYFR7fXr16l+7WcjfQmpTxAQBCKMQN269SJc463qFiyI+T5G5QIer/Q2IXYe7yGb5tWoVY9efrWhkXPz5k3q1a01HTywz6ZkaEkpUtzOBG0ZpUyZyjhh86YNNHpYn6AnC7HzhSi2OPrWErdHadOmpatXr9K//9qO7+K2MS6uVrb8A9S15yCjhvggdv9fcHTRBYnm1HTp0tHly5cTzf1G80aF2EUH3URD7PIXKES33XZbyCheu/YnnTxxPGD53HnyUvLkKQKWiS3hypkzN2VhzVzmzFkp0x13Egjc2TOn6fSpE9SmQw+6K2t247pLFs2h5UvmB2wDMlOlTk158uSnzHdlpTvvykKpUqWmSxfO06+nT1KuPPno1debGXVc5LQu7RrRpUuXgtYZCWKXNGkyypsvf8Br/fnnH3Tq5ImAZVRmnrz5KVmyZHSDtZdHjx5WycY2W/achOcAcsedd/G9n6Bf9uwK6yUdDRyNBrn4D23KzvdmJ+fOnaFLFy+aWbfffjs9VbkqFSlawsDirizZ6N0Fs+j9pbe0XWZBm50778zMfVWQUqZKRRkyZKKzZ0/T0SOH+Ln8NWximJKfv/wFClKWLPyM8zMJuXDhd7rIf6f4d3fixDGbFtgn4Z5q1m5AVau/bBbo0bmFuW/dOcbPhZ12Oy5wTJ48OeXKnZfy5i9EWfg3fP63c4zhAeNZ/fOPP6xNDek4Y6Y7KD/XlztfAUqaJClBg3/k8AE68+vpgP2C90omPhdy7OgRxuQvSp8hA5Up9wBdv3aNU/+lrd9vMtJRpmChIlSocFG6fOkC3bhxg3Zs3/JfOeRGT25PmZLwPoTg/fQbYwbB+6fYvaX4PZmDf/PJ6ejh/XRg/146//tvRn6g//AsZ8iYybbI8WNH6a+/rpt5KPt0lWpUoGBhfmbvprTp0lO/nu3pZ353WCWa3wT0c15+v+Hdf8cdmbmNf9H587/RxfO/0+FD+/n3c97anKDHbp/HLFmyGnjgQngX4LlQgmep0N1FCe3+959/jPbu2/uT8VyqMkLsFBKR3SYaYjd38RrCByBU2fvzLur9ZruAxcdOnkvZsucKWKZOjacCvlytJ5e770GqVqMOFb6nuDXL7/jo4YPUvVNz+od/NE6SkV9ez75Qi57mjzk+XMFkxOC3+GX+bbBiRn4kiB0+LlNmLgl4vT27f6R+b3UIWEZlTpy+iIlCFgIxb1DneSM5W/YcVLteE7r/wUp+5P7K5Us0Y+pY2rRxnarCdhtNHG0vGEZimbIVqFuvIbZnrHr/XVo4b7qR91DFx6hu/eZ0Z+YsPmU/WbOCZr89wSdNP6jwQEVq1LwdZcx4iwToedjH4GD8qIH8Uf3FmuV3DK1m5Wer8zNe2/wg+BXihLO/nqIftn5Hy96dx4OMGGKqyqJNjzz2DA9W8hkf9nAGbWs+WEbzZk1WVZnbaOKId0/9xm/Qo09UpqRJk5rXVDvQmH752Uc0d+ZEgsYxFCnOpKZl224mMbae8/tvZ2kSm1Ts3rndmmUcv1ynAdV4+TVjf1DfLnQb/2vTsSelY8KkZOeOrTRicC9qzP2Ptuty6MBeGtyva1gDI/38UPeL3FOM+g259Xyu//JTmjpxBL1StxH977katu/03Tu30dgR/QK2CwPZF158xbYJwwa8Sdt+2GwM2jFYqPbSq37XGTO8L3337dd+50fjmwBi9NIrr9MTT1XhZyeZ3zWRgOcH/bGF392hDPQj9Ty24uev0uPPGG3q2aWlQaxTp07DmNWhKs/XIPzedYGCYuXyRfQeKyTQZiF2OjqR2xdi54BlfBC71xu1omervuTQIv/kQwf30ZudnDUT2XPkpL6DxjmOTP1rJK6vOR06uN8uyy/Ny8QOjW1WvwZrRbJRj74jCC8bJ8HLplv7JnT8+FHbItHG0faiYSQGIiRbNm+kqROGUafuA6ho8ZK2tToRO2g+6zVowR/QF23P0xNv3rxB82dPpY9Xv68n++yDfGG6tEy5+33SAx307NLKljCCaDz9vxcCneqY9/W6z2jSWH8iHC0cc+XOQ+079zE0446N+i/jFGsqRw3t7fgsohhwfIkJGT72wQgtPp4rli00tLLWa+vE7tOPVtEjjz5lO/g7d/ZXR/IY6oyB9drhHOvEDu+848eOGG0NVMcZHhgMHdDdcdYlELED6f/5px+pQ9e+BI22ncQVscM7dvDIqcZg1a4d1rRQzBAi+TzqxG7siP70065t1Kv/KMqdt4C1aT7H0yePoi/WrhFi54NK5A4SDbHr1X8kTyMUC4icrtELhdgNH/M2ZbVMgel14GK1X3wy4DVVJrQpbTv1UofGFtNFZ8+cIkxFpk6VxpiatY7YnBwW8MIfOX4m5cyV16fOC6y6P8+q+yScn4nV+ekz+DrDYJTfummdkLSMkSB2GI3qjiCqsTqOe3bvYI1dR5UVcKs0dig0c9o4qvNaE5PUXblymV/Yu3ha+waVKlPetClE2Y0bvmKt0wDs+khc4OhzwVgclCpdjjoycVOiYwfN1xmewi9eoozKNraYdjvGUycXL16gbzd8aeso06RFB562vaX1VCfjmfydp3czZrqTMEWmCzTHnds2dPyYPlPlBWrUzFcLjg/ROW4fpr4wxYXnQTdvcCJ29Ru3pieefs68vH7PSAyk9dr49Rc0bdJI81y1Ew0ccS/jpsznqf/M6jLGFu374+oVw8zCJ4MPftmzk/r0aG9NNo9BtBs0ecM8VjuYgkydJq2fdgn5g/p0pp0/blNFja1O7FQGNN3QsiRJkkQlmdur/PvBVJs+hYlp305tGpllorGjEzu9fpBWmKZAMBth1ShD2ziob1f9FHMfNsrP8UyGEv352bZ1s2EmgGdcF7w/TvLgD1OeK5ctMLRTej72I/lNQH3tu/SmBx56FLumoB2/8W+QX9L8/s5k9Ifqr2DELtLPo07soIkry4M2Rerwnv1p1w7WnF6iIjwDpc8UwOyiRcNaQuzMXo3sTqIhdqHA1rFbP8IUDyQUYmdXZ3VW29eu19jMCnUqts/A0axRKWWet2LpQlr1/mL644+rZlr69OnZlqgh4QOpZNPG9ca0gzpWW0zT9BowWh2yXcMpg7js3xczXQbSUoy1OCCU+ssaNnawuQkmkSB2Ttfo0WcYlSxd3siOzVSstd5PPlpJSxbMNBwFkAfP4mFMzJXDCDQlHd5oYD2N4gJHv4u6THj4kSeMKTW7ajDlvO7zj2k3v3B1OyJrWdgjjpow25w2xIt4+sSRtH3b98bUP6YT87JNFzQf95Ysa57uRJBRoPeAUWwTVdosu3D+2/TpmpXGtLlKxDNZuEhRKn9/ReODBs1IKHaqtes2puo1XzWqCfZxU9cKto0Ejs+9UJNea9jSvBRIw9LFcwybKGiKYYdUslQ5atGmm2Ebqgo6mUSAgMD7V/1e8fGcxv2yY/v3bH92wegv2JnWqtOQ4FCi5CBPk/fs2tpnwGYldqdOHqeBvTsZpK43vzuUHS/qwHsGnvJoc9tOb7FZwyNG1ZHCWrXTbmtH7E6eOEojh/TyGUQ88FAlgle6Pv0X6gxE05Yd6clnYgYKqh0YyHy8ejlt/nY94d0JMhmuxPabgPuYMf99837wLZg8bgj9sOU7H/MblCtZuiyVr1CRSpQqawzMndoY6edRJ3b6NX/atZ2mc5QERbzxnsXvvwDbaSpp0bAmPfecP+Yq381WvGIl3In5/MQnsZuz6EOTZOAl3IOnoOwEH77xU98xpwhg39Su5S07Gb3889VqGdNoKm34wB6GzZI61rewnWnZJmZki2kqTFcFk4RC7EAgVi1f7Hc7TVvxy/w/rQ9e4K+/UsXvxR0XOPo1zGWCHSH5lTUbs6aPYwKwNaTa23XuTQ8+fEtTAGzat6xnGq3rFcD2cMS4maZdFj58bZrVoXPnzurFjP05Cz9gx4vUxj5e/P17dfIrE9uEuCJ24eCIe53Av1Vls3bu7BkOS9SUrly54nebsEeCFlLJ3p93s41vW3VobmF/Cy20kvmzp9DqVUvVoblNkyYNDR093XxPIGMwa69+ZC2WEiux07WjLd7oQo89+T9VlJq8Vs1s90MVHzfIncqELSs0fdESO2LXsvHLtk4S1ncZbPImjx8WtGl2xG7Xjz+w1n9syI5bTheJLbGDo9eQUdPMapcunktL2eY0thKN59GO2H3/3Tc0bmR/H0cKtLlipSfojQ49zeb3ebMNlS8XMyg0MyKwI8ROiJ35GMUXsQNZW7hsrWkvE0xbOGbSXMqe45bTBj407Vq9bt6D2qnJ9jc1a9dXhzwl0YV27vjBPNZ3rERg4pjBtGH953oR2/2EQOzg7QmvTzuB8bTyBkZ+3ZqVjWlavWxc4KhfLxL71v4ESZ8+aZTp3RjsGpjWmvXOKvN5XPfFJzRlwnDH0+AM0bBpGzPf6VmbtWCVOS0O78ZWTV4xNEDmiS524oLYhYtj+QoPUuc3B5p3NXxQT9a2bDKPrTuDR0w2NRrQzjSq+4K1CA0dNZXysWcmxE4Lp5+gh4BBupUEWomdbjYC8ggSqUTPK1GyDPXsFzOVDVtWOwcXda7brZXYbdn8LWvr3rKtFhrNabOXmgMIOJh17dDUtqyeaCV2E8cM4nfgF3qRWO/Hltjly1/QIOfqwnDWgAY7thKN59FK7DBgw/Q3NLtWKXR3ERo4PMZpCQONEvcWtRaLyLEQOyF25oMUX8QODRjJWg+EHYFA6zFj6hjDuFRX/cO+7rlqNenV12JeVNvYc3AYa+OsgmmJ9l36mMnwmMLUhQoVoDIwJdm911CTKCJdeTepMk7bhEDsAk2FW22V7IhdXODohG9s063Erm2Luj4hBoLVa9UUDBvIXoI8hegk1vJOdp86KUFdsIGaN2tSSNP+TtdW6XFB7MLFUdfC4XfckInaNbaXdRI4qkBDrETXkqm0We+sNOzocPzBindpwdxbHs8qX99aCTocW+bMmGgW0YkdbDHbtKhn5j3Kno7wuIWgzQ1erWrm3VP0Xuo7eJx5HNfEDs4N8Gx2ki49BhKiC0BgF9j4tepORc10ndj9zuFUMOiIlMSW2MHhC4MhJXiG1n78AU/lzw4pHJU6T22j8TxaiV3T16s7eiPDRGD42BmqOYYGWYidCUdEd8TGToMzPomdnaE6HB2OHD7ExtApjLhb+ThWlZrWUc2GBx1U31ZB7KUJHPpDGdUiHy+GA/t+ZsPya6wmZ/settnD6B8aQyWIf9Wtw62YdirNaet1Yvcl25JN49AIThIKsYsLHJ3aF9t0t8TOSmZhi3fk0H7H5qRNl8FnYAD70MVsz2gVK94qH/aNMOyHRx1CVcQm+KsXiV19dnCo8p9HMRwbMH0YSODlC29fJdYBFoIgz5i/UmWTE4E2C/DO9DnLTAcp6yBQJ3bo3268eo0SLxO7YNq0Zq06sWPNs+pWjLBHwaaKdWKHKfM32JwgUhJbYofr698k1R44KUFbu4t/M7v5NwOHMDhDBZNIP4+4nk7sgpFoIXbBeihy+ULsNCz1H1Gw6VDtNJ/d2P6I8dIeMHQiZf8vEKdPpQ4HiHsFt3Fdq6cXtfNC1POt+/hh9u/VkcnkQWuW7bHXid1HHy7nuGCTbNuORCvRsNPYoVy0ccQ1Iiluid0LHGNO1wqH27bPPvnQ0Dhbz4PDRa06DYwpPn0woZfDRwteoXAcCqQl1M/BvheJna45cnLO0e8DS/rB+F/JkP7daMe2LerQCCit21whduBG9mgOJLDHRSBkyH4ODvtWt5gp84RK7Ky4WO8fzmt4Dytpz6YqyohfpVm3XiV2GHzDsUZ3hLG2HcGkEXdvycKZPs4k1nKRfh5Rv07sgikFhNhZeyR6x0LsNGzjk9ihGXdxYN1GzTtwnK8KWqv8d+Hh+sGKJfTZJx84kjp1FpwD4CQQSPAxhTfj+0vmhRXxP7EQO2AXTRwD9U1s8twSO+uUYLhtgAfy7OnjHU+DpzFisN1TrKSPRtl6wtqPV/FU7ZSQtBFeJHb9Bo81VvjAfZ0+dZzat4qxebXeK46thv9dObYiovkrsdq2BdNc4byJ0xeaMeisSwUmVGIH+zrY2TmJ/iygjJOjhX6+V4mdaiPCDj31zPPGiiVOgyJ4KL/DzjRr+btgJ5F+HnENIXZ2SMd/mhA7rQ/im9ihKbrHGZYY2rrlW8NOBPGjECj0NIckwFI+IGOhij5q//zT1XSDQySk4PhaqA9LTu1hg9czvCxUuGIldsFsX8KpPxLhTiKlsVPtjhaOqv5Ibd0SO2tIBESy3/fLTyE3D0vfOQV71ivBmpvFS5Tlv9JGaJusvDSUVUINgKt/zCMVgsMtjnoMMjiLNOfwDoEEwckRpFyJ1dsUgWVHjp+tsmkmr5ji9BFXhXSbvA9XvkfvzJmqsiihEjvEIMRshZPoJA1G/PVqVQ46ANbP8dJUrPUe4YVeileaKVXmPirBYYaspjkoj+XO9vy003qqT0y8SDyPuIAQOz+YPZEgxE7rBi8Qu85vDuB4RA8ZrRrA06KIN+ZGdM+qcAL9hnJNrEwwf8nHpo2e9cMRSh1OZbxG7KKJoxMGsU13S0hgeI5pGyWhekmr8rHdYs3g1xq28omLF2oAXJ3YYRCEj7lbcYsjprMxra0kkGE5yui2YZd5GbWm7G2qC+KVzV282vy9WZ0h9LLYh33opBnvmsnQokKbqiShEjuE/EDoDyeBJzI8QCGhkrSEQuz0e4bmDlO0r3OcRD1QPjTdCM5ulUg/j6hfiJ0VZW8cC7HT+iG+iR28oKaxsTMWZoY0a8BhBLQF3LWmhryrhy1w+sGHXJlNwcn84VBR9SNJHL1G7KKNow20sU5yS0hy5WLNEAcnVuIUU03lR3ILz++ps5aYmohQtW/WsDQIfhqbRdH1e3GLI6bP4BSlZArHU1vHcdXsBKt4jGJtHNY5huzfu4ft4fxXl5g6+z1zhQXY7XXh6Vp94XW9bmsYmqH9uxsBplWZhErsjh89TJ3bxQSBV/eDLXA0wp1wQFxIqM9uQiR2xg3yfxgQDRsT420K21R4slslGs+jEDsryt44FmKn9UN8EzvdEw3NQtwieLDi7y+2n7jEyz9hibFjRw/xeolHg04voI4J0xb4BCmFsTU+ltc5oOi1P/80lqaB/Q/WYLQLnIo6Akn/IeOo8D33mkXe6trKiNBuJvy3A+eQv1mTAkPfUMRrxC7aOIaCSahl3BISaGInz3yXvaZjlptDwNFvv1nn2IQCBe82FmVfveo9R+cbxLFCQO1AXq/w4h41fpbpRATP8BaNAnuTolHWe0ZYj0Dr1jreiJZhrTPccCe6lhfVIjZd947NbEPPNG/dmR7nRd6VIG4g4gdapVP3/nTf/Q+byU5auxw5c9GQkdPMZd8Q77Jjm4Y+8cUSKrHDzTvFSqxcpRo1bBYT2Bne2fDSDiZeJHYgqblz5zV+TwgS7iRYhm/qrPfMbMRKRMxEq0TjeRRiZ0XZG8dC7LR+CIfYQbuWiX9QVqlWsy7Bu01JZ36Z6l6rsG/79fQple2zta5y4JNpOcAHb/Omb+iD9xfRWXbPtxNoP6bNWUpp06azy/ZJU96IWJgZwYn1NvsUtBxgzcUatWLiX8GzFtHaf2Q7QNSBtWof5Qj2lR57xlj6bOv3vkbPeCmlYSyt0qPvcHNtQRBa2I3ocpU/khd4zVur6GvFRsrGLi5wtN5HOMdwutGXUXq40pNUgxeJV4LR+xmHZ+4k22za9bWdswjsudZ9/pHhYIO1hhEkOwdr9x59vLK5Fu0AXpJq987t6tI+W9h74WP14/YfaNM3X7JjwEEeWJw1gtsivWDBwlT1xTo+zkNbNm804i/6VGRzYNVaIPYaAlNv5OtA6w17vrwcLqjCg5VoB3sQol6rRANH/Z2C6x3je/549Qra+8suXgLsPBW6uyiVYHspFRYFZbBcVue2jW3taLGMG1aU0A3oYQKxi2MCHtj/s6E9L1zkXsPjOyeTAiV2jhYJmdghBA+mG7d8t8FYJhD2vlg7+JW6jUxsMIBt3eRlvwErsMueIyfdxv+UwMEMzjxKOtksL4g8hBVxskeO9DcBfY1lDzEg+H7TBg5rtYG/HSf4fX/WiC2IwXIJXo4OnuY5cuZRTWfv2Nm0/L13zGN9J9LPoxA7HV3v7Aux0/pC1xIhntaA3p21XN/dQSMmUcFC9/gmhnBkDQSqn4IXDpbygXdcqIIXzdgR/clKmNT5CB7bq/8oM6ipSg+0xb0PHfCmETsvUDnkYekirF2ZJgTyaF3/Ekvc4GOvx9oLdj2V/xXHqJtqE6MuGsQO14w2juq+wt1aDerDPb9+7WcNDa71PDyLQ0ZOMVc5sOY7HQcjdlik3iogljpR0fOd4jTqZdT+m72HGkbl6thpixUksGyeLtHCEdPawzn4eDjP+OhhfXjQtkFvns9+m449DQ2lT2KAA8So696phR+BT8jETr9dzAJgYGAVJ+9sa7xA63mBjkGee3aJWfpNLxvpb4Iidvo11L7TbwaEF8oEJ/IZ6edRiJ3qEW9thdhp/aFPtwULbjts9DRDA6CdHtJuIGKHCvCBK1m6HAclTmloYVLwEjlp06WnrFlzGAvXI0gxjnXBi63jG/X9VpVQZbJlz2FozlKkSGHUiZdg5sxZKQt7IWLkmidvAb8P60ccpX6uFqVe1WW3xRRys9adeAHyZHbZZpqV2Fkjq5sFQ9iJa2KHJkUbxxBu26+IVVPlVyBIghOxw2nQYLXgNYSLlygTpJZb2dCsYhH5o2wDZSe6h6ZdvjUNWgdoH0IVtHcQk1F9CtnuXDtiF00cKzxQkTDVGmzwc4W13VMnDAsYygP3A01Ns9ZdCPUGE5hzTJ80wtBqWcsmVGIHu7nC9xS33o7P8aaN6w3ybhe4F9pRBOuNjQQidpH+JgQidnZthwfw2BH9bAPW6+Uj+TwKsdOR9c6+ELv/+gIhF6DZUrJo/gxauXyROvTbWm3L/Ao4JFiDhDoUc0yG/VP5Cg/zGqdNzcCjKAwNBD5YsREQlipVaxLsU5TAFqpdy5jpPJXutIVGCx8vTHdZtS8Iq7Jh3ee05sOlPs4gIJgz5q0wnUWc6rZLR9+gj6wyasIsg8QiPZyp2CuXLxleiHbTktZrOB1HAkenup3Scc0xk+b5Ye5UXk/HtHmzBi/52F3p+dhHXyJ+FkJxwPPOqnmC4f6BfXt4qaNVhI+pkyE/6sIg4v4HH6X7ebm7/LziiRJMmWGNTwjwx+LrCLFiF7JBneO0Bel5+dXGxsoD+K1Y5TcO74MYkFb7u2jjmDnzXWz71Y6KFithaM+hWVHT5zCr2PvLHpo3cyKHHzprbbLjMTRP6Jds3C/oJ1Un+uA42+F+zmYV6Bcn0ddKRiBkBP5VUpqnh7uzBhQSaEkxDCrh5IU+jJZY14rtxU4lOTiQe3U2e4E5AOzP0NfAAP0LB5X3Fs3x01Cq9sGWEe+q2IiT/RrqivQ3AfdTuEgxw3zgfjYhUI41uJbqa+yjv9fzPa9YtsDWfhNlrBKp5xErpeA5hIQboLhbhyZU8eEYe1FrG90cy1qxslYsL7mTgXr3H22u1Qp7s45sYxEsWrmbB8/tuU9XrkqNW8TYncGeCAvex1bwEpnC3ogZM96yG8Tor27NZ8KuDmQNBr9JkiTlF+2tcAO/83JKbghT2I2IxxMihWM83oLjpfHxxAcV2mR4biNQNtYejk3fpmSvxYyZMlGGDJnYhuiK8dzBVhQ2d3ZaFsdGOWSgH6DBAxkFaYSd3fnfzznaozpUE5VktCtlqlSseUtPJ44fNWwM3VwI95crdz7+2F/j31wSrvMYk/Ubbqr01Ll2xG4few2jjxH7MEOGjAbROc82t3Z2t566GReNwaAF7+cMbJd8mbXjMGvAoBnv13DimlqbEOnn0Vp/oOO6desFyo51nhC7RE7sSpQqS42atjW98PAkrf9qLU0ed2u0GusnK4onYtqodbselIeNa5UEi8auytltoTnAyF83PD7MXrKwyxEJHQHBMXSspKQgECoCTsQu1POlnHcREGIXnb5JVFOxWKsSxv7wICpavBSVu+8BKlS4mA+yWKwbITugiYhPAUmAJ19a/kvPWg1MOeTkUXmx4iUpd578Pk3DVE4nNpi9evWqT7p+gNEt7h0LtqdjGz2o9eGxmidfQSpVuryf8fHC+W/TquWL9SpknxEQHOUxEATiFgEhdnGLd1xeTYhddNBONMRuxLgZfoTICikCfg7u1zXOp2uwjFgjtlVIniw5JeXpLhBQEIhQBK7wMFg/eGCfT3F4NIIM3qovmZ9tlE9hywE0lgimGpspNktVCf5QcEzwXSg3kMAREGKXwDswQPOF2AUAx0VWoiF2cxevMQ20rXjBpgfG1CvYRi2Q1st6XqSOq1Z/herWbxZWdTBW/mTNCtaqLfSL04SKAt2v04Xg8bWEbfV2bN/qVCTRpQuOia7L5YY9hoAQO491SASbI8QugmBqVSVaYgdPql/27KQd276njV9/Ea9Tr6EQO0y3nmSN4pEjh4xgpFhDFt5qThKMkIDMwjkEhtZ7f95FO3ds4RUtjjhVl2jTBcdE2/Vy4x5BwLrEHUI7nTxx3COtk2a4QUCInRv0nM91JHZ/3bhOzQaW9zszZaqYZYb8Mj2cgKC/WEYLi2tfunTBcAuPpot+OFDAGaIgR6C/ceNvunnjprFFGIHLHILj8uWLdJE9oHAcjjzGqz1AbrIrPOoFkb169TIhrMclYMBegjLVGhxRwTE4RlJCEIg2ArA3hnkK3lmBlqSLdjuk/sgiIMQusniq2hyJ3T/8A2rUL2aJFXVCQiV2qv2yFQQEAUFAEBAEBIH4R0CIXXT6QIhddHCVWgUBQUAQEAQEAUFAEIhzBITYxTnkckFBQBAQBAQBQUAQEASig4AQu+jgKrUKAoKAICAICAKCgCAQ5wgIsYtzyOWCgoAgIAgIAoKAICAIRAcBR2L3982/qemAsn5XFecJP0gkQRAQBAQBQUAQEAQEAU8g4EjsLlw+R+1HPe7XSCF2fpBIgiAgCAgCgoAgIAgIAp5AQIidJ7pBGiEICAKCgCAgCAgCgoB7BITYucdQahAEBAFBQBAQBAQBQcATCAix80Q3SCMEAUFAEBAEBAFBQBBwj4AQO/cYSg2CgCAgCAgCgoAgIAh4AgEhdp7oBmmEICAICAKCgCAgCAgC7hEQYuceQ6lBEBAEBAFBQBAQBAQBTyAgxM4T3SCNEAQEAUFAEBAEBAFBwD0CQuzcYyg1CAKCgCAgCAgCgoAg4AkEhNh5ohukEYKAICAICAKCgCAgCLhHQIidewylBkFAEBAEBAFBQBAQBDyBgBA7T3SDNEIQEAQEAUFAEBAEBAH3CAixc4+h1CAICAKCgCAgCAgCgoAnEBBi54lukEYIAoKAICAICAKCgCDgHgEhdu4xlBoEAUFAEBAEBAFBQBDwBAJC7DzRDdIIQUAQEAQEAUFAEBAE3CMgxM49hlKDICAICAKCgCAgCAgCnkBAiJ0nukEaIQgIAoKAICAICAKCgHsEhNi5x1BqEAQEAUFAEBAEBAFBwBMICLHzRDdIIwQBQUAQEAQEAUFAEHCPgBA79xhKDYKAICAICAKCgCAgCHgCASF2nugGaYQgIAgIAoKAICAICALuERBi5x5DqUEQEAQEAUFAEBAEBAFPICDEzhPdII0QBAQBQUAQEAQEAUHAPQJC7NxjKDUIAoKAICAICAKCgCDgCQSE2HmiG6QRgoAgIAgIAoKAICAIuEdAiJ17DKUGQUAQEAQEAUFAEBAEPIGAEDtPdIM0QhAQBAQBQUAQEAQEAfcICLFzj6HUIAgIAoKAICAICAKCgCcQEGLniW6QRggCgoAgIAgIAoKAIOAeASF27jGUGgQBQUAQEAQEAUFAEPAEAkLsPNEN0ghBQBAQBAQBQUAQEATcIyDEzj2GUoMgIAgIAoKAICAICAKeQECInSe6QRohCAgCgoAgIAgIAoKAewQcid1fN65Ts4Hl/a6QMlVGvzRJEAQEAUFAEBAEBAFBQBCIfwT+D/zF7ZhlIKO3AAAAAElFTkSuQmCC\"}]}],\"tools\":[{\"type\":\"function\",\"name\":\"read_screenshot\",\"description\":\"Capture a screenshot of the current screen.\",\"parameters\":{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false}}],\"store\":false,\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"max_output_tokens\":40,\"stream\":true}" + "body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Read images carefully. Reply only with the visible text, lowercase, no punctuation.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Use the read_screenshot tool, then reply with the words shown.\"}]},{\"type\":\"function_call\",\"call_id\":\"call_screenshot_1\",\"name\":\"read_screenshot\",\"arguments\":\"{}\"},{\"type\":\"function_call_output\",\"call_id\":\"call_screenshot_1\",\"output\":[{\"type\":\"input_text\",\"text\":\"Image read successfully\"},{\"type\":\"input_image\",\"image_url\":\"data:image/png;base64,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\"}]}],\"tools\":[{\"type\":\"function\",\"name\":\"read_screenshot\",\"description\":\"Capture a screenshot of the current screen.\",\"parameters\":{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false}}],\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"max_output_tokens\":40,\"stream\":true}" }, "response": { "status": 200, "headers": { "content-type": "text/event-stream; charset=utf-8" }, - "body": "event: response.created\ndata: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_043c0deed393f65a016a0fb7dc846881a2b11e4f8691b2a9cd\",\"object\":\"response\",\"created_at\":1779415004,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":40,\"max_tool_calls\":null,\"model\":\"gpt-5.5-2026-04-23\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"effort\":\"medium\",\"summary\":\"detailed\"},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":false,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"low\"},\"tool_choice\":\"auto\",\"tools\":[{\"type\":\"function\",\"description\":\"Capture a screenshot of the current screen.\",\"name\":\"read_screenshot\",\"parameters\":{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false,\"required\":[]},\"strict\":true}],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":0}\n\nevent: response.in_progress\ndata: {\"type\":\"response.in_progress\",\"response\":{\"id\":\"resp_043c0deed393f65a016a0fb7dc846881a2b11e4f8691b2a9cd\",\"object\":\"response\",\"created_at\":1779415004,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":40,\"max_tool_calls\":null,\"model\":\"gpt-5.5-2026-04-23\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"effort\":\"medium\",\"summary\":\"detailed\"},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":false,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"low\"},\"tool_choice\":\"auto\",\"tools\":[{\"type\":\"function\",\"description\":\"Capture a screenshot of the current screen.\",\"name\":\"read_screenshot\",\"parameters\":{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false,\"required\":[]},\"strict\":true}],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":1}\n\nevent: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"item\":{\"id\":\"msg_043c0deed393f65a016a0fb7dd8adc81a28047a51ca2324d55\",\"type\":\"message\",\"status\":\"in_progress\",\"content\":[],\"phase\":\"final_answer\",\"role\":\"assistant\"},\"output_index\":0,\"sequence_number\":2}\n\nevent: response.content_part.added\ndata: {\"type\":\"response.content_part.added\",\"content_index\":0,\"item_id\":\"msg_043c0deed393f65a016a0fb7dd8adc81a28047a51ca2324d55\",\"output_index\":0,\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"\"},\"sequence_number\":3}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"j\",\"item_id\":\"msg_043c0deed393f65a016a0fb7dd8adc81a28047a51ca2324d55\",\"logprobs\":[],\"obfuscation\":\"E7rxHk2uXGfQ1ld\",\"output_index\":0,\"sequence_number\":4}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"igg\",\"item_id\":\"msg_043c0deed393f65a016a0fb7dd8adc81a28047a51ca2324d55\",\"logprobs\":[],\"obfuscation\":\"wHEj3PrFtMkLC\",\"output_index\":0,\"sequence_number\":5}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"ling\",\"item_id\":\"msg_043c0deed393f65a016a0fb7dd8adc81a28047a51ca2324d55\",\"logprobs\":[],\"obfuscation\":\"z3tGrLKAfhd7\",\"output_index\":0,\"sequence_number\":6}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" restroom\",\"item_id\":\"msg_043c0deed393f65a016a0fb7dd8adc81a28047a51ca2324d55\",\"logprobs\":[],\"obfuscation\":\"bSkCu75\",\"output_index\":0,\"sequence_number\":7}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" prison\",\"item_id\":\"msg_043c0deed393f65a016a0fb7dd8adc81a28047a51ca2324d55\",\"logprobs\":[],\"obfuscation\":\"RGOU3OGVC\",\"output_index\":0,\"sequence_number\":8}\n\nevent: response.output_text.done\ndata: {\"type\":\"response.output_text.done\",\"content_index\":0,\"item_id\":\"msg_043c0deed393f65a016a0fb7dd8adc81a28047a51ca2324d55\",\"logprobs\":[],\"output_index\":0,\"sequence_number\":9,\"text\":\"jiggling restroom prison\"}\n\nevent: response.content_part.done\ndata: {\"type\":\"response.content_part.done\",\"content_index\":0,\"item_id\":\"msg_043c0deed393f65a016a0fb7dd8adc81a28047a51ca2324d55\",\"output_index\":0,\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"jiggling restroom prison\"},\"sequence_number\":10}\n\nevent: response.output_item.done\ndata: {\"type\":\"response.output_item.done\",\"item\":{\"id\":\"msg_043c0deed393f65a016a0fb7dd8adc81a28047a51ca2324d55\",\"type\":\"message\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"jiggling restroom prison\"}],\"phase\":\"final_answer\",\"role\":\"assistant\"},\"output_index\":0,\"sequence_number\":11}\n\nevent: response.completed\ndata: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_043c0deed393f65a016a0fb7dc846881a2b11e4f8691b2a9cd\",\"object\":\"response\",\"created_at\":1779415004,\"status\":\"completed\",\"background\":false,\"completed_at\":1779415005,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":40,\"max_tool_calls\":null,\"model\":\"gpt-5.5-2026-04-23\",\"moderation\":null,\"output\":[{\"id\":\"msg_043c0deed393f65a016a0fb7dd8adc81a28047a51ca2324d55\",\"type\":\"message\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"jiggling restroom prison\"}],\"phase\":\"final_answer\",\"role\":\"assistant\"}],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"effort\":\"medium\",\"summary\":\"detailed\"},\"safety_identifier\":null,\"service_tier\":\"default\",\"store\":false,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"low\"},\"tool_choice\":\"auto\",\"tools\":[{\"type\":\"function\",\"description\":\"Capture a screenshot of the current screen.\",\"name\":\"read_screenshot\",\"parameters\":{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false,\"required\":[]},\"strict\":true}],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":{\"input_tokens\":227,\"input_tokens_details\":{\"cached_tokens\":0},\"output_tokens\":6,\"output_tokens_details\":{\"reasoning_tokens\":0},\"total_tokens\":233},\"user\":null,\"metadata\":{}},\"sequence_number\":12}\n\n" + "body": "event: response.created\ndata: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_06646cef0abb6407016a1235f665a88197a401db20cfc8c787\",\"object\":\"response\",\"created_at\":1779578358,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":40,\"max_tool_calls\":null,\"model\":\"gpt-5.5-2026-04-23\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"context\":\"current_turn\",\"effort\":\"medium\",\"summary\":\"detailed\"},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":false,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"low\"},\"tool_choice\":\"auto\",\"tools\":[{\"type\":\"function\",\"description\":\"Capture a screenshot of the current screen.\",\"name\":\"read_screenshot\",\"parameters\":{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false,\"required\":[]},\"strict\":true}],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":0}\n\nevent: response.in_progress\ndata: {\"type\":\"response.in_progress\",\"response\":{\"id\":\"resp_06646cef0abb6407016a1235f665a88197a401db20cfc8c787\",\"object\":\"response\",\"created_at\":1779578358,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":40,\"max_tool_calls\":null,\"model\":\"gpt-5.5-2026-04-23\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"context\":\"current_turn\",\"effort\":\"medium\",\"summary\":\"detailed\"},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":false,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"low\"},\"tool_choice\":\"auto\",\"tools\":[{\"type\":\"function\",\"description\":\"Capture a screenshot of the current screen.\",\"name\":\"read_screenshot\",\"parameters\":{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false,\"required\":[]},\"strict\":true}],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":1}\n\nevent: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"item\":{\"id\":\"msg_06646cef0abb6407016a1235f703708197bd125c4f32fb7b69\",\"type\":\"message\",\"status\":\"in_progress\",\"content\":[],\"phase\":\"final_answer\",\"role\":\"assistant\"},\"output_index\":0,\"sequence_number\":2}\n\nevent: response.content_part.added\ndata: {\"type\":\"response.content_part.added\",\"content_index\":0,\"item_id\":\"msg_06646cef0abb6407016a1235f703708197bd125c4f32fb7b69\",\"output_index\":0,\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"\"},\"sequence_number\":3}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"j\",\"item_id\":\"msg_06646cef0abb6407016a1235f703708197bd125c4f32fb7b69\",\"logprobs\":[],\"obfuscation\":\"eWkUz3qb2ZTKrat\",\"output_index\":0,\"sequence_number\":4}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"igg\",\"item_id\":\"msg_06646cef0abb6407016a1235f703708197bd125c4f32fb7b69\",\"logprobs\":[],\"obfuscation\":\"rPnd9lcMUqqob\",\"output_index\":0,\"sequence_number\":5}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"ling\",\"item_id\":\"msg_06646cef0abb6407016a1235f703708197bd125c4f32fb7b69\",\"logprobs\":[],\"obfuscation\":\"IuJYWK4DiIvE\",\"output_index\":0,\"sequence_number\":6}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" restroom\",\"item_id\":\"msg_06646cef0abb6407016a1235f703708197bd125c4f32fb7b69\",\"logprobs\":[],\"obfuscation\":\"v2MVmAR\",\"output_index\":0,\"sequence_number\":7}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" prison\",\"item_id\":\"msg_06646cef0abb6407016a1235f703708197bd125c4f32fb7b69\",\"logprobs\":[],\"obfuscation\":\"AfKH50yJZ\",\"output_index\":0,\"sequence_number\":8}\n\nevent: response.output_text.done\ndata: {\"type\":\"response.output_text.done\",\"content_index\":0,\"item_id\":\"msg_06646cef0abb6407016a1235f703708197bd125c4f32fb7b69\",\"logprobs\":[],\"output_index\":0,\"sequence_number\":9,\"text\":\"jiggling restroom prison\"}\n\nevent: response.content_part.done\ndata: {\"type\":\"response.content_part.done\",\"content_index\":0,\"item_id\":\"msg_06646cef0abb6407016a1235f703708197bd125c4f32fb7b69\",\"output_index\":0,\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"jiggling restroom prison\"},\"sequence_number\":10}\n\nevent: response.output_item.done\ndata: {\"type\":\"response.output_item.done\",\"item\":{\"id\":\"msg_06646cef0abb6407016a1235f703708197bd125c4f32fb7b69\",\"type\":\"message\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"jiggling restroom prison\"}],\"phase\":\"final_answer\",\"role\":\"assistant\"},\"output_index\":0,\"sequence_number\":11}\n\nevent: response.completed\ndata: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_06646cef0abb6407016a1235f665a88197a401db20cfc8c787\",\"object\":\"response\",\"created_at\":1779578358,\"status\":\"completed\",\"background\":false,\"completed_at\":1779578359,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":40,\"max_tool_calls\":null,\"model\":\"gpt-5.5-2026-04-23\",\"moderation\":null,\"output\":[{\"id\":\"msg_06646cef0abb6407016a1235f703708197bd125c4f32fb7b69\",\"type\":\"message\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"jiggling restroom prison\"}],\"phase\":\"final_answer\",\"role\":\"assistant\"}],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"context\":\"current_turn\",\"effort\":\"medium\",\"summary\":\"detailed\"},\"safety_identifier\":null,\"service_tier\":\"default\",\"store\":false,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"low\"},\"tool_choice\":\"auto\",\"tools\":[{\"type\":\"function\",\"description\":\"Capture a screenshot of the current screen.\",\"name\":\"read_screenshot\",\"parameters\":{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false,\"required\":[]},\"strict\":true}],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":{\"input_tokens\":227,\"input_tokens_details\":{\"cached_tokens\":0},\"output_tokens\":9,\"output_tokens_details\":{\"reasoning_tokens\":0},\"total_tokens\":236},\"user\":null,\"metadata\":{}},\"sequence_number\":12}\n\n" } } ] diff --git a/packages/llm/test/fixtures/recordings/openai-responses/openai-responses-gpt-5-5-reasoning-continuation.json b/packages/llm/test/fixtures/recordings/openai-responses/openai-responses-gpt-5-5-reasoning-continuation.json index f2f2ace01d..850e381caf 100644 --- a/packages/llm/test/fixtures/recordings/openai-responses/openai-responses-gpt-5-5-reasoning-continuation.json +++ b/packages/llm/test/fixtures/recordings/openai-responses/openai-responses-gpt-5-5-reasoning-continuation.json @@ -2,7 +2,7 @@ "version": 1, "metadata": { "name": "openai-responses/openai-responses-gpt-5-5-reasoning-continuation", - "recordedAt": "2026-05-21T15:44:44.148Z", + "recordedAt": "2026-05-23T23:19:06.776Z", "provider": "openai", "route": "openai-responses", "transport": "http", @@ -33,7 +33,7 @@ "headers": { "content-type": "text/event-stream; charset=utf-8" }, - "body": "event: response.created\ndata: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_074702e1ce54271e016a0f2867ca3881a2aafce9c77ab21317\",\"object\":\"response\",\"created_at\":1779378279,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":120,\"max_tool_calls\":null,\"model\":\"gpt-5.5-2026-04-23\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"effort\":\"low\",\"summary\":\"detailed\"},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":false,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"low\"},\"tool_choice\":\"auto\",\"tools\":[],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":0}\n\nevent: response.in_progress\ndata: {\"type\":\"response.in_progress\",\"response\":{\"id\":\"resp_074702e1ce54271e016a0f2867ca3881a2aafce9c77ab21317\",\"object\":\"response\",\"created_at\":1779378279,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":120,\"max_tool_calls\":null,\"model\":\"gpt-5.5-2026-04-23\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"effort\":\"low\",\"summary\":\"detailed\"},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":false,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"low\"},\"tool_choice\":\"auto\",\"tools\":[],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":1}\n\nevent: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"item\":{\"id\":\"rs_074702e1ce54271e016a0f2868d12c81a29a2e54c6ef9e6378\",\"type\":\"reasoning\",\"encrypted_content\":\"gAAAAABqDyhonX_F5cxG-2PVDX1Xw6zpBKHmGD5J3tCshwxmVUTCLHLw3muwXbefIYTPIxsKAAEnDKh6yRHVsYKAfrTUogES5Qma1iR5sK8_3azy06IYWLzJUlAjJI3VSwL8VQL-QAjkJT9G3ALi6J1v1TgCfex_hUTJSCPZ8nXyzJb4KvI5LsmGilAKgVZmNHqUgEOn4BCZRZIA71qpbKhnMV6v_oS8PSxSN46RNPs3Wgp5WQBVk3XPQ2PFqvs8ntR8vvyfizGizcHbLC_diWLb9qx-GHD6ysu-4IO4dVmXsmd4t0u_HPS-a1mUli09fsYwQ75XsQOPhdfFp2wnRMeBx00p5iOZaTDRy_g8AQ5F9mGl3NYe48HG78SYVSFYYGvpRoJ2ob-3i9zdSO6zsC9uRkYGAvSPp31xfLiEIxw-Czwms26EBpSHzuO-91L4e25Yr-_PEdkDbQYZi2QdjnQMtTj688xVQcNar5rGrMKdv-yYCcb3qvkhQLkUGTaqvCfxh1aLzkmH0uCEEshpfbTD5mr_l1sDKn4kh4JWVEr_kS93kJUVqkdhgw8UkIJ_ZOQ8uNzBWU-SjkInsS3doRY42SlsnEt_g-h0TwJeVx1DQy3c1dHiHzDKI2zp_9_pSjUhrB4s5qdneU1rUQIoO8zUxCVwul8Sujd6MLkeiOScA6VfTZIOtV80ixVJ3_TO-DcaQLePCAAxNUEccvrEiwMZgC6iZnxr4UBbQnOEitwfE5yidKFmKzCNFjxfC0bLxeVonZ2nqMIBR9PXCo-Fm89HhKQUIVXhRaukCxGozYaADMxdsfsC8dVyYgNph5npUBJ6j1eAbThzmRxj3MHNP5TSSth8qGbPbTFwn3zTMgU_mwZiJISee7e0GRD6OqZOpL4lJ8O7W1GUxdszZM545dJQu2o_yD-ZwQ==\",\"summary\":[]},\"output_index\":0,\"sequence_number\":2}\n\nevent: response.output_item.done\ndata: {\"type\":\"response.output_item.done\",\"item\":{\"id\":\"rs_074702e1ce54271e016a0f2868d12c81a29a2e54c6ef9e6378\",\"type\":\"reasoning\",\"encrypted_content\":\"gAAAAABqDyhpNt-fJi04K-Nx4EFaFnHjFXBx5J64JL0smJDJQm0Ruuyg1oihcOlT9unQclJXlLyxHVV_9bRb1iXwz26IpmsiDPeA-y9NCEQ9KV4lyfAsmwCrw5De9D-sGWYAtqnlTpsQdRjLPBECcDjEDB8dn1KPY12EJ9WnA9ocDURv-XTGVPp9JhKkSrz0FZsU0toBo_GK6c_SwyCAPorcvzjHAojAibi71LAq2ZWFYFXMmafvRCS9mGZF2PD0WwkQBPCc4Q-_3ZVx0LbBU6ceAZmL-DpRNFnSY0YdGQ-RpJm_nNY-ojZg6Or67nnuCY7N9BMCyuZ81ytcfuym2vKeux8VTYPyQ5a8km4xZoTaoNRY0Y3IJvsqCRD9mxyGx1rpIGlguyVeEG7_PyY4Wr0HKV_jSBUAjqIuJi809wFNIfS8e6beaqqV5CwjdfYp8_uncWwF98fUsMXaLdvKKj-RshZxSSIj1JMKJm_-BbJHfa_TNe9vcq51Qu6xY6Fwc1vB4hwXJ7k0VGkiqAlMIRbJ0erRTJv7LeiMFoT_KRVMF6lC9DOdn0LmVWenrqFOPu2ZKVr1WdOU9ow9r6bAztojXN0iGveCTpu5Vabs_G5fJE38nS868IOH9VqX0vuDzNSJUKErGmOp4EGM6M1C8gAtDAP9c9gU60khFJbYDRxnO24tDLtrZ71YCCYSmKLL6BGniNo5TBZZJL6blPiTsEYJpy_yB5hjXIkVF1EmTWrXhTw8RjI0QcKIhDPNSb3tpTLcGr7G3WnRqQc-91M9BQCmBD2LczfhkESGKpuciWG7tyR1XPEF4B9QitL0x3uxNob5u3j2Rwr1dlZ0JFvZ7Ezo2J_zOsaxtN5BfZjwJuelihxneoPHRWq2M61Domhe3MOc5lYL3_a0EluLRnK0smX0UcC0dfG8gy6cL_jJfs-mkpZmn-bcUsbGTRDTVQ_tPc1BODz3LxKP1rOZuL_JvBPoTvm-NFHluA==\",\"summary\":[]},\"output_index\":0,\"sequence_number\":3}\n\nevent: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"item\":{\"id\":\"msg_074702e1ce54271e016a0f2869830481a2983268f801d1ee8c\",\"type\":\"message\",\"status\":\"in_progress\",\"content\":[],\"phase\":\"final_answer\",\"role\":\"assistant\"},\"output_index\":1,\"sequence_number\":4}\n\nevent: response.content_part.added\ndata: {\"type\":\"response.content_part.added\",\"content_index\":0,\"item_id\":\"msg_074702e1ce54271e016a0f2869830481a2983268f801d1ee8c\",\"output_index\":1,\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"\"},\"sequence_number\":5}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"Hello\",\"item_id\":\"msg_074702e1ce54271e016a0f2869830481a2983268f801d1ee8c\",\"logprobs\":[],\"obfuscation\":\"BnS137oNVXg\",\"output_index\":1,\"sequence_number\":6}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"!\",\"item_id\":\"msg_074702e1ce54271e016a0f2869830481a2983268f801d1ee8c\",\"logprobs\":[],\"obfuscation\":\"RhAyjCQ9wUStcwz\",\"output_index\":1,\"sequence_number\":7}\n\nevent: response.output_text.done\ndata: {\"type\":\"response.output_text.done\",\"content_index\":0,\"item_id\":\"msg_074702e1ce54271e016a0f2869830481a2983268f801d1ee8c\",\"logprobs\":[],\"output_index\":1,\"sequence_number\":8,\"text\":\"Hello!\"}\n\nevent: response.content_part.done\ndata: {\"type\":\"response.content_part.done\",\"content_index\":0,\"item_id\":\"msg_074702e1ce54271e016a0f2869830481a2983268f801d1ee8c\",\"output_index\":1,\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"Hello!\"},\"sequence_number\":9}\n\nevent: response.output_item.done\ndata: {\"type\":\"response.output_item.done\",\"item\":{\"id\":\"msg_074702e1ce54271e016a0f2869830481a2983268f801d1ee8c\",\"type\":\"message\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"Hello!\"}],\"phase\":\"final_answer\",\"role\":\"assistant\"},\"output_index\":1,\"sequence_number\":10}\n\nevent: response.completed\ndata: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_074702e1ce54271e016a0f2867ca3881a2aafce9c77ab21317\",\"object\":\"response\",\"created_at\":1779378279,\"status\":\"completed\",\"background\":false,\"completed_at\":1779378281,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":120,\"max_tool_calls\":null,\"model\":\"gpt-5.5-2026-04-23\",\"moderation\":null,\"output\":[{\"id\":\"rs_074702e1ce54271e016a0f2868d12c81a29a2e54c6ef9e6378\",\"type\":\"reasoning\",\"encrypted_content\":\"gAAAAABqDyhprORGpIaaHUWq-Sk1sPvEW3zXpkk5q0tKIwclTPkbOyg0Ef1A03skcjgIxNV18Qm9QiKZynle8lTgc3Ib_DmNQAB10g3lXDcxtkyLuikKM3qbn8i-pwGqRctZINaH2hG8asgvq0ARMD87vLgF36To5DK2tc5j16TQb7HZYMOGdPrvXXswalEaD1EB73qN8Oao8jpPH1jsbcJJk6rKU7RsAjyRC3nHs36HH1JWAKG04meDezHYPfag9PAM55VPA6PfMs_zVMD2k-gv0eBNR17Xk_kY-Mbng9dITuxYTtOELYJ3mCkZmHKzZXzd9VtnfftgM4y5YOcOkBEeJQ5l6YNyOi2Zml3KUT9fZfnJbZWq8HSvCf20T7GpuLyQ_0XHgjR4r4dFPii6XKVgzXP4C144OWC4kPStOhmzAoaM9HaDPOY0rS77Om_C4nzqzhHl1U79wCMxEEkHj7q90opjjfUgS-u74skEa8cv2m5LRSIOJtXTOY0kowWlw5N3OtoER3l2c24OantC6MyQBCkl0FxqL635DiMmBBuTkWM-pQfGwlbbwlqJktMsAByoYihO7Qreialx-22D2QJsxBS4fYuEt1yQUVY8PwmH3Sf7JqV08VVROHMAZNKknd098UCGjP2ciAxda7bMQ-eF_hC-7tK0YVO2xdupT-yW2dRrbGOAx8ZVfoAICbIB1bP7VMu5-KIiQNsjVw7vK502uHxuh0HOhGL0Cq0-V67luspb5QlRoY8ZfFhmLhM20qm8BZpdRFrUoI3OvGgoKPzrg2MMxY7g-rMQQmCWuZ6QKL9aI8GpwHOphM3ttFJbQFu4DhqULW29Q9oDmssMuuTbUhkvtfUztkibyYphFA2l9Q7neSYBQtL6xM921uwgrOAodsZWrakLMZNgFzRoFH_T0KYMcbw5E9dFgYdCn0Bspy28RIJUy4XmXWYTtJ0yKaLuLo0s73ZPWbuELy7ifE8mEVKwGTFT3Q==\",\"summary\":[]},{\"id\":\"msg_074702e1ce54271e016a0f2869830481a2983268f801d1ee8c\",\"type\":\"message\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"Hello!\"}],\"phase\":\"final_answer\",\"role\":\"assistant\"}],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"effort\":\"low\",\"summary\":\"detailed\"},\"safety_identifier\":null,\"service_tier\":\"default\",\"store\":false,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"low\"},\"tool_choice\":\"auto\",\"tools\":[],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":{\"input_tokens\":31,\"input_tokens_details\":{\"cached_tokens\":0},\"output_tokens\":21,\"output_tokens_details\":{\"reasoning_tokens\":13},\"total_tokens\":52},\"user\":null,\"metadata\":{}},\"sequence_number\":11}\n\n" + "body": "event: response.created\ndata: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_0a0794dab3b8ec7d016a1235e74e148195beb46e1925d20292\",\"object\":\"response\",\"created_at\":1779578343,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":120,\"max_tool_calls\":null,\"model\":\"gpt-5.5-2026-04-23\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"context\":\"current_turn\",\"effort\":\"low\",\"summary\":\"detailed\"},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":false,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"low\"},\"tool_choice\":\"auto\",\"tools\":[],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":0}\n\nevent: response.in_progress\ndata: {\"type\":\"response.in_progress\",\"response\":{\"id\":\"resp_0a0794dab3b8ec7d016a1235e74e148195beb46e1925d20292\",\"object\":\"response\",\"created_at\":1779578343,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":120,\"max_tool_calls\":null,\"model\":\"gpt-5.5-2026-04-23\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"context\":\"current_turn\",\"effort\":\"low\",\"summary\":\"detailed\"},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":false,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"low\"},\"tool_choice\":\"auto\",\"tools\":[],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":1}\n\nevent: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"item\":{\"id\":\"rs_0a0794dab3b8ec7d016a1235e7ce3881958a5eca32a36a14c5\",\"type\":\"reasoning\",\"encrypted_content\":\"gAAAAABqEjXnglldg7hhpTBATVqj7sThK5ATieOVR8sZGYPDW2zYopwpKxA3RyRccK_FPjRvvlzrvL-FitOxmdMGBaKa5jncrT9hHo5IMhsFsCEHkQ1x5tlrKPqtfwJ_LFexR0h_IpPogu8wlVAkHRoWQoq61o9vBxjMOEsq6dtXu09959gXnAvJA3jN_mqNkRZ7Yp6LaJJtLDAAtt_dhX8veoEFXZ412lCY4zcaMvC5o0yq6MPvLIN4NhHmfPKkVAy-j8wGlgA42KR4wd5-VeFXUdeSn32dlNLZZxBFa9w6iTgCQ9aF-3C7RB4OXeSY782QUD1dRyFybd7vJtjlptwXBntSHZ9wugoKSDEj0KnvQKG_WiCWuJvkGiOVno4MAs5QnCmKBnpak5OV1wOhPwX2ez6OmAYT4mMKIogdfivVvUxMrmdVJzgE85WoZEAU2ZporxVXkI7_8p0L6dxxwk_IKiKSCz-bZgsCtOP5Jsr5GeI831nVv272kZ3DugV-hcjGHAE5T9KhebzpFjsdxnJcfxuGY8SyRaLlUAHM_37H4veHsOzyhCoaG8mMaT3gIb4tAvM7ezd1xzLsFae89P5xCv_fNeoV7qmf2IWDWUi1vitIib5w9jsclWRqYaLVZR0GK6dYyNJ1DXDOOcWRdH7UJakv1m2koUbcYWBuxao7sc-af_9ySKAloWhb6QjiVElJHYtwraJBtX-CLBVHEYqAXmZgMUWVbz8NNRA6JS1TrOys7_LiQtXXubLWas_66LyaqmB-628LCUitUISYYc2wmq1uUm7gjPA53Wm4F7VU6g-PO7bt1O0Nd-jasisPXINTX3Z4hgC1APPEq29iEHwmEPnicO_Nu6U3JLfq4DD6r1oLK-RnIp3Ratw0P-Gwog86RBLGUWIEIKdFu6m9d1TI8rIBbAVaBA==\",\"summary\":[]},\"output_index\":0,\"sequence_number\":2}\n\nevent: response.output_item.done\ndata: {\"type\":\"response.output_item.done\",\"item\":{\"id\":\"rs_0a0794dab3b8ec7d016a1235e7ce3881958a5eca32a36a14c5\",\"type\":\"reasoning\",\"encrypted_content\":\"gAAAAABqEjXoGMCw3WDXpoD9151PEr2Lt8raW7KBKefQhZJGWx5f8jy152bApO6oE-Mr1BhUtfZNq3OPBVfSL4ioQ9bHREfujIBXgk9LUDBAz2Sle7KjOr9HaUV16A4HBiaFIRFjsHPS9G8yEySp1m6F1CD_WR6apyUGgugRh_y39EcOJmxPOzmiac5DVM6fraA1VpcGbqrZ1x2ANHFDOfnYTycPtPNTgzE7LjkYjDDWbT03uN1YxfP4pqjDVRzY14pA8bSZ8ys-pDv5kUFCAsw-OlU4jYKUXp-M8_6KTaRQP71LPwppt__zG_NJPfy-qUil4pOU8_NoxtxerHgLLXbfExZdzfpoGinoEjn7nj7BJDEtl-LNeNEb5c-1ZymNfVMp-Cs3fLEPkAV8rtHFtZ0MhE_07GKbGo7hTrOmkM4DydxmHsdWGNbXAG35cprslEA5P7p3GHFKnRs5hGs2eq-XcZ3yki64ZBOU_Tv6UR7nUH09gF1rdrJo3dpre6M00COwwdZ02zUP5KxCuI8FKu2jsZu9zgMVXDALsdtM5orTCVLXsn4rddWd111zE-vMjNmMMmktW2cHMjH7j1ooA-9P083koNVYiLi4UhMA64gTqgyl8MxkZekl7eFSMa7qk295NaHOKtFxzYYcZ9jdioCwSPSZ0ZZWLoNgrK7SWfRh0uaTHNcMZ3wq8ae6CguktIeVTCPTQAqJLQqd7AU0oOCKCJ7BWnC-L8UC6m7Pm9ZS958uUVeWBhgKHzMAGq9UeQB7IEeAcbMn3EDgOSfd8qCb8iwU9iG9dcu9axQwWU7pd7kd-T-He61W7z5wWgpx1KehWCxrN6kuKSo6p-uUfwVnJukreOn8BJNAzADQgz68bhmN9VGih7YcKVnLgwDwKditrjSd6-tfE0Baarj3jWENvT6ohY17R9FDrKS-2v8IIX6tGjoKJw8SRhaWLNv4vWlmxRgR0gdac3qumd0GKqsWSveNz01naA==\",\"summary\":[]},\"output_index\":0,\"sequence_number\":3}\n\nevent: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"item\":{\"id\":\"msg_0a0794dab3b8ec7d016a1235e8d64c81959a41f8db3ea7b66c\",\"type\":\"message\",\"status\":\"in_progress\",\"content\":[],\"phase\":\"final_answer\",\"role\":\"assistant\"},\"output_index\":1,\"sequence_number\":4}\n\nevent: response.content_part.added\ndata: {\"type\":\"response.content_part.added\",\"content_index\":0,\"item_id\":\"msg_0a0794dab3b8ec7d016a1235e8d64c81959a41f8db3ea7b66c\",\"output_index\":1,\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"\"},\"sequence_number\":5}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"Hello\",\"item_id\":\"msg_0a0794dab3b8ec7d016a1235e8d64c81959a41f8db3ea7b66c\",\"logprobs\":[],\"obfuscation\":\"3nRhhCWA1H8\",\"output_index\":1,\"sequence_number\":6}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"!\",\"item_id\":\"msg_0a0794dab3b8ec7d016a1235e8d64c81959a41f8db3ea7b66c\",\"logprobs\":[],\"obfuscation\":\"60NqChSEyXHKsoy\",\"output_index\":1,\"sequence_number\":7}\n\nevent: response.output_text.done\ndata: {\"type\":\"response.output_text.done\",\"content_index\":0,\"item_id\":\"msg_0a0794dab3b8ec7d016a1235e8d64c81959a41f8db3ea7b66c\",\"logprobs\":[],\"output_index\":1,\"sequence_number\":8,\"text\":\"Hello!\"}\n\nevent: response.content_part.done\ndata: {\"type\":\"response.content_part.done\",\"content_index\":0,\"item_id\":\"msg_0a0794dab3b8ec7d016a1235e8d64c81959a41f8db3ea7b66c\",\"output_index\":1,\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"Hello!\"},\"sequence_number\":9}\n\nevent: response.output_item.done\ndata: {\"type\":\"response.output_item.done\",\"item\":{\"id\":\"msg_0a0794dab3b8ec7d016a1235e8d64c81959a41f8db3ea7b66c\",\"type\":\"message\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"Hello!\"}],\"phase\":\"final_answer\",\"role\":\"assistant\"},\"output_index\":1,\"sequence_number\":10}\n\nevent: response.completed\ndata: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_0a0794dab3b8ec7d016a1235e74e148195beb46e1925d20292\",\"object\":\"response\",\"created_at\":1779578343,\"status\":\"completed\",\"background\":false,\"completed_at\":1779578344,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":120,\"max_tool_calls\":null,\"model\":\"gpt-5.5-2026-04-23\",\"moderation\":null,\"output\":[{\"id\":\"rs_0a0794dab3b8ec7d016a1235e7ce3881958a5eca32a36a14c5\",\"type\":\"reasoning\",\"encrypted_content\":\"gAAAAABqEjXoMO9Ci_Q06HKQ0YDBarUvbp9ulkR9W2RXWPbx7XKokNCrKUZX-pPPGpUg6r-vTXe8iEX-oED6TmjxZV_nyo838x6pmQJlDqz5JECs2axIrUbCjv9xBt3ob8eAyOizhKFjp3dJNu4i01c38MPZ5QYpD24uCKf69jzjUfydKIEjbo0VhP3K6SDG0V9ZUtua-e6WMqzIg-W5Zs3u64DxGw974ntmvNsx8lsuLR-bk9S5ZZ7zPlCG2Emwfph8UE5HJmIfmMxYlrY5qmXSWKDhse9hovQj-TrvbllP-0vLNQWEPLc3aUfVrWWR9i3NZZ-nxJZiIJPCF3xxIIyKaLh9a6Lh9J6Z-brsvVfbVJWXIGZhsu-uKk6Gwoqo56KqHdNaPF7lkPo5GAWfMrweCnJZ4o_j-oWm8BwTkXxrLib4XYKDO2JNqrNdbmy8rZ7UGgW_DVTiNyZi6LoRfSuvK45MWV2uzB_OJ9LBcqgscY4HyPvKrhGG4Peh4iXuBUCyQQ2IudM5GbeeMOAF3dnEzZff68SwE1H56CO6PtKhVQ6cFJMf7LwI5LFFio0qJnEDx-MejvU7PxmYW7R3MEbgjbsuEFU5KnRVYsgug3_Bq1vXdmP2qhebufFZwz26SwaFqyn3xjwCP8-GR7lWCZ2EvUvWtfxJ5_zgkZg06UsF4Eo_CWKFdp0ao43nemNJxOlMzFa6tPuCgplmD0oYoQ316f-bWK02-eJk56S7G4bZSk8cQfExfIZMjW2f-qrxvfxEpFiXsZF80BQwgRUOeKjsqidg2ihdldRkXGn3vX8p15mf1UgstU8y3DNd2_qJe1f_pEl6rWNXoxFdSRCTG7wTAqbCCmuDgCKGhNQY9tfJNsFqgWBIGkqKy88DN_HiWywJjJ-5u9aoe68yDK-E0TMDqs7ZrTely1wvmkl2yF0XQttaB30taxkIcRR-n0PRO-CRNA_9nJkw9ZsBb8oBjyqWH_mwSijT5g==\",\"summary\":[]},{\"id\":\"msg_0a0794dab3b8ec7d016a1235e8d64c81959a41f8db3ea7b66c\",\"type\":\"message\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"Hello!\"}],\"phase\":\"final_answer\",\"role\":\"assistant\"}],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"context\":\"current_turn\",\"effort\":\"low\",\"summary\":\"detailed\"},\"safety_identifier\":null,\"service_tier\":\"default\",\"store\":false,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"low\"},\"tool_choice\":\"auto\",\"tools\":[],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":{\"input_tokens\":31,\"input_tokens_details\":{\"cached_tokens\":0},\"output_tokens\":20,\"output_tokens_details\":{\"reasoning_tokens\":12},\"total_tokens\":51},\"user\":null,\"metadata\":{}},\"sequence_number\":11}\n\n" } }, { @@ -44,14 +44,14 @@ "headers": { "content-type": "application/json" }, - "body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Think briefly, then reply exactly with: Hello!\"}]},{\"type\":\"reasoning\",\"id\":\"rs_074702e1ce54271e016a0f2868d12c81a29a2e54c6ef9e6378\",\"summary\":[],\"encrypted_content\":\"gAAAAABqDyhpNt-fJi04K-Nx4EFaFnHjFXBx5J64JL0smJDJQm0Ruuyg1oihcOlT9unQclJXlLyxHVV_9bRb1iXwz26IpmsiDPeA-y9NCEQ9KV4lyfAsmwCrw5De9D-sGWYAtqnlTpsQdRjLPBECcDjEDB8dn1KPY12EJ9WnA9ocDURv-XTGVPp9JhKkSrz0FZsU0toBo_GK6c_SwyCAPorcvzjHAojAibi71LAq2ZWFYFXMmafvRCS9mGZF2PD0WwkQBPCc4Q-_3ZVx0LbBU6ceAZmL-DpRNFnSY0YdGQ-RpJm_nNY-ojZg6Or67nnuCY7N9BMCyuZ81ytcfuym2vKeux8VTYPyQ5a8km4xZoTaoNRY0Y3IJvsqCRD9mxyGx1rpIGlguyVeEG7_PyY4Wr0HKV_jSBUAjqIuJi809wFNIfS8e6beaqqV5CwjdfYp8_uncWwF98fUsMXaLdvKKj-RshZxSSIj1JMKJm_-BbJHfa_TNe9vcq51Qu6xY6Fwc1vB4hwXJ7k0VGkiqAlMIRbJ0erRTJv7LeiMFoT_KRVMF6lC9DOdn0LmVWenrqFOPu2ZKVr1WdOU9ow9r6bAztojXN0iGveCTpu5Vabs_G5fJE38nS868IOH9VqX0vuDzNSJUKErGmOp4EGM6M1C8gAtDAP9c9gU60khFJbYDRxnO24tDLtrZ71YCCYSmKLL6BGniNo5TBZZJL6blPiTsEYJpy_yB5hjXIkVF1EmTWrXhTw8RjI0QcKIhDPNSb3tpTLcGr7G3WnRqQc-91M9BQCmBD2LczfhkESGKpuciWG7tyR1XPEF4B9QitL0x3uxNob5u3j2Rwr1dlZ0JFvZ7Ezo2J_zOsaxtN5BfZjwJuelihxneoPHRWq2M61Domhe3MOc5lYL3_a0EluLRnK0smX0UcC0dfG8gy6cL_jJfs-mkpZmn-bcUsbGTRDTVQ_tPc1BODz3LxKP1rOZuL_JvBPoTvm-NFHluA==\"},{\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Hello!\"}]},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Now reply exactly with: Done.\"}]}],\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"low\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"max_output_tokens\":40,\"stream\":true}" + "body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Think briefly, then reply exactly with: Hello!\"}]},{\"type\":\"reasoning\",\"id\":\"rs_0a0794dab3b8ec7d016a1235e7ce3881958a5eca32a36a14c5\",\"summary\":[],\"encrypted_content\":\"gAAAAABqEjXoGMCw3WDXpoD9151PEr2Lt8raW7KBKefQhZJGWx5f8jy152bApO6oE-Mr1BhUtfZNq3OPBVfSL4ioQ9bHREfujIBXgk9LUDBAz2Sle7KjOr9HaUV16A4HBiaFIRFjsHPS9G8yEySp1m6F1CD_WR6apyUGgugRh_y39EcOJmxPOzmiac5DVM6fraA1VpcGbqrZ1x2ANHFDOfnYTycPtPNTgzE7LjkYjDDWbT03uN1YxfP4pqjDVRzY14pA8bSZ8ys-pDv5kUFCAsw-OlU4jYKUXp-M8_6KTaRQP71LPwppt__zG_NJPfy-qUil4pOU8_NoxtxerHgLLXbfExZdzfpoGinoEjn7nj7BJDEtl-LNeNEb5c-1ZymNfVMp-Cs3fLEPkAV8rtHFtZ0MhE_07GKbGo7hTrOmkM4DydxmHsdWGNbXAG35cprslEA5P7p3GHFKnRs5hGs2eq-XcZ3yki64ZBOU_Tv6UR7nUH09gF1rdrJo3dpre6M00COwwdZ02zUP5KxCuI8FKu2jsZu9zgMVXDALsdtM5orTCVLXsn4rddWd111zE-vMjNmMMmktW2cHMjH7j1ooA-9P083koNVYiLi4UhMA64gTqgyl8MxkZekl7eFSMa7qk295NaHOKtFxzYYcZ9jdioCwSPSZ0ZZWLoNgrK7SWfRh0uaTHNcMZ3wq8ae6CguktIeVTCPTQAqJLQqd7AU0oOCKCJ7BWnC-L8UC6m7Pm9ZS958uUVeWBhgKHzMAGq9UeQB7IEeAcbMn3EDgOSfd8qCb8iwU9iG9dcu9axQwWU7pd7kd-T-He61W7z5wWgpx1KehWCxrN6kuKSo6p-uUfwVnJukreOn8BJNAzADQgz68bhmN9VGih7YcKVnLgwDwKditrjSd6-tfE0Baarj3jWENvT6ohY17R9FDrKS-2v8IIX6tGjoKJw8SRhaWLNv4vWlmxRgR0gdac3qumd0GKqsWSveNz01naA==\"},{\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Hello!\"}]},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Now reply exactly with: Done.\"}]}],\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"low\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"max_output_tokens\":40,\"stream\":true}" }, "response": { "status": 200, "headers": { "content-type": "text/event-stream; charset=utf-8" }, - "body": "event: response.created\ndata: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_074702e1ce54271e016a0f286a489081a2b0380518cc94ee61\",\"object\":\"response\",\"created_at\":1779378282,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":40,\"max_tool_calls\":null,\"model\":\"gpt-5.5-2026-04-23\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"effort\":\"low\",\"summary\":\"detailed\"},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":false,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"low\"},\"tool_choice\":\"auto\",\"tools\":[],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":0}\n\nevent: response.in_progress\ndata: {\"type\":\"response.in_progress\",\"response\":{\"id\":\"resp_074702e1ce54271e016a0f286a489081a2b0380518cc94ee61\",\"object\":\"response\",\"created_at\":1779378282,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":40,\"max_tool_calls\":null,\"model\":\"gpt-5.5-2026-04-23\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"effort\":\"low\",\"summary\":\"detailed\"},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":false,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"low\"},\"tool_choice\":\"auto\",\"tools\":[],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":1}\n\nevent: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"item\":{\"id\":\"rs_074702e1ce54271e016a0f286b72fc81a2afab49f726aa46a1\",\"type\":\"reasoning\",\"encrypted_content\":\"gAAAAABqDyhrbGxCpVJ1BUUZF9hT17B8nh20OfRV2njUYnn2rHuNNnMj_VzITpd1H65KoM24ZiZ4OGf0o0PTezI-wSgBfx_fCA_aSOzjBuHngYPaFNE3tUM3aeEItcK-sml8fpo6nOJH--0AJcPhnLAkIUCEngSvefGE-Icvrdf1VTBaol_C5s_g5n6gTZQmAWof9mJRbztj-2utqJxhYI1AuzD11FHZ-o74PpvXcp_n8A1mfs8ea6d_bpwZ6B-oEvzn5agsM8xoLPkD_rdetVeyxRO2pQnH6GpdkgEzVA5co0QQdLmhtmiQ53ieHoz-cGaxtGBZlv8e5UVcG4eRoxCfT5Djd6dwCaLTGeVxryqAHko3TwXoiSYMJh1djmdkQsF0wAYvlJ5lajtJdbhfoytGf1TWUtwaet7Y04APsCnBhEkY9_bArfhV6cwEo_Aa9zgsGoT6Sdj7iNQnEGmJDVXmG8dYqVdvhhOfIhLP9BByXXxBbfYLSvMyvb5G9RmHI68xnP0ceYwu8x2j12kamPVMKDJVnINmpETLTmZUD7Iagsx-c88xSbtg9ZuS52gjocRy-WOelZ5CrT7huJQswqlMghwedMHBNoMh7xS6t1diHTw3r5Vf-K5h1C5WOsMacZ4WjMc2ylR9aCCp3UVl-GCAICzlsz44ahyFpipL0c7rGwkLG3zoiDRPCVIuXsYxJem053EB6WuYi0CqptnT4piiAUK12Jq0Vth5m25fGz5f_E7qlJhp98WGjaB683nxakLZNPaE6T-sq39QaSciJ_EQztU6AhJ2eqjL07tOrtJ-dgp5N5c0dlSi2XbA7wEm2AWxFR_zT8FeZCpWsGyM24tSAxvdAyShKNiM-d8PZwemNayHY3X29vf27VX3E95VtPt03Uy8D7u3giZu6iGJIMy4Zga5NZqh2Q==\",\"summary\":[]},\"output_index\":0,\"sequence_number\":2}\n\nevent: response.output_item.done\ndata: {\"type\":\"response.output_item.done\",\"item\":{\"id\":\"rs_074702e1ce54271e016a0f286b72fc81a2afab49f726aa46a1\",\"type\":\"reasoning\",\"encrypted_content\":\"gAAAAABqDyhrrrpzGPdm-CBVv7P321p9aOAXnAsONy_JoAKXrfCzGzvC6r0gHUlaLtjcGSPtzCLDvzLxX73y_KHw6Rvb-4cAay3vaicFR7UDmgouTSuzmIpUf-o431N1Pu9k9w49OdwKLHPxZdlVYi9aaVWLMs9SQyinOZmhffmAmA_OoQAlVaTqgcZrW1jKkql4lcywrOJpItyu3_7czpDOszAuc7Zn1BNufkUq8K5UrwizWTYIB9kKpwrKMeSQSM5qX-z6pM9sHKDG4EsLBXom-ESPOpHo2yw4xO9E7-9VSaELYjm2tq-BEmT6SnamUZrDWQESFOnzxNDFnpXZgBTZOgWZjrMEPlJv0Mos-6CLB_odMLaLR6I4o_6ovFnrBUJmSc4VYD4uF2M4bn6XhoC4t3cB1jm3MuPwtLRTs-KPpBAVXYL9NSJDvy-2tJSqJwADXCdMDPTDZQLUJ9F_htrADQtKlHBnLo9RQkwjvV_tRmi7z1En_fEZtiugWKn3jjonQDgsrDpreBF7Wge4RKX0tLAYGxfTqqkM9f2l8YuedCb5DBo4-RJR8MM6ziCnKFWYfZWl06yXrCs4ZdFF0vo73CHNvAT-8xnewcZD_acePekly0GquMEPPnkMqNoVo_72rKY9A4en9s6iZ1iaqXOhvtt8K8oECkWY2g358P742FI--gOaUPYfSh1T5bOm-4pcAl0Ystzxmu4xraWDLyOs6idtw6Bo_tp1KFQH88RhCsbYb_GtC8Ofn1CzCxDB6XHw2FkJNwLpGxo-ctZLqqKghkhy9R76QKxlA8QoW_cQ2-ZQ_X0z4m3E5R61plzkMmBHmp2MJ58C0G2wmOaPQokBYurZeptMJPHCMndGCdZI5QCfgkP9QZmjdHNoNDaBkgyAlyB4Eg4ym7HtK9KaZg-nygYZiJfnmnQCzOc0TE8cvUvji2xKHDu5fHx0F4mO9cViU7v94RBvTHcEDgdb8__aAfcF-mhvQg==\",\"summary\":[]},\"output_index\":0,\"sequence_number\":3}\n\nevent: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"item\":{\"id\":\"msg_074702e1ce54271e016a0f286bd96481a2a778def95bcafbb8\",\"type\":\"message\",\"status\":\"in_progress\",\"content\":[],\"phase\":\"final_answer\",\"role\":\"assistant\"},\"output_index\":1,\"sequence_number\":4}\n\nevent: response.content_part.added\ndata: {\"type\":\"response.content_part.added\",\"content_index\":0,\"item_id\":\"msg_074702e1ce54271e016a0f286bd96481a2a778def95bcafbb8\",\"output_index\":1,\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"\"},\"sequence_number\":5}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"Done\",\"item_id\":\"msg_074702e1ce54271e016a0f286bd96481a2a778def95bcafbb8\",\"logprobs\":[],\"obfuscation\":\"IsAVLUUil7jS\",\"output_index\":1,\"sequence_number\":6}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\".\",\"item_id\":\"msg_074702e1ce54271e016a0f286bd96481a2a778def95bcafbb8\",\"logprobs\":[],\"obfuscation\":\"IT5ulfUNH3uOm2t\",\"output_index\":1,\"sequence_number\":7}\n\nevent: response.output_text.done\ndata: {\"type\":\"response.output_text.done\",\"content_index\":0,\"item_id\":\"msg_074702e1ce54271e016a0f286bd96481a2a778def95bcafbb8\",\"logprobs\":[],\"output_index\":1,\"sequence_number\":8,\"text\":\"Done.\"}\n\nevent: response.content_part.done\ndata: {\"type\":\"response.content_part.done\",\"content_index\":0,\"item_id\":\"msg_074702e1ce54271e016a0f286bd96481a2a778def95bcafbb8\",\"output_index\":1,\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"Done.\"},\"sequence_number\":9}\n\nevent: response.output_item.done\ndata: {\"type\":\"response.output_item.done\",\"item\":{\"id\":\"msg_074702e1ce54271e016a0f286bd96481a2a778def95bcafbb8\",\"type\":\"message\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"Done.\"}],\"phase\":\"final_answer\",\"role\":\"assistant\"},\"output_index\":1,\"sequence_number\":10}\n\nevent: response.completed\ndata: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_074702e1ce54271e016a0f286a489081a2b0380518cc94ee61\",\"object\":\"response\",\"created_at\":1779378282,\"status\":\"completed\",\"background\":false,\"completed_at\":1779378283,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":40,\"max_tool_calls\":null,\"model\":\"gpt-5.5-2026-04-23\",\"moderation\":null,\"output\":[{\"id\":\"rs_074702e1ce54271e016a0f286b72fc81a2afab49f726aa46a1\",\"type\":\"reasoning\",\"encrypted_content\":\"gAAAAABqDyhrNP-3ayBM6JnfBvNL9VA4UViQ_PEk5V7OoQXg_gQtoH257m2m_ezKiov86gyX52tIBW2UkpkMt6-vQF6W4r9a-YkxfIZ9lX4gQihE1fdrkNLGHX6D2lloHFnJ__95zj3bF1rXX_XiGb5ysWyMtj0KzWuxepUIgtFJPTL_5K5fBgyZf9pPJKfFarOaaMBPlv0A9sI0zaC08zQ03vHITevCtUj5zY1qMYP8Fr5O_GsDzp3yc2ZiSXzlPm5WUY4DxF6lgH-dw6F-366o0e7dNFoIOx-VxVjDAE2t5krH4iLg-k5bZ57BP50QR8ASVG2IhzsTOxKwl2f8iVuKx-VGwn0GpLltgl5D0fCKPmOzbWHvg44GnpklfvjXhRaVAArFcLoHiosjHuOeDATmRAwJdyMc3sKysGtJFJDkv_-ZkFtPDTmhR_xohENwdHcNJscB0eYsg7Q5gcaRorzruQDp2y4i0LkmGaV4mYXElqe4wlqWfv-yPTXPt9GFe9XwPv3oHRsc53p4CcFKcXovW-cAIBwngNZPEr5Q0hIKo995pz-A3Ya8Qz61hxDBBacZdrDbR8xroHPNd8MWqq5_UQC2puSBGOKpPrxqXfyNS6E6p3TrpLFq2kah5JCC8TahnMDpvcaoYXhkctoIwVOfNsgq7ewYSEGTmapuW9FGWqusQOt44MhvPczSYUncfeZK2b-B6MurN_lsfzylKG0evE4lHNfEjs1dpbWdYBpCS5SpPSheT8ns6tGrFDzg2MmnZAWAq3NSf7AXDb_z6znszSreqcLZ1ywU0qspzDcDCGPPmzEmEehm7YZ4D0dNuKtfhGJhYpmZ06va5xzdnSoYdHkfShg1sbSd-26EqeiAvv-xAjsNwqc-uAf9P59D7M7v441oXujYE7N7CP60XxMqAhNz78TV0dmqBzQnN9wf1WFMjJe1MOB-75L5oJRLxJzkRRbcGbbS_xKJ95RSOxNFPzF4_Ozh4w==\",\"summary\":[]},{\"id\":\"msg_074702e1ce54271e016a0f286bd96481a2a778def95bcafbb8\",\"type\":\"message\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"Done.\"}],\"phase\":\"final_answer\",\"role\":\"assistant\"}],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"effort\":\"low\",\"summary\":\"detailed\"},\"safety_identifier\":null,\"service_tier\":\"default\",\"store\":false,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"low\"},\"tool_choice\":\"auto\",\"tools\":[],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":{\"input_tokens\":35,\"input_tokens_details\":{\"cached_tokens\":0},\"output_tokens\":17,\"output_tokens_details\":{\"reasoning_tokens\":9},\"total_tokens\":52},\"user\":null,\"metadata\":{}},\"sequence_number\":11}\n\n" + "body": "event: response.created\ndata: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_0a0794dab3b8ec7d016a1235e991c88195a4d2f9766babd985\",\"object\":\"response\",\"created_at\":1779578345,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":40,\"max_tool_calls\":null,\"model\":\"gpt-5.5-2026-04-23\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"context\":\"current_turn\",\"effort\":\"low\",\"summary\":\"detailed\"},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":false,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"low\"},\"tool_choice\":\"auto\",\"tools\":[],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":0}\n\nevent: response.in_progress\ndata: {\"type\":\"response.in_progress\",\"response\":{\"id\":\"resp_0a0794dab3b8ec7d016a1235e991c88195a4d2f9766babd985\",\"object\":\"response\",\"created_at\":1779578345,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":40,\"max_tool_calls\":null,\"model\":\"gpt-5.5-2026-04-23\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"context\":\"current_turn\",\"effort\":\"low\",\"summary\":\"detailed\"},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":false,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"low\"},\"tool_choice\":\"auto\",\"tools\":[],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":1}\n\nevent: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"item\":{\"id\":\"rs_0a0794dab3b8ec7d016a1235ea4dd88195a32179255ed6c532\",\"type\":\"reasoning\",\"encrypted_content\":\"gAAAAABqEjXqB-kOX_0QAeoEksgNjwSbtGmVEQuMj5ODcFV6b7Kp3E8RoHRRmSXtRH0rtNbZRbhKz5jM48DUpDI1WTeO2HqCd_A3fsSgFxp5ACGVFjPWjfvP2JMdDkpoOo5gu2zy7WsWY0fseocQQ5q_jfG6SWw0fyaeeqfdQ9HkcHyg6gVEl5skb4L8_2lD5nClmLlNVVh5JCuXRH9eYysrfO19NOZ29A2MVUX-XgB6mmK5uSb1jE43GhrEPPYrMbB5JyzM6B-yeB8rE4H2wx530hQqtxwSZREa8G03rzTJ49_KAPWl0djGDDtufUX-t4EpBHo6loA3PMuiZ3VsJTkkPpEqkm6QQyAVkQ_8AdRu12CqHbFdu73I-BnArzr33yW6reNUjnZjFV5bWDyxIMh6ljy3O_2nGk-qdTLt6bGJbEjTdPj1hi7icYZTVPqofPU4pjlo9BnIBheo-4u9pA26V9G9vDAtM4myDdMEe4pnieUztBUYPUOVMaG2U9gqtNs6iPehKo3BeKy4lhYPorL2OPmf1lVUQOCW1MBbwT5xt1kjOVw7LggnyjrBsXVvDBWg0AFcvm14r3ZQezPgLetQfSx56mVEJpui9BuVSUg2Xvqb5tCCip6TipUVvzZJKKkxN43o8N6UVXLIn6wgstAn9727JgBEsjMxzvuOWaaI-qM3dWMcFzFSGvKb6gTiF37AhSOzosf31hnsGx8AnGmLmbuW3IMhZXZZMHgVUHx4p8pNRPeoCE-Bv833KZbRfVxe6tbmkLadBjXYaCQqPXDHBR7qbpfu4_M9UAy0kpic-joepxQKsCT2t6vsNaThRYaN3PtIgjs5xxAfa9yKeIYak8yiP96CUlME9Y7zaOIydPWYBhWLf3phQsWMax9eKdLDh19f0Y0iAJPpk--1xd2OVTuDxKIMpA==\",\"summary\":[]},\"output_index\":0,\"sequence_number\":2}\n\nevent: response.output_item.done\ndata: {\"type\":\"response.output_item.done\",\"item\":{\"id\":\"rs_0a0794dab3b8ec7d016a1235ea4dd88195a32179255ed6c532\",\"type\":\"reasoning\",\"encrypted_content\":\"gAAAAABqEjXqW5MInCBPKulZ1lizyFtOaKUpKgHldAXVjTTs4XFE45gtxC1NbJoOi2tHoQhpfq-JGxtjSQEDTHnMCiLLhyqvQ4GlWVF4n51xcFVC_WgymkZqDxG6xPw8ITAsRI5vb8HiPO6EmmKt6xGIVXOjrrxRNAY3xtrByeYSvnCa6FDUHEkMeXmwllBalCeQPNDPl0Ub2ehuchNG0loMVLJoOjT-2KgDrXlOa6rCn3nUf4U1W5JA_kHytlgrD0IPbs7nY8wemdynJRXBoNSOT_U3nQSB6j-i4KIJAdLiUs9LVWMYleqmFQNs8S4dC3i5DfpHXWUMZ5Ai1d3gbvMP8bH7fsUyfIhyiDUvlgr6PZ9rfh8JqkjOpiQ7NFtSDuHQGdx__W3qi23WPDp3iQjKxVl1oUXfbMzsPE4bmNN9dnJ9qTQ43kvw8GyrGrSqRS8jCKuk9bxqeR_ibj4KoDdxvVbUeGMg3WKANfCRsNXlxwYtMpu3I4HxKm5EuMNKDg_e9RFH2wFEDm9wCKMZrC_5LShgKhSfhsk3yJ47Mit0zYdX27kyHGlZvpzLPYKdtHW1O15KNT4gFKBrIguCtjXz2Lb42ENM6Jo8BTY7BZbf0hXZ4A5mFNl_gyLVHWEHpR1GcYiJbs9RtQ-7qX_PTeZ11iFY3a7_jM7TK3WEN2IuG0OKbZVHvOkVcvyBEgIbzSzCzhtC-j584knI4WmYiqnltuwRcR2N3sxYY3vMcYGA2_AU5kYlZcJcztapTTW-aKbyGxPcw5D_dqb5mGpDqJgquye-qOufDt4Fd7cSc-g8awqR8QPsLz-ZDPLMB9JKQ3VqLQlNCKDUoDodGOAL3h-7EQG66osALfhpdsWcNmuVqlb0lNAXklrsZJtRKBU4pJ1UGCyVDwde7nv6I9PW19VumaRrJhc2cC52qUoyihvUo8xJsElaFp7-EHn5ymS4znZhRfyA_4UDL4rwj-3DqbMwNeDJrgBc3w==\",\"summary\":[]},\"output_index\":0,\"sequence_number\":3}\n\nevent: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"item\":{\"id\":\"msg_0a0794dab3b8ec7d016a1235eaae648195ab7ad5385b641107\",\"type\":\"message\",\"status\":\"in_progress\",\"content\":[],\"phase\":\"final_answer\",\"role\":\"assistant\"},\"output_index\":1,\"sequence_number\":4}\n\nevent: response.content_part.added\ndata: {\"type\":\"response.content_part.added\",\"content_index\":0,\"item_id\":\"msg_0a0794dab3b8ec7d016a1235eaae648195ab7ad5385b641107\",\"output_index\":1,\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"\"},\"sequence_number\":5}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"Done\",\"item_id\":\"msg_0a0794dab3b8ec7d016a1235eaae648195ab7ad5385b641107\",\"logprobs\":[],\"obfuscation\":\"7tyE7hMvNOTM\",\"output_index\":1,\"sequence_number\":6}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\".\",\"item_id\":\"msg_0a0794dab3b8ec7d016a1235eaae648195ab7ad5385b641107\",\"logprobs\":[],\"obfuscation\":\"RGXvuTTSJS3AT5E\",\"output_index\":1,\"sequence_number\":7}\n\nevent: response.output_text.done\ndata: {\"type\":\"response.output_text.done\",\"content_index\":0,\"item_id\":\"msg_0a0794dab3b8ec7d016a1235eaae648195ab7ad5385b641107\",\"logprobs\":[],\"output_index\":1,\"sequence_number\":8,\"text\":\"Done.\"}\n\nevent: response.content_part.done\ndata: {\"type\":\"response.content_part.done\",\"content_index\":0,\"item_id\":\"msg_0a0794dab3b8ec7d016a1235eaae648195ab7ad5385b641107\",\"output_index\":1,\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"Done.\"},\"sequence_number\":9}\n\nevent: response.output_item.done\ndata: {\"type\":\"response.output_item.done\",\"item\":{\"id\":\"msg_0a0794dab3b8ec7d016a1235eaae648195ab7ad5385b641107\",\"type\":\"message\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"Done.\"}],\"phase\":\"final_answer\",\"role\":\"assistant\"},\"output_index\":1,\"sequence_number\":10}\n\nevent: response.completed\ndata: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_0a0794dab3b8ec7d016a1235e991c88195a4d2f9766babd985\",\"object\":\"response\",\"created_at\":1779578345,\"status\":\"completed\",\"background\":false,\"completed_at\":1779578346,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":40,\"max_tool_calls\":null,\"model\":\"gpt-5.5-2026-04-23\",\"moderation\":null,\"output\":[{\"id\":\"rs_0a0794dab3b8ec7d016a1235ea4dd88195a32179255ed6c532\",\"type\":\"reasoning\",\"encrypted_content\":\"gAAAAABqEjXq8oliF2VeqiOUi-jUdi49emjffD6wtbmxlwQWbJ6tSxXIjyXvCeclOqKx83G83GyDOJqvR4L_D8V_ebJtgG87ahWB8Rr9LEQDoLT24n4Vz279xtHMxEGgv7f0NmaXu2dGFeFY_s2RhH-DqNE7V4nEkS7odJOTkhxTKgEcxtz3dDlEnGU7IgN2sD1lh9y90BD3ysvARegy4Cs0DhUjLOvkx11G9lk5dQ3yo1ek8JhTHpnVSYrLDYIudCh6pfu1yP1tx8xbxDHUcwlNclU9Hp_9ils5FhZNWC_tiLDscXXvRPBgMF77jdOicCV6cyUV0Snsu1_KSRbm4rLtgXLXVMqFyYpxdyicsD577e4yZ0VVXT4Oo_af0eDh3I3ZPIWui38EmYuoRhvQuYZkqjhGd_xOkvjQF4_Tp6cyNO0XdAMGMoYG-5npHC0gcPpv56qYGX8ffj0P8ZyR9shn3H7kcQqE2YXXBa42VKK0poPbC996xSqFNW7ygePel41h493XlJ70wnP50vFY5s0raNFf9eLP3YYmLxiPks9gshayGwUQXNNwrSimoQv3OeJzRzihbzZNWTfhR4xKs53nlXMjwnnXwHRH5D07vJg_1zU7BQzJ-QRLZnsnhIOq3psHt1yuoCtsSTKBN6HPiR81F-snIttJiUAiYsgv_ajwPxxnKP0FnFXQfBuaUAtAOD5G_3MC1yECjzq-YI4MDOXj4dsIGnHkdzXo-DV2lXMl2WnPqytoUkugp14SWbJso-eDsN5QivqspnYc1VsdNAaOOgjBiHmi-bACI1CykrkuiYJm1nOHAH4L4IQjpd0pcNm-Dk7z9LGIE5lwKI07hLXp_ByhVXRT8xWuugl43pzoM1jgYD4LjTjScC3ymauqqvKjjoHfnt0Zma0eVDeQrnVT6W9RQ9wDt5KVebrrwJTqlaNV0HywZJo3gwFy-Qq5MfwAwwC-GdjMsER1TgXO_E5kFZZD4sNVgw==\",\"summary\":[]},{\"id\":\"msg_0a0794dab3b8ec7d016a1235eaae648195ab7ad5385b641107\",\"type\":\"message\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"Done.\"}],\"phase\":\"final_answer\",\"role\":\"assistant\"}],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"context\":\"current_turn\",\"effort\":\"low\",\"summary\":\"detailed\"},\"safety_identifier\":null,\"service_tier\":\"default\",\"store\":false,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"low\"},\"tool_choice\":\"auto\",\"tools\":[],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":{\"input_tokens\":35,\"input_tokens_details\":{\"cached_tokens\":0},\"output_tokens\":20,\"output_tokens_details\":{\"reasoning_tokens\":12},\"total_tokens\":55},\"user\":null,\"metadata\":{}},\"sequence_number\":11}\n\n" } } ] diff --git a/packages/llm/test/fixtures/recordings/openai-responses/openai-responses-gpt-5-5-reasoning.json b/packages/llm/test/fixtures/recordings/openai-responses/openai-responses-gpt-5-5-reasoning.json index 9ec71084a9..107192d951 100644 --- a/packages/llm/test/fixtures/recordings/openai-responses/openai-responses-gpt-5-5-reasoning.json +++ b/packages/llm/test/fixtures/recordings/openai-responses/openai-responses-gpt-5-5-reasoning.json @@ -2,7 +2,7 @@ "version": 1, "metadata": { "name": "openai-responses/openai-responses-gpt-5-5-reasoning", - "recordedAt": "2026-05-21T00:31:43.337Z", + "recordedAt": "2026-05-23T23:19:03.175Z", "provider": "openai", "route": "openai-responses", "transport": "http", @@ -18,14 +18,14 @@ "headers": { "content-type": "application/json" }, - "body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Show concise reasoning when the provider supports visible reasoning summaries.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Think briefly, then reply exactly with: Hello!\"}]}],\"store\":false,\"reasoning\":{\"effort\":\"low\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"max_output_tokens\":120,\"stream\":true}" + "body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Show concise reasoning when the provider supports visible reasoning summaries.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Think briefly, then reply exactly with: Hello!\"}]}],\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"low\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"max_output_tokens\":120,\"stream\":true}" }, "response": { "status": 200, "headers": { "content-type": "text/event-stream; charset=utf-8" }, - "body": "event: response.created\ndata: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_06ed52e908377c6e016a0e526d81b481a08a5e1bb9a924eb35\",\"object\":\"response\",\"created_at\":1779323501,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":120,\"max_tool_calls\":null,\"model\":\"gpt-5.5-2026-04-23\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"effort\":\"low\",\"summary\":\"detailed\"},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":false,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"low\"},\"tool_choice\":\"auto\",\"tools\":[],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":0}\n\nevent: response.in_progress\ndata: {\"type\":\"response.in_progress\",\"response\":{\"id\":\"resp_06ed52e908377c6e016a0e526d81b481a08a5e1bb9a924eb35\",\"object\":\"response\",\"created_at\":1779323501,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":120,\"max_tool_calls\":null,\"model\":\"gpt-5.5-2026-04-23\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"effort\":\"low\",\"summary\":\"detailed\"},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":false,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"low\"},\"tool_choice\":\"auto\",\"tools\":[],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":1}\n\nevent: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"item\":{\"id\":\"rs_06ed52e908377c6e016a0e526e536881a0a0e4f50546eca329\",\"type\":\"reasoning\",\"summary\":[]},\"output_index\":0,\"sequence_number\":2}\n\nevent: response.output_item.done\ndata: {\"type\":\"response.output_item.done\",\"item\":{\"id\":\"rs_06ed52e908377c6e016a0e526e536881a0a0e4f50546eca329\",\"type\":\"reasoning\",\"summary\":[]},\"output_index\":0,\"sequence_number\":3}\n\nevent: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"item\":{\"id\":\"msg_06ed52e908377c6e016a0e526f03d881a0ade18629ec05cc67\",\"type\":\"message\",\"status\":\"in_progress\",\"content\":[],\"phase\":\"final_answer\",\"role\":\"assistant\"},\"output_index\":1,\"sequence_number\":4}\n\nevent: response.content_part.added\ndata: {\"type\":\"response.content_part.added\",\"content_index\":0,\"item_id\":\"msg_06ed52e908377c6e016a0e526f03d881a0ade18629ec05cc67\",\"output_index\":1,\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"\"},\"sequence_number\":5}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"Hello\",\"item_id\":\"msg_06ed52e908377c6e016a0e526f03d881a0ade18629ec05cc67\",\"logprobs\":[],\"obfuscation\":\"MsHl8mCgwLd\",\"output_index\":1,\"sequence_number\":6}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"!\",\"item_id\":\"msg_06ed52e908377c6e016a0e526f03d881a0ade18629ec05cc67\",\"logprobs\":[],\"obfuscation\":\"3HOMNPxXXgADovZ\",\"output_index\":1,\"sequence_number\":7}\n\nevent: response.output_text.done\ndata: {\"type\":\"response.output_text.done\",\"content_index\":0,\"item_id\":\"msg_06ed52e908377c6e016a0e526f03d881a0ade18629ec05cc67\",\"logprobs\":[],\"output_index\":1,\"sequence_number\":8,\"text\":\"Hello!\"}\n\nevent: response.content_part.done\ndata: {\"type\":\"response.content_part.done\",\"content_index\":0,\"item_id\":\"msg_06ed52e908377c6e016a0e526f03d881a0ade18629ec05cc67\",\"output_index\":1,\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"Hello!\"},\"sequence_number\":9}\n\nevent: response.output_item.done\ndata: {\"type\":\"response.output_item.done\",\"item\":{\"id\":\"msg_06ed52e908377c6e016a0e526f03d881a0ade18629ec05cc67\",\"type\":\"message\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"Hello!\"}],\"phase\":\"final_answer\",\"role\":\"assistant\"},\"output_index\":1,\"sequence_number\":10}\n\nevent: response.completed\ndata: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_06ed52e908377c6e016a0e526d81b481a08a5e1bb9a924eb35\",\"object\":\"response\",\"created_at\":1779323501,\"status\":\"completed\",\"background\":false,\"completed_at\":1779323503,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":120,\"max_tool_calls\":null,\"model\":\"gpt-5.5-2026-04-23\",\"moderation\":null,\"output\":[{\"id\":\"rs_06ed52e908377c6e016a0e526e536881a0a0e4f50546eca329\",\"type\":\"reasoning\",\"summary\":[]},{\"id\":\"msg_06ed52e908377c6e016a0e526f03d881a0ade18629ec05cc67\",\"type\":\"message\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"Hello!\"}],\"phase\":\"final_answer\",\"role\":\"assistant\"}],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"effort\":\"low\",\"summary\":\"detailed\"},\"safety_identifier\":null,\"service_tier\":\"default\",\"store\":false,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"low\"},\"tool_choice\":\"auto\",\"tools\":[],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":{\"input_tokens\":31,\"input_tokens_details\":{\"cached_tokens\":0},\"output_tokens\":20,\"output_tokens_details\":{\"reasoning_tokens\":12},\"total_tokens\":51},\"user\":null,\"metadata\":{}},\"sequence_number\":11}\n\n" + "body": "event: response.created\ndata: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_05f7e55dc1d2d743016a1235e5bb3c8193b4c0e30fa316700c\",\"object\":\"response\",\"created_at\":1779578341,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":120,\"max_tool_calls\":null,\"model\":\"gpt-5.5-2026-04-23\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"context\":\"current_turn\",\"effort\":\"low\",\"summary\":\"detailed\"},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":false,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"low\"},\"tool_choice\":\"auto\",\"tools\":[],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":0}\n\nevent: response.in_progress\ndata: {\"type\":\"response.in_progress\",\"response\":{\"id\":\"resp_05f7e55dc1d2d743016a1235e5bb3c8193b4c0e30fa316700c\",\"object\":\"response\",\"created_at\":1779578341,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":120,\"max_tool_calls\":null,\"model\":\"gpt-5.5-2026-04-23\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"context\":\"current_turn\",\"effort\":\"low\",\"summary\":\"detailed\"},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":false,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"low\"},\"tool_choice\":\"auto\",\"tools\":[],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":1}\n\nevent: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"item\":{\"id\":\"rs_05f7e55dc1d2d743016a1235e65eac8193ba914b956e3a49e1\",\"type\":\"reasoning\",\"encrypted_content\":\"gAAAAABqEjXmpkIEr1Ox1CgTHFUzuGnLwOE9YZFf7lBDRcOgQiNuTk-CopgZ20-kaosULz5cy40Ga0zkuwMFxLHSH4Cyebm9mQCMMZmzZQybp-s-yfNGm9zz7kAh_y6NbaAQZiI2iaTY5_sQwIrMsdQ56-T3_--unhlXVjEqMwdbsQOLpTg0KpAigUOJkuAl4PgCHsr6p_oTIZ5ycChpihxh6Oyuyf_YzLz-bL63kWZeu2bZFYE3WftnJNzcY5VjaNEVDlYY5IBbgCqOCc8OhLrYarwaPdhiizu1FIuhreEDiYurEQOa5jSW3wN2KGUhfDPurzguIbZ-ZhSRW58w9UgrhYOS30bQkm3EYd7w9nI5MteZruw3hIVKR__tU_2T2o7GsTxYNYLgn7Q8WoBASZrBtDpXuBM5jO2JQzDEFPG9_S8f44_LDgKInHgh-t32eV1ZNW08Pg9l0tUutiqdEVbDFKFB68Eu5PCKRKqZ18VzXI2X_YeU0DW9ld_GoSesI88rmWCmOta2AQOrVNa2uehlMRXb0wGXKzS6t31di0_PQwPj9rBdVk-N3S-FOlF5fFEGDqWBR3E0lrEOtNMeIuR8u3gGDbEacqL-Sz22Mi7AJou7trOnVH5qFqbKpfOJnxpzjOeWUYVDpmVFD81Om7Fc6zlZsv0KIEt-w5IE_dXi2_guUpu0JhowDWFI9aFcRVZ6A9z7Mrwqtkrs9QqRJws2BRUde-6MOryUcKWGfwdkNMRkAVHSlC6T3Yq44UtLqb1gJ-vasc2K9BZwRtYBVy0jif1tjrMviiNy9ltdxPVlIJ-R_4JcYtcbRQc1ovd-aQ9MprDatYE3Vnmk_O_WSnzHRxzPbftBHb_IWwir4G3lTOTi7xopkmVFidgl03h72BfbmwkzKXlMmvClRYP5u-mYyZoG50h9ag==\",\"summary\":[]},\"output_index\":0,\"sequence_number\":2}\n\nevent: response.output_item.done\ndata: {\"type\":\"response.output_item.done\",\"item\":{\"id\":\"rs_05f7e55dc1d2d743016a1235e65eac8193ba914b956e3a49e1\",\"type\":\"reasoning\",\"encrypted_content\":\"gAAAAABqEjXn9Xij0jt39Rh_0pwIWNN4jY3e6aD8Z0BBuhH_kgkhr6VNIvsDQdD8q9ju8vkF4JnhDqcfSIbdtX_8r4HHiV7O6kFEHRSiW23Z775HdB6fggaELggnLAcBN4MxzN618I2CV-pnNStMVMeUZtbV22l6o0F6n00-_5vm7KkP44JQRFrYj_iypI9AY0S0OqItcAiB6ERLLdKTJELOAzGxyeHKpQeWH3kQY9ZbSFLfUVooZjZEEBBB3sLMkvkDq6J9iIyQFKXZeo_oFvayxWFD8BwW3fVuUFcg0waG5TJwHA90iPFjOCddkk4Kn07cIwoyuM8xYquLPQ2x3jBLninoP8UyRXrQaDFR3maPtQ4HbNBf9cz5nZmXmjmZnO9Ee78ELcU_LkvmTgtozd094XVxLHg9DN-Y_s-1GMD9SgiqBNePe-1f_Sf2FD-CQsE-vDIcpmlLwxrhkXkwZ8Ws4k8NhA1Kq8DA5KYmz7S2akl2Vg2wr5ZrdH1XQBarWUIwlhTFALhTipDPinqhytnxm5MEmu09MBGXYOP5qNQ2hT2F_ibH7kM4kBQV5zVLDl6U2ZsKDt_r7F9in8YU5ffLYAd_4M9FgsJQUvC1CxLRSSQyyPKbdy0mYCrBLTo68FGV36JIj_SQBYHp9I5IIXfMfw1d78SNN_jtgs8JweLVNhOzoOFqMEYgO4zO6xa6r7E1F6YtuQ3oQ8OIEjXIeWeLsRGn51hNYA9s2hfm8Qh14rq5TMV9QhDXj0a-7FO6PIqTUr2ZqfB6Oqeb_Ut708MWtIjZ0oRSYkDddS8QzcwGbg1AFwF9okVtZZqXyItZseC9hKQ7vjh9pWaMrsKT8pYxjbB1fwKPgwTZHGzQzhkLXsp7btR5DpRP4h1-YHTsiMDlEdjtZVF0Zn7OuKTQlanB14FsmaaJq-Ty0pktr9ejKji3VmmDeIWTWKmIMvnp9nc3_Wl_Cbvschquknk7Hd12KZkyRaWq-Q==\",\"summary\":[]},\"output_index\":0,\"sequence_number\":3}\n\nevent: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"item\":{\"id\":\"msg_05f7e55dc1d2d743016a1235e7171481938e1402264186381c\",\"type\":\"message\",\"status\":\"in_progress\",\"content\":[],\"phase\":\"final_answer\",\"role\":\"assistant\"},\"output_index\":1,\"sequence_number\":4}\n\nevent: response.content_part.added\ndata: {\"type\":\"response.content_part.added\",\"content_index\":0,\"item_id\":\"msg_05f7e55dc1d2d743016a1235e7171481938e1402264186381c\",\"output_index\":1,\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"\"},\"sequence_number\":5}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"Hello\",\"item_id\":\"msg_05f7e55dc1d2d743016a1235e7171481938e1402264186381c\",\"logprobs\":[],\"obfuscation\":\"rArO0JBBk8l\",\"output_index\":1,\"sequence_number\":6}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"!\",\"item_id\":\"msg_05f7e55dc1d2d743016a1235e7171481938e1402264186381c\",\"logprobs\":[],\"obfuscation\":\"WgBYGB6udiKMN2k\",\"output_index\":1,\"sequence_number\":7}\n\nevent: response.output_text.done\ndata: {\"type\":\"response.output_text.done\",\"content_index\":0,\"item_id\":\"msg_05f7e55dc1d2d743016a1235e7171481938e1402264186381c\",\"logprobs\":[],\"output_index\":1,\"sequence_number\":8,\"text\":\"Hello!\"}\n\nevent: response.content_part.done\ndata: {\"type\":\"response.content_part.done\",\"content_index\":0,\"item_id\":\"msg_05f7e55dc1d2d743016a1235e7171481938e1402264186381c\",\"output_index\":1,\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"Hello!\"},\"sequence_number\":9}\n\nevent: response.output_item.done\ndata: {\"type\":\"response.output_item.done\",\"item\":{\"id\":\"msg_05f7e55dc1d2d743016a1235e7171481938e1402264186381c\",\"type\":\"message\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"Hello!\"}],\"phase\":\"final_answer\",\"role\":\"assistant\"},\"output_index\":1,\"sequence_number\":10}\n\nevent: response.completed\ndata: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_05f7e55dc1d2d743016a1235e5bb3c8193b4c0e30fa316700c\",\"object\":\"response\",\"created_at\":1779578341,\"status\":\"completed\",\"background\":false,\"completed_at\":1779578343,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":120,\"max_tool_calls\":null,\"model\":\"gpt-5.5-2026-04-23\",\"moderation\":null,\"output\":[{\"id\":\"rs_05f7e55dc1d2d743016a1235e65eac8193ba914b956e3a49e1\",\"type\":\"reasoning\",\"encrypted_content\":\"gAAAAABqEjXnFOrSKMBad2Atutni2Bxu4oRnudNwadGzwBCliXn-ocbCrlL6URwbrS22k21EaNPztTRWQVOGMzldY1PrMyJQZxj479kaAPXFbXir2xv5QfL6ykVcNNb5yn8eV5flXuOPfRM1dkosOD5QtlEhOvFbW14i-WhbjU1fpTiDCbLIotPo8tkuIk-zJMSgA0jlE73Rmz2BQlT-ZSTqJA7f9hT93QMYjsWNCslk40WCbmqo4dJuuf19qg_2ktrXu5yjmde8QU-blxhPKiDsMlj4rFA1F6_pNqJ_oUgU6mwIuUDIVPLxIaV3IBDS9j9XjiBSzwtFAsotlqWv_D1vWGUKSGPQKPs31ylYvBVQMTOdXxQZ6TKPveYybka8XbAEVNTFAJjg5PDRKWikfqqH4b0W_FFVNCSSf1UIgLh2-Hf-xMRBXozAFkCoj8M_wZOqj-gYoIsaoLnm7Z15rMrNiL2aMgNc1-mB1Iw1rFTo7hdiLiEj7C0rmm77CrxkFfQgpWsVAw94xpbVO7oC4Xiy1UNB-VCCb5FLNAHyJyLd_wc0SVuEsxjllLZ-q3unHYDnZO-zuxhjZ2jodX_7tbM6PCKul5ETSxtWQztzZD3MZ8ED_kVagXNNth-BTuTB4W9678cQbkSZK0DzV50H2_NJuRgQdCROnVKiiA2mzAOl7LXzDLTdTlHOQYRKt3wvaFI1DkVD0T0_Wj3z9lI2kgx6ExtOvU9nkqnPv4gf1x2ce8Ao9uf8NgtQbqhpeGMoU0Km9AALkoCtiyIXYJWNVVYAi9JA9QS05K-S__J_GlgFg7J3_YiH8bcmMrfwryn9K6yelsLt3SBKCA9pkLVI-HFDRx19brkAQRZRlaflF7qQApps8MQBGGrvrKTui_4EAABhFmjsn8XVIGoz7f_MFoS6ijdi8LCHEf8MNquinKURmb8Cdg1Zekrbl6KGsdcPztftMKH-sO6bEWZeUWJb2KqtIDnH5jr-Qw==\",\"summary\":[]},{\"id\":\"msg_05f7e55dc1d2d743016a1235e7171481938e1402264186381c\",\"type\":\"message\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"Hello!\"}],\"phase\":\"final_answer\",\"role\":\"assistant\"}],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"context\":\"current_turn\",\"effort\":\"low\",\"summary\":\"detailed\"},\"safety_identifier\":null,\"service_tier\":\"default\",\"store\":false,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"low\"},\"tool_choice\":\"auto\",\"tools\":[],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":{\"input_tokens\":31,\"input_tokens_details\":{\"cached_tokens\":0},\"output_tokens\":21,\"output_tokens_details\":{\"reasoning_tokens\":13},\"total_tokens\":52},\"user\":null,\"metadata\":{}},\"sequence_number\":11}\n\n" } } ] diff --git a/packages/llm/test/provider/openai-responses.test.ts b/packages/llm/test/provider/openai-responses.test.ts index 3f291728ab..dd57d00a91 100644 --- a/packages/llm/test/provider/openai-responses.test.ts +++ b/packages/llm/test/provider/openai-responses.test.ts @@ -393,7 +393,7 @@ describe("OpenAI Responses route", () => { promptCacheKey: "session_123", reasoningEffort: "high", reasoningSummary: "auto", - includeEncryptedReasoning: true, + include: ["reasoning.encrypted_content"], }, }, }), @@ -407,6 +407,108 @@ describe("OpenAI Responses route", () => { }), ) + it.effect("accepts the full ResponseIncludable union", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + model, + prompt: "hi", + providerOptions: { + openai: { + include: ["reasoning.encrypted_content", "code_interpreter_call.outputs", "web_search_call.results"], + }, + }, + }), + ) + + expect(prepared.body.include).toEqual([ + "reasoning.encrypted_content", + "code_interpreter_call.outputs", + "web_search_call.results", + ]) + }), + ) + + it.effect("filters unknown includable values out of the include array", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + model, + prompt: "hi", + // The user passed one invalid entry alongside a valid one. Keep the + // valid one so the request still succeeds rather than failing on a + // typo from upstream config. + providerOptions: { openai: { include: ["reasoning.encrypted_content", "bogus.thing"] } }, + }), + ) + + expect(prepared.body.include).toEqual(["reasoning.encrypted_content"]) + }), + ) + + it.effect("treats an explicit empty include as no include at all", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ model, prompt: "hi", providerOptions: { openai: { include: [] } } }), + ) + + expect(prepared.body.include).toBeUndefined() + }), + ) + + it.effect("treats an all-invalid include as no include at all", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ model, prompt: "hi", providerOptions: { openai: { include: ["bogus.thing"] } } }), + ) + + expect(prepared.body.include).toBeUndefined() + }), + ) + + it.effect("omits include when no include is set", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ model, prompt: "hi", providerOptions: { openai: { store: false } } }), + ) + + expect(prepared.body.include).toBeUndefined() + }), + ) + + it.effect("requests encrypted reasoning by default for GPT-5 reasoning models", () => + Effect.gen(function* () { + // The native OpenAI facade configures GPT-5 stateless (store: false) with + // reasoningSummary: "auto" by default. Without `include`, a follow-up + // turn cannot replay reasoning state, so the facade also opts into + // `reasoning.encrypted_content` automatically. + const prepared = yield* LLMClient.prepare( + LLM.request({ + model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responses("gpt-5.2"), + prompt: "hi", + }), + ) + + expect(prepared.body.store).toBe(false) + expect(prepared.body.include).toEqual(["reasoning.encrypted_content"]) + expect(prepared.body.reasoning).toEqual({ effort: "medium", summary: "auto" }) + }), + ) + + it.effect("lets callers opt out of the GPT-5 default include", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responses("gpt-5.2"), + prompt: "hi", + providerOptions: { openai: { include: [] } }, + }), + ) + + expect(prepared.body.include).toBeUndefined() + }), + ) + it.effect("request OpenAI provider options override route defaults", () => Effect.gen(function* () { const prepared = yield* LLMClient.prepare( @@ -547,6 +649,94 @@ describe("OpenAI Responses route", () => { }), ) + it.effect("streams each reasoning summary part as a separate block", () => + Effect.gen(function* () { + const response = yield* LLMClient.generate( + LLM.updateRequest(request, { providerOptions: { openai: { store: false } } }), + ).pipe( + Effect.provide( + fixedResponse( + sseEvents( + { + type: "response.output_item.added", + item: { type: "reasoning", id: "rs_1", encrypted_content: null }, + }, + { type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 0 }, + { type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 0, delta: "First" }, + { type: "response.reasoning_summary_part.done", item_id: "rs_1", summary_index: 0 }, + { type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 1 }, + { type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 1, delta: "Second" }, + { type: "response.reasoning_summary_part.done", item_id: "rs_1", summary_index: 1 }, + { + type: "response.output_item.done", + item: { type: "reasoning", id: "rs_1", encrypted_content: "encrypted-state" }, + }, + { type: "response.completed", response: { id: "resp_1" } }, + ), + ), + ), + ) + + expect(response.reasoning).toBe("FirstSecond") + expect(response.events).toMatchObject([ + { type: "step-start", index: 0 }, + { + type: "reasoning-start", + id: "rs_1:0", + providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: null } }, + }, + { type: "reasoning-delta", id: "rs_1:0", text: "First" }, + { type: "reasoning-end", id: "rs_1:0", providerMetadata: { openai: { itemId: "rs_1" } } }, + { + type: "reasoning-start", + id: "rs_1:1", + providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: null } }, + }, + { type: "reasoning-delta", id: "rs_1:1", text: "Second" }, + { + type: "reasoning-end", + id: "rs_1:1", + providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } }, + }, + { type: "step-finish", index: 0, reason: "stop" }, + { type: "finish", reason: "stop" }, + ]) + }), + ) + + it.effect("closes reasoning summary parts when storage is not disabled", () => + Effect.gen(function* () { + const response = yield* LLMClient.generate(request).pipe( + Effect.provide( + fixedResponse( + sseEvents( + { + type: "response.output_item.added", + item: { type: "reasoning", id: "rs_1", encrypted_content: null }, + }, + { type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 0 }, + { type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 0, delta: "First" }, + { type: "response.reasoning_summary_part.done", item_id: "rs_1", summary_index: 0 }, + { type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 1 }, + { type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 1, delta: "Second" }, + { type: "response.reasoning_summary_part.done", item_id: "rs_1", summary_index: 1 }, + { + type: "response.output_item.done", + item: { type: "reasoning", id: "rs_1", encrypted_content: null }, + }, + { type: "response.completed", response: { id: "resp_1" } }, + ), + ), + ), + ) + + expect(response.events.filter((event) => event.type === "reasoning-end")).toEqual([ + { type: "reasoning-end", id: "rs_1:0", providerMetadata: { openai: { itemId: "rs_1" } } }, + { type: "reasoning-end", id: "rs_1:1", providerMetadata: { openai: { itemId: "rs_1" } } }, + ]) + }), + ) + it.effect("continues a stateless reasoning conversation", () => Effect.gen(function* () { const response = yield* LLMClient.generate( @@ -570,6 +760,7 @@ describe("OpenAI Responses route", () => { ]), Message.user("Summarize it."), ], + providerOptions: { openai: { store: false } }, }), ).pipe( Effect.provide( @@ -627,6 +818,7 @@ describe("OpenAI Responses route", () => { { type: "text", text: "After." }, ]), ], + providerOptions: { openai: { store: false } }, }), ) @@ -643,6 +835,66 @@ describe("OpenAI Responses route", () => { }), ) + it.effect("references stored reasoning items by id", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + model, + messages: [ + Message.assistant([ + { + type: "reasoning", + text: "Checked the previous diff.", + providerMetadata: { openai: { itemId: "rs_1" } }, + }, + ]), + ], + providerOptions: { openai: { store: true } }, + }), + ) + + expect(prepared.body.input).toEqual([{ type: "item_reference", id: "rs_1" }]) + }), + ) + + it.effect("joins streamed summary blocks into one continuation reasoning item", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + id: "req_multi_summary_continuation", + model, + messages: [ + Message.assistant([ + { + type: "reasoning", + text: "First", + providerMetadata: { openai: { itemId: "rs_1" } }, + }, + { + type: "reasoning", + text: "Second", + providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } }, + }, + ]), + ], + providerOptions: { openai: { store: false } }, + }), + ) + + expect(prepared.body.input).toEqual([ + { + type: "reasoning", + id: "rs_1", + encrypted_content: "encrypted-state", + summary: [ + { type: "summary_text", text: "First" }, + { type: "summary_text", text: "Second" }, + ], + }, + ]) + }), + ) + it.effect("skips non-persisted reasoning ids without encrypted state", () => Effect.gen(function* () { const prepared = yield* LLMClient.prepare( diff --git a/packages/llm/test/recorded-scenarios.ts b/packages/llm/test/recorded-scenarios.ts index eca97ee655..db28ec4493 100644 --- a/packages/llm/test/recorded-scenarios.ts +++ b/packages/llm/test/recorded-scenarios.ts @@ -158,7 +158,7 @@ const normalizeImageText = (value: string) => const encryptedReasoningOptions = { openai: { store: false, - includeEncryptedReasoning: true, + include: ["reasoning.encrypted_content"], reasoningEffort: "low", reasoningSummary: "auto", }, diff --git a/packages/llm/test/tool-runtime.test.ts b/packages/llm/test/tool-runtime.test.ts index 2733debe42..6c85e2d38c 100644 --- a/packages/llm/test/tool-runtime.test.ts +++ b/packages/llm/test/tool-runtime.test.ts @@ -4,6 +4,7 @@ import { GenerationOptions, LLM, LLMEvent, LLMRequest, LLMResponse, ToolChoice } import { Auth, LLMClient } from "../src/route" import * as AnthropicMessages from "../src/protocols/anthropic-messages" import * as OpenAIChat from "../src/protocols/openai-chat" +import * as OpenAIResponses from "../src/protocols/openai-responses" import { tool, ToolFailure, type ToolExecuteContext } from "../src/tool" import { ToolRuntime } from "../src/tool-runtime" import { it } from "./lib/effect" @@ -309,6 +310,80 @@ describe("LLMClient tools", () => { }), ) + it.effect("replays encrypted OpenAI reasoning items with tool outputs", () => + Effect.gen(function* () { + const bodies: unknown[] = [] + const layer = dynamicResponse((input) => + Effect.sync(() => { + bodies.push(decodeJson(input.text)) + return input.respond( + bodies.length === 1 + ? sseEvents( + { + type: "response.output_item.added", + item: { type: "reasoning", id: "rs_1", encrypted_content: null }, + }, + { type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 0 }, + { type: "response.reasoning_summary_part.done", item_id: "rs_1", summary_index: 0 }, + { + type: "response.output_item.done", + item: { type: "reasoning", id: "rs_1", encrypted_content: "encrypted-state" }, + }, + { + type: "response.output_item.added", + item: { + type: "function_call", + id: "item_1", + call_id: "call_1", + name: "get_weather", + arguments: "", + }, + }, + { type: "response.function_call_arguments.delta", item_id: "item_1", delta: '{"city":"Paris"}' }, + { + type: "response.output_item.done", + item: { + type: "function_call", + id: "item_1", + call_id: "call_1", + name: "get_weather", + arguments: '{"city":"Paris"}', + }, + }, + { type: "response.completed", response: {} }, + ) + : sseEvents( + { type: "response.output_text.delta", item_id: "msg_1", delta: "Done." }, + { type: "response.completed", response: {} }, + ), + { headers: { "content-type": "text/event-stream" } }, + ) + }), + ) + + yield* TestToolRuntime.runTools({ + request: LLM.request({ + model: OpenAIResponses.route + .with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") }) + .model({ id: "gpt-5.5" }), + prompt: "Use the tool.", + providerOptions: { openai: { store: false, include: ["reasoning.encrypted_content"] } }, + }), + tools: { get_weather }, + }).pipe(Stream.runCollect, Effect.provide(layer)) + + expect(bodies[1]).toMatchObject({ + include: ["reasoning.encrypted_content"], + input: [ + { role: "user" }, + { type: "reasoning", id: "rs_1", summary: [], encrypted_content: "encrypted-state" }, + { type: "function_call", call_id: "call_1", name: "get_weather" }, + { type: "function_call_output", call_id: "call_1" }, + ], + }) + }), + ) + it.effect("emits tool-error for unknown tools so the model can self-correct", () => Effect.gen(function* () { const layer = scriptedResponses([ diff --git a/packages/opencode/migration/20260511173437_session-metadata/migration.sql b/packages/opencode/migration/20260511173437_session-metadata/migration.sql new file mode 100644 index 0000000000..0ce73631f0 --- /dev/null +++ b/packages/opencode/migration/20260511173437_session-metadata/migration.sql @@ -0,0 +1 @@ +ALTER TABLE `session` ADD `metadata` text; \ No newline at end of file diff --git a/packages/opencode/migration/20260511173437_session-metadata/snapshot.json b/packages/opencode/migration/20260511173437_session-metadata/snapshot.json new file mode 100644 index 0000000000..07d9ec016a --- /dev/null +++ b/packages/opencode/migration/20260511173437_session-metadata/snapshot.json @@ -0,0 +1,1500 @@ +{ + "version": "7", + "dialect": "sqlite", + "id": "85d2086c-3c95-4706-90b0-f7480b73db5c", + "prevIds": ["fdfcccee-fb3a-481f-b801-b9835fa30d5d"], + "ddl": [ + { + "name": "account_state", + "entityType": "tables" + }, + { + "name": "account", + "entityType": "tables" + }, + { + "name": "control_account", + "entityType": "tables" + }, + { + "name": "workspace", + "entityType": "tables" + }, + { + "name": "data_migration", + "entityType": "tables" + }, + { + "name": "project", + "entityType": "tables" + }, + { + "name": "message", + "entityType": "tables" + }, + { + "name": "part", + "entityType": "tables" + }, + { + "name": "permission", + "entityType": "tables" + }, + { + "name": "session_message", + "entityType": "tables" + }, + { + "name": "session", + "entityType": "tables" + }, + { + "name": "todo", + "entityType": "tables" + }, + { + "name": "session_share", + "entityType": "tables" + }, + { + "name": "event_sequence", + "entityType": "tables" + }, + { + "name": "event", + "entityType": "tables" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active_account_id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active_org_id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "access_token", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "refresh_token", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "token_expiry", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "access_token", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "refresh_token", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "token_expiry", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "''", + "generated": null, + "name": "name", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "branch", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "directory", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "extra", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_used", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "data_migration" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_completed", + "entityType": "columns", + "table": "data_migration" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "worktree", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "vcs", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_url", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_url_override", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_color", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_initialized", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "sandboxes", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "commands", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "message_id", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "part" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "part" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "permission" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "permission" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "permission" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "permission" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "parent_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "slug", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "directory", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "path", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "title", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "version", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "share_url", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_additions", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_deletions", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_files", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_diffs", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "metadata", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "revert", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "permission", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "agent", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "model", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_compacting", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_archived", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "content", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "status", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "priority", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "position", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "secret", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "aggregate_id", + "entityType": "columns", + "table": "event_sequence" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seq", + "entityType": "columns", + "table": "event_sequence" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "owner_id", + "entityType": "columns", + "table": "event_sequence" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "aggregate_id", + "entityType": "columns", + "table": "event" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seq", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "event" + }, + { + "columns": ["active_account_id"], + "tableTo": "account", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "nameExplicit": false, + "name": "fk_account_state_active_account_id_account_id_fk", + "entityType": "fks", + "table": "account_state" + }, + { + "columns": ["project_id"], + "tableTo": "project", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_workspace_project_id_project_id_fk", + "entityType": "fks", + "table": "workspace" + }, + { + "columns": ["session_id"], + "tableTo": "session", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_message_session_id_session_id_fk", + "entityType": "fks", + "table": "message" + }, + { + "columns": ["message_id"], + "tableTo": "message", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_part_message_id_message_id_fk", + "entityType": "fks", + "table": "part" + }, + { + "columns": ["project_id"], + "tableTo": "project", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_permission_project_id_project_id_fk", + "entityType": "fks", + "table": "permission" + }, + { + "columns": ["session_id"], + "tableTo": "session", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_message_session_id_session_id_fk", + "entityType": "fks", + "table": "session_message" + }, + { + "columns": ["project_id"], + "tableTo": "project", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_project_id_project_id_fk", + "entityType": "fks", + "table": "session" + }, + { + "columns": ["session_id"], + "tableTo": "session", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_todo_session_id_session_id_fk", + "entityType": "fks", + "table": "todo" + }, + { + "columns": ["session_id"], + "tableTo": "session", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_share_session_id_session_id_fk", + "entityType": "fks", + "table": "session_share" + }, + { + "columns": ["aggregate_id"], + "tableTo": "event_sequence", + "columnsTo": ["aggregate_id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_event_aggregate_id_event_sequence_aggregate_id_fk", + "entityType": "fks", + "table": "event" + }, + { + "columns": ["email", "url"], + "nameExplicit": false, + "name": "control_account_pk", + "entityType": "pks", + "table": "control_account" + }, + { + "columns": ["session_id", "position"], + "nameExplicit": false, + "name": "todo_pk", + "entityType": "pks", + "table": "todo" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "account_state_pk", + "table": "account_state", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "account_pk", + "table": "account", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "workspace_pk", + "table": "workspace", + "entityType": "pks" + }, + { + "columns": ["name"], + "nameExplicit": false, + "name": "data_migration_pk", + "table": "data_migration", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "project_pk", + "table": "project", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "message_pk", + "table": "message", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "part_pk", + "table": "part", + "entityType": "pks" + }, + { + "columns": ["project_id"], + "nameExplicit": false, + "name": "permission_pk", + "table": "permission", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "session_message_pk", + "table": "session_message", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "session_pk", + "table": "session", + "entityType": "pks" + }, + { + "columns": ["session_id"], + "nameExplicit": false, + "name": "session_share_pk", + "table": "session_share", + "entityType": "pks" + }, + { + "columns": ["aggregate_id"], + "nameExplicit": false, + "name": "event_sequence_pk", + "table": "event_sequence", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "event_pk", + "table": "event", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "time_created", + "isExpression": false + }, + { + "value": "id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "message_session_time_created_id_idx", + "entityType": "indexes", + "table": "message" + }, + { + "columns": [ + { + "value": "message_id", + "isExpression": false + }, + { + "value": "id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "part_message_id_id_idx", + "entityType": "indexes", + "table": "part" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "part_session_idx", + "entityType": "indexes", + "table": "part" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_message_session_idx", + "entityType": "indexes", + "table": "session_message" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "type", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_message_session_type_idx", + "entityType": "indexes", + "table": "session_message" + }, + { + "columns": [ + { + "value": "time_created", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_message_time_created_idx", + "entityType": "indexes", + "table": "session_message" + }, + { + "columns": [ + { + "value": "project_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_project_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_workspace_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "parent_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_parent_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "todo_session_idx", + "entityType": "indexes", + "table": "todo" + } + ], + "renames": [] +} diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 9d8101371a..7c5f3b5244 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -38,7 +38,6 @@ } }, "devDependencies": { - "@babel/core": "7.28.4", "@effect/language-service": "0.84.2", "@octokit/webhooks-types": "7.6.1", "@opencode-ai/script": "workspace:*", @@ -51,9 +50,7 @@ "@parcel/watcher-linux-x64-musl": "2.5.1", "@parcel/watcher-win32-arm64": "2.5.1", "@parcel/watcher-win32-x64": "2.5.1", - "@standard-schema/spec": "1.0.0", "@tsconfig/bun": "catalog:", - "@types/babel__core": "7.20.5", "@types/bun": "catalog:", "@types/cross-spawn": "catalog:", "@types/mime-types": "3.0.1", @@ -70,24 +67,26 @@ "typescript": "catalog:", "vscode-languageserver-types": "3.17.5", "why-is-node-running": "3.2.2", - "zod-to-json-schema": "3.24.5", "@types/npmcli__arborist": "6.3.3", - "@opencode-ai/http-recorder": "workspace:*" + "@opencode-ai/http-recorder": "workspace:*", + "@babel/core": "7.28.4", + "@standard-schema/spec": "1.0.0", + "@types/babel__core": "7.20.5" }, "dependencies": { "@actions/core": "1.11.1", "@actions/github": "6.0.1", "@agentclientprotocol/sdk": "0.21.0", "@ai-sdk/alibaba": "1.0.17", - "@ai-sdk/amazon-bedrock": "4.0.96", + "@ai-sdk/amazon-bedrock": "4.0.107", "@ai-sdk/anthropic": "3.0.71", "@ai-sdk/azure": "3.0.49", "@ai-sdk/cerebras": "2.0.54", "@ai-sdk/cohere": "3.0.27", "@ai-sdk/deepinfra": "2.0.41", "@ai-sdk/gateway": "3.0.104", - "@ai-sdk/google": "3.0.63", - "@ai-sdk/google-vertex": "4.0.112", + "@ai-sdk/google": "3.0.73", + "@ai-sdk/google-vertex": "4.0.128", "@ai-sdk/groq": "3.0.31", "@ai-sdk/mistral": "3.0.27", "@ai-sdk/openai": "3.0.53", @@ -104,10 +103,6 @@ "@effect/platform-node": "catalog:", "@gitlab/gitlab-ai-provider": "3.6.0", "@gitlab/opencode-gitlab-auth": "1.3.3", - "@hono/node-server": "1.19.13", - "@hono/node-ws": "1.3.0", - "@hono/standard-validator": "0.1.5", - "@hono/zod-validator": "catalog:", "@kilocode/kilo-gateway": "workspace:*", "@kilocode/kilo-indexing": "workspace:*", "@kilocode/kilo-telemetry": "workspace:*", @@ -118,36 +113,30 @@ "@modelcontextprotocol/sdk": "1.29.0", "@morphllm/morphsdk": "0.2.166", "@npmcli/arborist": "9.4.0", - "@npmcli/config": "10.8.1", "@octokit/graphql": "9.0.2", "@octokit/rest": "catalog:", - "@openauthjs/openauth": "catalog:", + "@opencode-ai/llm": "workspace:*", "@opencode-ai/script": "workspace:*", "@opencode-ai/ui": "workspace:*", "@openrouter/ai-sdk-provider": "2.9.0", "@opentelemetry/api": "1.9.0", - "@opentelemetry/context-async-hooks": "2.6.1", - "@opentelemetry/exporter-trace-otlp-http": "0.214.0", "@opentelemetry/sdk-trace-base": "2.6.1", "@opentelemetry/sdk-trace-node": "2.6.1", "@opentui/core": "catalog:", "@opentui/keymap": "catalog:", "@opentui/solid": "catalog:", "@parcel/watcher": "2.5.1", - "@pierre/diffs": "catalog:", "@secretlint/core": "10.2.2", "@secretlint/secretlint-rule-preset-recommend": "10.2.2", "@silvia-odwyer/photon-node": "0.3.4", "@solid-primitives/event-bus": "1.1.2", "@solid-primitives/scheduled": "1.5.2", - "@standard-schema/spec": "1.0.0", "@zip.js/zip.js": "2.7.62", "ai": "catalog:", "ai-gateway-provider": "3.1.2", "bonjour-service": "1.3.0", "bun-pty": "0.4.8", "chardet": "2.1.1", - "chokidar": "4.0.3", "cli-sound": "1.1.3", "clipboardy": "4.0.0", "cross-spawn": "catalog:", @@ -156,12 +145,9 @@ "drizzle-orm": "catalog:", "effect": "catalog:", "fuzzysort": "3.1.0", - "gitlab-ai-provider": "6.7.0", - "glob": "13.0.5", + "gitlab-ai-provider": "6.8.0", "google-auth-library": "10.5.0", "gray-matter": "4.0.3", - "hono": "catalog:", - "hono-openapi": "catalog:", "htmlparser2": "8.0.2", "iconv-lite": "0.7.2", "ignore": "7.0.5", @@ -169,16 +155,13 @@ "jsonc-parser": "3.3.1", "mammoth": "1.12.0", "mime-types": "3.0.2", - "minimatch": "10.2.5", "npm-package-arg": "13.0.2", "open": "10.1.2", "opencode-gitlab-auth": "2.0.1", "opencode-poe-auth": "0.0.1", "opentui-spinner": "catalog:", - "partial-json": "0.1.7", "remeda": "catalog:", "ripgrep": "0.3.1", - "rotating-file-stream": "3.2.9", "semver": "^7.6.3", "simple-git": "3.36.0", "solid-js": "catalog:", @@ -188,16 +171,25 @@ "tree-sitter-wasms": "^0.1.12", "turndown": "7.2.0", "ulid": "catalog:", - "venice-ai-sdk-provider": "2.0.1", + "venice-ai-sdk-provider": "2.0.2", "vscode-jsonrpc": "8.2.1", "web-tree-sitter": "0.25.10", "which": "6.0.1", - "xdg-basedir": "5.1.0", "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz", "yargs": "18.0.0", "zod": "catalog:", - "zod-to-json-schema": "3.24.5", - "@opencode-ai/llm": "workspace:*" + "@openauthjs/openauth": "catalog:", + "@opentelemetry/context-async-hooks": "2.6.1", + "@opentelemetry/exporter-trace-otlp-http": "0.214.0", + "@pierre/diffs": "catalog:", + "@standard-schema/spec": "1.0.0", + "@types/ws": "8.18.1", + "chokidar": "4.0.3", + "glob": "13.0.5", + "minimatch": "10.0.3", + "partial-json": "0.1.7", + "ws": "8.21.0", + "xdg-basedir": "5.1.0" }, "overrides": { "drizzle-orm": "catalog:" diff --git a/packages/opencode/src/account/account.ts b/packages/opencode/src/account/account.ts index dcff82664b..2d855e0e95 100644 --- a/packages/opencode/src/account/account.ts +++ b/packages/opencode/src/account/account.ts @@ -1,5 +1,5 @@ import { Cache, Clock, Duration, Effect, Layer, Option, Schema, SchemaGetter, Context } from "effect" -import { serviceUse } from "@/effect/service-use" +import { serviceUse } from "@opencode-ai/core/effect/service-use" import { FetchHttpClient, HttpClient, diff --git a/packages/opencode/src/account/repo.ts b/packages/opencode/src/account/repo.ts index a5291e8283..052f1cbd65 100644 --- a/packages/opencode/src/account/repo.ts +++ b/packages/opencode/src/account/repo.ts @@ -1,5 +1,5 @@ import { eq } from "drizzle-orm" -import { serviceUse } from "@/effect/service-use" +import { serviceUse } from "@opencode-ai/core/effect/service-use" import { Effect, Layer, Option, Schema, Context } from "effect" import { Database } from "@/storage/db" diff --git a/packages/opencode/src/acp/README.md b/packages/opencode/src/acp/README.md deleted file mode 100644 index ce9a22e03b..0000000000 --- a/packages/opencode/src/acp/README.md +++ /dev/null @@ -1,174 +0,0 @@ -# ACP (Agent Client Protocol) Implementation - -This directory contains a clean, protocol-compliant implementation of the [Agent Client Protocol](https://agentclientprotocol.com/) for opencode. - -## Architecture - -The implementation follows a clean separation of concerns: - -### Core Components - -- **`agent.ts`** - Implements the `Agent` interface from `@agentclientprotocol/sdk` - - Handles initialization and capability negotiation - - Manages session lifecycle (`session/new`, `session/load`) - - Processes prompts and returns responses - - Properly implements ACP protocol v1 - -- **`client.ts`** - Implements the `Client` interface for client-side capabilities - - File operations (`readTextFile`, `writeTextFile`) - - Permission requests (auto-approves for now) - - Terminal support (stub implementation) - -- **`session.ts`** - Session state management - - Creates and tracks ACP sessions - - Maps ACP sessions to internal opencode sessions - - Maintains working directory context - - Handles MCP server configurations - -- **`server.ts`** - ACP server startup and lifecycle - - Sets up JSON-RPC over stdio using the official library - - Manages graceful shutdown on SIGTERM/SIGINT - - Provides Instance context for the agent - -- **`types.ts`** - Type definitions for internal use - -## Usage - -### Command Line - -```bash -# Start the ACP server in the current directory -opencode acp - -# Start in a specific directory -opencode acp --cwd /path/to/project -``` - -### Question Tool Opt-In - -ACP excludes `QuestionTool` by default. - -```bash -KILO_ENABLE_QUESTION_TOOL=1 opencode acp -``` - -Enable this only for ACP clients that support interactive question prompts. - -### Programmatic - -```typescript -import { ACPServer } from "./acp/server" - -await ACPServer.start() -``` - -### Integration with Zed - -Add to your Zed configuration (`~/.config/zed/settings.json`): - -```json -{ - "agent_servers": { - "OpenCode": { - "command": "opencode", - "args": ["acp"] - } - } -} -``` - -## Protocol Compliance - -This implementation follows the ACP specification v1: - -✅ **Initialization** - -- Proper `initialize` request/response with protocol version negotiation -- Capability advertisement (`agentCapabilities`) -- Authentication support (stub) - -✅ **Session Management** - -- `session/new` - Create new conversation sessions -- `session/load` - Resume existing sessions (basic support) -- Working directory context (`cwd`) -- MCP server configuration support - -✅ **Prompting** - -- `session/prompt` - Process user messages -- Content block handling (text, resources) -- Response with stop reasons - -✅ **Client Capabilities** - -- File read/write operations -- Permission requests -- Terminal support (stub for future) - -## Current Limitations - -### Not Yet Implemented - -1. **Streaming Responses** - Currently returns complete responses instead of streaming via `session/update` notifications -2. **Tool Call Reporting** - Doesn't report tool execution progress -3. **Session Modes** - No mode switching support yet -4. **Authentication** - No actual auth implementation -5. **Terminal Support** - Placeholder only -6. **Session Persistence** - `session/load` doesn't restore actual conversation history - -### Future Enhancements - -- **Real-time Streaming**: Implement `session/update` notifications for progressive responses -- **Tool Call Visibility**: Report tool executions as they happen -- **Session Persistence**: Save and restore full conversation history -- **Mode Support**: Implement different operational modes (ask, code, etc.) -- **Enhanced Permissions**: More sophisticated permission handling -- **Terminal Integration**: Full terminal support via opencode's bash tool - -## Testing - -```bash -# Run ACP tests -bun test test/acp.test.ts - -# Test manually with stdio -echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":1}}' | opencode acp -``` - -## Design Decisions - -### Why the Official Library? - -We use `@agentclientprotocol/sdk` instead of implementing JSON-RPC ourselves because: - -- Ensures protocol compliance -- Handles edge cases and future protocol versions -- Reduces maintenance burden -- Works with other ACP clients automatically - -### Clean Architecture - -Each component has a single responsibility: - -- **Agent** = Protocol interface -- **Client** = Client-side operations -- **Session** = State management -- **Server** = Lifecycle and I/O - -This makes the codebase maintainable and testable. - -### Mapping to OpenCode - -ACP sessions map cleanly to opencode's internal session model: - -- ACP `session/new` → creates internal Session -- ACP `session/prompt` → uses SessionPrompt.prompt() -- Working directory context preserved per-session -- Tool execution uses existing ToolRegistry - -## References - -- [ACP Specification](https://agentclientprotocol.com/) -- [TypeScript Library](https://github.com/agentclientprotocol/typescript-sdk) -- [Protocol Examples](https://github.com/agentclientprotocol/typescript-sdk/tree/main/src/examples) diff --git a/packages/opencode/src/acp/agent.ts b/packages/opencode/src/acp/agent.ts index c50a89e372..9c9f8d70b4 100644 --- a/packages/opencode/src/acp/agent.ts +++ b/packages/opencode/src/acp/agent.ts @@ -3,1964 +3,93 @@ import { type Agent as ACPAgent, type AgentSideConnection, type AuthenticateRequest, - type AuthMethod, type CancelNotification, type CloseSessionRequest, - type CloseSessionResponse, type ForkSessionRequest, - type ForkSessionResponse, type InitializeRequest, - type InitializeResponse, type ListSessionsRequest, - type ListSessionsResponse, type LoadSessionRequest, type NewSessionRequest, - type PermissionOption, - type PlanEntry, type PromptRequest, type ResumeSessionRequest, - type ResumeSessionResponse, - type Role, - type SessionInfo, - type SetSessionModelRequest, - type SessionConfigOption, type SetSessionConfigOptionRequest, - type SetSessionConfigOptionResponse, + type SetSessionModelRequest, type SetSessionModeRequest, - type SetSessionModeResponse, - type ToolCallContent, - type ToolKind, - type Usage, } from "@agentclientprotocol/sdk" - -import * as Log from "@opencode-ai/core/util/log" -import { pathToFileURL } from "url" -import { Filesystem } from "@/util/filesystem" -import { Hash } from "@opencode-ai/core/util/hash" -import { ACPSessionManager } from "./session" -import type { ACPConfig } from "./types" -import { ACPRuntime } from "./runtime" -import { Provider } from "@/provider/provider" -import { ModelID, ProviderID } from "../provider/schema" -import { MessageV2 } from "@/session/message-v2" -import { ConfigMCP } from "@/config/mcp" -import { Todo } from "@/session/todo" -import { Result, Schema } from "effect" -import { LoadAPIKeyError } from "ai" -import type { AssistantMessage, Event, KiloClient, SessionMessageResponse, ToolPart } from "@kilocode/sdk/v2" -import { applyPatch } from "diff" -import { InstallationVersion } from "@opencode-ai/core/installation/version" -import { ShellID } from "@/tool/shell/id" - -type ModeOption = { id: string; name: string; description?: string } -type ModelOption = { modelId: string; name: string } -const decodeTodos = Schema.decodeUnknownResult(Schema.fromJsonString(Schema.Array(Todo.Info))) - -const DEFAULT_VARIANT_VALUE = "default" - -const log = Log.create({ service: "acp-agent" }) - -async function getContextLimit( - sdk: KiloClient, - providerID: ProviderID, - modelID: ModelID, - directory: string, -): Promise { - const providers = await sdk.config - .providers({ directory }) - .then((x) => x.data?.providers ?? []) - .catch((error) => { - log.error("failed to get providers for context limit", { error }) - return [] - }) - - const provider = providers.find((p) => p.id === providerID) - const model = provider?.models[modelID] - return model?.limit.context ?? null -} - -async function sendUsageUpdate( - connection: AgentSideConnection, - sdk: KiloClient, - sessionID: string, - directory: string, -): Promise { - const messages = await sdk.session - .messages({ sessionID, directory }, { throwOnError: true }) - .then((x) => x.data) - .catch((error) => { - log.error("failed to fetch messages for usage update", { error }) - return undefined - }) - - if (!messages) return - - const assistantMessages = messages.filter( - (m): m is { info: AssistantMessage; parts: SessionMessageResponse["parts"] } => m.info.role === "assistant", - ) - - const lastAssistant = assistantMessages[assistantMessages.length - 1] - if (!lastAssistant) return - - const msg = lastAssistant.info - if (!msg.providerID || !msg.modelID) return - const size = await getContextLimit(sdk, ProviderID.make(msg.providerID), ModelID.make(msg.modelID), directory) - - if (!size) { - // Cannot calculate usage without known context size - return - } - - const used = msg.tokens.input + (msg.tokens.cache?.read ?? 0) - const totalCost = assistantMessages.reduce((sum, m) => sum + m.info.cost, 0) - - await connection - .sessionUpdate({ - sessionId: sessionID, - update: { - sessionUpdate: "usage_update", - used, - size, - cost: { amount: totalCost, currency: "USD" }, - }, - }) - .catch((error) => { - log.error("failed to send usage update", { error }) - }) -} +import { Effect } from "effect" +import type { KiloClient } from "@kilocode/sdk/v2" +import * as ACPError from "./error" +import * as ACPService from "./service" export function init({ sdk: _sdk }: { sdk: KiloClient }) { return { - create: (connection: AgentSideConnection, fullConfig: ACPConfig) => { - return new Agent(connection, fullConfig) + create: (connection: AgentSideConnection) => { + return new Agent(ACPService.make({ sdk: _sdk, connection })) }, } } export class Agent implements ACPAgent { - private connection: AgentSideConnection - private config: ACPConfig - private sdk: KiloClient - private sessionManager: ACPSessionManager - private eventAbort = new AbortController() - private eventStarted = false - private shellSnapshots = new Map() - private toolStarts = new Set() - private permissionQueues = new Map>() - private permissionOptions: PermissionOption[] = [ - { optionId: "once", kind: "allow_once", name: "Allow once" }, - { optionId: "always", kind: "allow_always", name: "Always allow" }, - { optionId: "reject", kind: "reject_once", name: "Reject" }, - ] + constructor(private readonly service: ACPService.Interface) {} - constructor(connection: AgentSideConnection, config: ACPConfig) { - this.connection = connection - this.config = config - this.sdk = config.sdk - this.sessionManager = new ACPSessionManager(this.sdk) - this.startEventSubscription() + initialize(params: InitializeRequest) { + return run(this.service.initialize(params)) } - private startEventSubscription() { - if (this.eventStarted) return - this.eventStarted = true - this.runEventSubscription().catch((error) => { - if (this.eventAbort.signal.aborted) return - log.error("event subscription failed", { error }) - }) + authenticate(params: AuthenticateRequest) { + return run(this.service.authenticate(params)) } - private async runEventSubscription() { - while (true) { - if (this.eventAbort.signal.aborted) return - const events = await this.sdk.global.event({ - signal: this.eventAbort.signal, - }) - for await (const event of events.stream) { - if (this.eventAbort.signal.aborted) return - const payload = event?.payload - if (!payload) continue - await this.handleEvent(payload as Event).catch((error) => { - log.error("failed to handle event", { error, type: payload.type }) - }) - } - } + newSession(params: NewSessionRequest) { + return run(this.service.newSession(params)) } - private async handleEvent(event: Event) { - switch (event.type) { - case "permission.asked": { - const permission = event.properties - const session = this.sessionManager.tryGet(permission.sessionID) - if (!session) return - - const prev = this.permissionQueues.get(permission.sessionID) ?? Promise.resolve() - const next = prev - .then(async () => { - const directory = session.cwd - - const res = await this.connection - .requestPermission({ - sessionId: permission.sessionID, - toolCall: { - toolCallId: permission.tool?.callID ?? permission.id, - status: "pending", - title: permission.permission, - rawInput: permission.metadata, - kind: toToolKind(permission.permission), - locations: toLocations(permission.permission, permission.metadata), - }, - options: this.permissionOptions, - }) - .catch(async (error) => { - log.error("failed to request permission from ACP", { - error, - permissionID: permission.id, - sessionID: permission.sessionID, - }) - await this.sdk.permission.reply({ - requestID: permission.id, - reply: "reject", - directory, - }) - return undefined - }) - - if (!res) return - if (res.outcome.outcome !== "selected") { - await this.sdk.permission.reply({ - requestID: permission.id, - reply: "reject", - directory, - }) - return - } - - if (res.outcome.optionId !== "reject" && permission.permission == "edit") { - const metadata = permission.metadata || {} - const filepath = typeof metadata["filepath"] === "string" ? metadata["filepath"] : "" - const diff = typeof metadata["diff"] === "string" ? metadata["diff"] : "" - const content = (await Filesystem.exists(filepath)) ? await Filesystem.readText(filepath) : "" - const newContent = getNewContent(content, diff) - - if (newContent) { - void this.connection.writeTextFile({ - sessionId: session.id, - path: filepath, - content: newContent, - }) - } - } - - await this.sdk.permission.reply({ - requestID: permission.id, - reply: res.outcome.optionId as "once" | "always" | "reject", - directory, - }) - }) - .catch((error) => { - log.error("failed to handle permission", { error, permissionID: permission.id }) - }) - .finally(() => { - if (this.permissionQueues.get(permission.sessionID) === next) { - this.permissionQueues.delete(permission.sessionID) - } - }) - this.permissionQueues.set(permission.sessionID, next) - return - } - - case "message.part.updated": { - log.info("message part updated", { event: event.properties }) - const props = event.properties - const part = props.part - const session = this.sessionManager.tryGet(part.sessionID) - if (!session) return - const sessionId = session.id - - if (part.type === "tool") { - await this.toolStart(sessionId, part) - - switch (part.state.status) { - case "pending": - this.shellSnapshots.delete(part.callID) - return - - case "running": - const output = this.shellOutput(part) - const content: ToolCallContent[] = [] - if (output) { - const hash = Hash.fast(output) - if (part.tool === ShellID.ToolID) { - if (this.shellSnapshots.get(part.callID) === hash) { - await this.connection - .sessionUpdate({ - sessionId, - update: { - sessionUpdate: "tool_call_update", - toolCallId: part.callID, - status: "in_progress", - kind: toToolKind(part.tool), - title: part.tool, - locations: toLocations(part.tool, part.state.input), - rawInput: part.state.input, - }, - }) - .catch((error) => { - log.error("failed to send tool in_progress to ACP", { error }) - }) - return - } - this.shellSnapshots.set(part.callID, hash) - } - content.push({ - type: "content", - content: { - type: "text", - text: output, - }, - }) - } - await this.connection - .sessionUpdate({ - sessionId, - update: { - sessionUpdate: "tool_call_update", - toolCallId: part.callID, - status: "in_progress", - kind: toToolKind(part.tool), - title: part.tool, - locations: toLocations(part.tool, part.state.input), - rawInput: part.state.input, - ...(content.length > 0 && { content }), - }, - }) - .catch((error) => { - log.error("failed to send tool in_progress to ACP", { error }) - }) - return - - case "completed": { - this.toolStarts.delete(part.callID) - this.shellSnapshots.delete(part.callID) - const kind = toToolKind(part.tool) - const content = completedToolContent(part, kind) - - if (part.tool === "todowrite") { - const parsedTodos = decodeTodos(part.state.output) - if (Result.isSuccess(parsedTodos)) { - await this.connection - .sessionUpdate({ - sessionId, - update: { - sessionUpdate: "plan", - entries: parsedTodos.success.map((todo) => { - const status: PlanEntry["status"] = - todo.status === "cancelled" ? "completed" : (todo.status as PlanEntry["status"]) - return { - priority: "medium", - status, - content: todo.content, - } - }), - }, - }) - .catch((error) => { - log.error("failed to send session update for todo", { error }) - }) - } else { - log.error("failed to parse todo output", { error: parsedTodos.failure }) - } - } - - await this.connection - .sessionUpdate({ - sessionId, - update: { - sessionUpdate: "tool_call_update", - toolCallId: part.callID, - status: "completed", - kind, - content, - title: part.state.title, - rawInput: part.state.input, - rawOutput: completedToolRawOutput(part), - }, - }) - .catch((error) => { - log.error("failed to send tool completed to ACP", { error }) - }) - return - } - case "error": - this.toolStarts.delete(part.callID) - this.shellSnapshots.delete(part.callID) - await this.connection - .sessionUpdate({ - sessionId, - update: { - sessionUpdate: "tool_call_update", - toolCallId: part.callID, - status: "failed", - kind: toToolKind(part.tool), - title: part.tool, - rawInput: part.state.input, - content: [ - { - type: "content", - content: { - type: "text", - text: part.state.error, - }, - }, - ], - rawOutput: { - error: part.state.error, - metadata: part.state.metadata, - }, - }, - }) - .catch((error) => { - log.error("failed to send tool error to ACP", { error }) - }) - return - } - } - - // ACP clients already know the prompt they just submitted, so replaying - // live user parts duplicates the message. We still replay user history in - // loadSession() and forkSession() via processMessage(). - if (part.type !== "text" && part.type !== "file") return - - return - } - - case "message.part.delta": { - const props = event.properties - const session = this.sessionManager.tryGet(props.sessionID) - if (!session) return - const sessionId = session.id - - const message = await this.sdk.session - .message( - { - sessionID: props.sessionID, - messageID: props.messageID, - directory: session.cwd, - }, - { throwOnError: true }, - ) - .then((x) => x.data) - .catch((error) => { - log.error("unexpected error when fetching message", { error }) - return undefined - }) - - if (!message || message.info.role !== "assistant") return - - const part = message.parts.find((p) => p.id === props.partID) - if (!part) return - - if (part.type === "text" && props.field === "text" && part.ignored !== true) { - await this.connection - .sessionUpdate({ - sessionId, - update: { - sessionUpdate: "agent_message_chunk", - messageId: props.messageID, - content: { - type: "text", - text: props.delta, - }, - }, - }) - .catch((error) => { - log.error("failed to send text delta to ACP", { error }) - }) - return - } - - if (part.type === "reasoning" && props.field === "text") { - await this.connection - .sessionUpdate({ - sessionId, - update: { - sessionUpdate: "agent_thought_chunk", - messageId: props.messageID, - content: { - type: "text", - text: props.delta, - }, - }, - }) - .catch((error) => { - log.error("failed to send reasoning delta to ACP", { error }) - }) - } - return - } - } + loadSession(params: LoadSessionRequest) { + return run(this.service.loadSession(params)) } - async initialize(params: InitializeRequest): Promise { - log.info("initialize", { protocolVersion: params.protocolVersion }) - - const authMethod: AuthMethod = { - description: "Run `opencode auth login` in the terminal", - name: "Login with opencode", - id: "opencode-login", - } - - // If client supports terminal-auth capability, use that instead. - if (params.clientCapabilities?._meta?.["terminal-auth"] === true) { - authMethod._meta = { - "terminal-auth": { - command: "opencode", - args: ["auth", "login"], - label: "OpenCode Login", - }, - } - } - - return { - protocolVersion: 1, - agentCapabilities: { - loadSession: true, - mcpCapabilities: { - http: true, - sse: true, - }, - promptCapabilities: { - embeddedContext: true, - image: true, - }, - sessionCapabilities: { - close: {}, - fork: {}, - list: {}, - resume: {}, - }, - }, - authMethods: [authMethod], - agentInfo: { - name: "OpenCode", - version: InstallationVersion, - }, - } + listSessions(params: ListSessionsRequest) { + return run(this.service.listSessions(params)) } - async authenticate(_params: AuthenticateRequest) { - throw new Error("Authentication not implemented") + resumeSession(params: ResumeSessionRequest) { + return run(this.service.resumeSession(params)) } - async newSession(params: NewSessionRequest) { - const directory = params.cwd - try { - const model = await defaultModel(this.config, directory) - - // Store ACP session state - const state = await this.sessionManager.create(params.cwd, params.mcpServers, model) - const sessionId = state.id - - log.info("creating_session", { sessionId, mcpServers: params.mcpServers.length }) - - const load = await this.loadSessionMode({ - cwd: directory, - mcpServers: params.mcpServers, - sessionId, - }) - - return { - sessionId, - configOptions: load.configOptions, - models: load.models, - modes: load.modes, - _meta: load._meta, - } - } catch (e) { - const error = MessageV2.fromError(e, { - providerID: ProviderID.make(this.config.defaultModel?.providerID ?? "unknown"), - }) - if (LoadAPIKeyError.isInstance(error)) { - throw RequestError.authRequired() - } - throw e - } + closeSession(params: CloseSessionRequest) { + return run(this.service.closeSession(params)) } - async loadSession(params: LoadSessionRequest) { - const directory = params.cwd - const sessionId = params.sessionId - - try { - const model = await defaultModel(this.config, directory) - - // Store ACP session state - await this.sessionManager.load(sessionId, params.cwd, params.mcpServers, model) - - const messages = await this.loadSessionMessages(directory, sessionId) - this.restoreSessionStateFromMessages(sessionId, messages) - - log.info("load_session", { sessionId, mcpServers: params.mcpServers.length }) - - const result = await this.loadSessionMode({ - cwd: directory, - mcpServers: params.mcpServers, - sessionId, - }) - - for (const msg of messages ?? []) { - log.debug("replay message", msg) - await this.processMessage(msg) - } - - await sendUsageUpdate(this.connection, this.sdk, sessionId, directory) - - return result - } catch (e) { - const error = MessageV2.fromError(e, { - providerID: ProviderID.make(this.config.defaultModel?.providerID ?? "unknown"), - }) - if (LoadAPIKeyError.isInstance(error)) { - throw RequestError.authRequired() - } - throw e - } + unstable_forkSession(params: ForkSessionRequest) { + return run(this.service.forkSession(params)) } - async listSessions(params: ListSessionsRequest): Promise { - try { - const cursor = params.cursor ? Number(params.cursor) : undefined - const limit = 100 - - const sessions = await this.sdk.session - .list( - { - directory: params.cwd ?? undefined, - roots: true, - }, - { throwOnError: true }, - ) - .then((x) => x.data ?? []) - - const sorted = sessions.toSorted((a, b) => b.time.updated - a.time.updated) - const filtered = cursor ? sorted.filter((s) => s.time.updated < cursor) : sorted - const page = filtered.slice(0, limit) - - const entries: SessionInfo[] = page.map((session) => ({ - sessionId: session.id, - cwd: session.directory, - title: session.title, - updatedAt: new Date(session.time.updated).toISOString(), - })) - - const last = page[page.length - 1] - const next = filtered.length > limit && last ? String(last.time.updated) : undefined - - const response: ListSessionsResponse = { - sessions: entries, - } - if (next) response.nextCursor = next - return response - } catch (e) { - const error = MessageV2.fromError(e, { - providerID: ProviderID.make(this.config.defaultModel?.providerID ?? "unknown"), - }) - if (LoadAPIKeyError.isInstance(error)) { - throw RequestError.authRequired() - } - throw e - } + setSessionConfigOption(params: SetSessionConfigOptionRequest) { + return run(this.service.setSessionConfigOption(params)) } - async unstable_forkSession(params: ForkSessionRequest): Promise { - const directory = params.cwd - const mcpServers = params.mcpServers ?? [] - - try { - const model = await defaultModel(this.config, directory) - - const forked = await this.sdk.session - .fork( - { - sessionID: params.sessionId, - directory, - }, - { throwOnError: true }, - ) - .then((x) => x.data) - - if (!forked) { - throw new Error("Fork session returned no data") - } - - const sessionId = forked.id - await this.sessionManager.load(sessionId, directory, mcpServers, model) - - const messages = await this.loadSessionMessages(directory, sessionId) - this.restoreSessionStateFromMessages(sessionId, messages) - - log.info("fork_session", { sessionId, mcpServers: mcpServers.length }) - - const mode = await this.loadSessionMode({ - cwd: directory, - mcpServers, - sessionId, - }) - - for (const msg of messages ?? []) { - log.debug("replay message", msg) - await this.processMessage(msg) - } - - await sendUsageUpdate(this.connection, this.sdk, sessionId, directory) - - return mode - } catch (e) { - const error = MessageV2.fromError(e, { - providerID: ProviderID.make(this.config.defaultModel?.providerID ?? "unknown"), - }) - if (LoadAPIKeyError.isInstance(error)) { - throw RequestError.authRequired() - } - throw e - } + setSessionMode(params: SetSessionModeRequest) { + return run(this.service.setSessionMode(params)) } - async resumeSession(params: ResumeSessionRequest): Promise { - const directory = params.cwd - const sessionId = params.sessionId - const mcpServers = params.mcpServers ?? [] - - try { - const model = await defaultModel(this.config, directory) - await this.sessionManager.load(sessionId, directory, mcpServers, model) - - const messages = await this.loadSessionMessages(directory, sessionId, 20) - this.restoreSessionStateFromMessages(sessionId, messages) - - log.info("resume_session", { sessionId, mcpServers: mcpServers.length }) - - const result = await this.loadSessionMode({ - cwd: directory, - mcpServers, - sessionId, - }) - - await sendUsageUpdate(this.connection, this.sdk, sessionId, directory) - - return result - } catch (e) { - const error = MessageV2.fromError(e, { - providerID: ProviderID.make(this.config.defaultModel?.providerID ?? "unknown"), - }) - if (LoadAPIKeyError.isInstance(error)) { - throw RequestError.authRequired() - } - throw e - } + unstable_setSessionModel(params: SetSessionModelRequest) { + return run(this.service.setSessionModel(params)) } - async closeSession(params: CloseSessionRequest): Promise { - const session = this.sessionManager.remove(params.sessionId) - if (!session) return {} - - await this.sdk.session - .abort( - { - sessionID: params.sessionId, - directory: session.cwd, - }, - { throwOnError: true }, - ) - .catch((error) => { - log.error("failed to abort session while closing ACP session", { error, sessionID: params.sessionId }) - }) - - this.permissionQueues.delete(params.sessionId) - log.info("close_session", { sessionId: params.sessionId }) - return {} + prompt(params: PromptRequest) { + return run(this.service.prompt(params)) } - private async processMessage(message: SessionMessageResponse) { - log.debug("process message", message) - if (message.info.role !== "assistant" && message.info.role !== "user") return - const sessionId = message.info.sessionID - - for (const part of message.parts) { - if (part.type === "tool") { - await this.toolStart(sessionId, part) - switch (part.state.status) { - case "pending": - this.shellSnapshots.delete(part.callID) - break - case "running": - const output = this.shellOutput(part) - const runningContent: ToolCallContent[] = [] - if (output) { - runningContent.push({ - type: "content", - content: { - type: "text", - text: output, - }, - }) - } - await this.connection - .sessionUpdate({ - sessionId, - update: { - sessionUpdate: "tool_call_update", - toolCallId: part.callID, - status: "in_progress", - kind: toToolKind(part.tool), - title: part.tool, - locations: toLocations(part.tool, part.state.input), - rawInput: part.state.input, - ...(runningContent.length > 0 && { content: runningContent }), - }, - }) - .catch((err) => { - log.error("failed to send tool in_progress to ACP", { error: err }) - }) - break - case "completed": - this.toolStarts.delete(part.callID) - this.shellSnapshots.delete(part.callID) - const kind = toToolKind(part.tool) - const content = completedToolContent(part, kind) - - if (part.tool === "todowrite") { - const parsedTodos = decodeTodos(part.state.output) - if (Result.isSuccess(parsedTodos)) { - await this.connection - .sessionUpdate({ - sessionId, - update: { - sessionUpdate: "plan", - entries: parsedTodos.success.map((todo) => { - const status: PlanEntry["status"] = - todo.status === "cancelled" ? "completed" : (todo.status as PlanEntry["status"]) - return { - priority: "medium", - status, - content: todo.content, - } - }), - }, - }) - .catch((err) => { - log.error("failed to send session update for todo", { error: err }) - }) - } else { - log.error("failed to parse todo output", { error: parsedTodos.failure }) - } - } - - await this.connection - .sessionUpdate({ - sessionId, - update: { - sessionUpdate: "tool_call_update", - toolCallId: part.callID, - status: "completed", - kind, - content, - title: part.state.title, - rawInput: part.state.input, - rawOutput: completedToolRawOutput(part), - }, - }) - .catch((err) => { - log.error("failed to send tool completed to ACP", { error: err }) - }) - break - case "error": - this.toolStarts.delete(part.callID) - this.shellSnapshots.delete(part.callID) - await this.connection - .sessionUpdate({ - sessionId, - update: { - sessionUpdate: "tool_call_update", - toolCallId: part.callID, - status: "failed", - kind: toToolKind(part.tool), - title: part.tool, - rawInput: part.state.input, - content: [ - { - type: "content", - content: { - type: "text", - text: part.state.error, - }, - }, - ], - rawOutput: { - error: part.state.error, - metadata: part.state.metadata, - }, - }, - }) - .catch((err) => { - log.error("failed to send tool error to ACP", { error: err }) - }) - break - } - } else if (part.type === "text") { - if (part.text) { - const audience: Role[] | undefined = part.synthetic ? ["assistant"] : part.ignored ? ["user"] : undefined - await this.connection - .sessionUpdate({ - sessionId, - update: { - sessionUpdate: message.info.role === "user" ? "user_message_chunk" : "agent_message_chunk", - messageId: message.info.id, - content: { - type: "text", - text: part.text, - ...(audience && { annotations: { audience } }), - }, - }, - }) - .catch((err) => { - log.error("failed to send text to ACP", { error: err }) - }) - } - } else if (part.type === "file") { - // Replay file attachments as appropriate ACP content blocks. - // OpenCode stores files internally as { type: "file", url, filename, mime }. - // We convert these back to ACP blocks based on the URL scheme and MIME type: - // - file:// URLs → resource_link - // - data: URLs with image/* → image block - // - data: URLs with text/* or application/json → resource with text - // - data: URLs with other types → resource with blob - const url = part.url - const filename = part.filename ?? "file" - const mime = part.mime || "application/octet-stream" - const messageChunk = message.info.role === "user" ? "user_message_chunk" : "agent_message_chunk" - - if (url.startsWith("file://")) { - // Local file reference - send as resource_link - await this.connection - .sessionUpdate({ - sessionId, - update: { - sessionUpdate: messageChunk, - messageId: message.info.id, - content: { type: "resource_link", uri: url, name: filename, mimeType: mime }, - }, - }) - .catch((err) => { - log.error("failed to send resource_link to ACP", { error: err }) - }) - } else if (url.startsWith("data:")) { - // Embedded content - parse data URL and send as appropriate block type - const base64Match = url.match(/^data:([^;]+);base64,(.*)$/) - const dataMime = base64Match?.[1] - const base64Data = base64Match?.[2] ?? "" - - const effectiveMime = dataMime || mime - - if (effectiveMime.startsWith("image/")) { - // Image - send as image block - await this.connection - .sessionUpdate({ - sessionId, - update: { - sessionUpdate: messageChunk, - messageId: message.info.id, - content: { - type: "image", - mimeType: effectiveMime, - data: base64Data, - uri: pathToFileURL(filename).href, - }, - }, - }) - .catch((err) => { - log.error("failed to send image to ACP", { error: err }) - }) - } else { - // Non-image: text types get decoded, binary types stay as blob - const isText = effectiveMime.startsWith("text/") || effectiveMime === "application/json" - const fileUri = pathToFileURL(filename).href - const resource = isText - ? { - uri: fileUri, - mimeType: effectiveMime, - text: Buffer.from(base64Data, "base64").toString("utf-8"), - } - : { uri: fileUri, mimeType: effectiveMime, blob: base64Data } - - await this.connection - .sessionUpdate({ - sessionId, - update: { - sessionUpdate: messageChunk, - messageId: message.info.id, - content: { type: "resource", resource }, - }, - }) - .catch((err) => { - log.error("failed to send resource to ACP", { error: err }) - }) - } - } - // URLs that don't match file:// or data: are skipped (unsupported) - } else if (part.type === "reasoning") { - if (part.text) { - await this.connection - .sessionUpdate({ - sessionId, - update: { - sessionUpdate: "agent_thought_chunk", - messageId: message.info.id, - content: { - type: "text", - text: part.text, - }, - }, - }) - .catch((err) => { - log.error("failed to send reasoning to ACP", { error: err }) - }) - } - } - } - } - - private shellOutput(part: ToolPart) { - if (part.tool !== ShellID.ToolID) return - if (!("metadata" in part.state) || !part.state.metadata || typeof part.state.metadata !== "object") return - const output = part.state.metadata["output"] - if (typeof output !== "string") return - return output - } - - private async toolStart(sessionId: string, part: ToolPart) { - if (this.toolStarts.has(part.callID)) return - this.toolStarts.add(part.callID) - await this.connection - .sessionUpdate({ - sessionId, - update: { - sessionUpdate: "tool_call", - toolCallId: part.callID, - title: part.tool, - kind: toToolKind(part.tool), - status: "pending", - locations: [], - rawInput: {}, - }, - }) - .catch((error) => { - log.error("failed to send tool pending to ACP", { error }) - }) - } - - private async loadAvailableModes(directory: string): Promise { - const agents = await this.config.sdk.app - .agents( - { - directory, - }, - { throwOnError: true }, - ) - .then((resp) => resp.data!) - - return agents - .filter((agent) => agent.mode !== "subagent" && !agent.hidden) - .map((agent) => ({ - id: agent.name, - name: agent.name, - description: agent.description, - })) - } - - private async resolveModeState( - directory: string, - sessionId: string, - ): Promise<{ availableModes: ModeOption[]; currentModeId?: string }> { - const availableModes = await this.loadAvailableModes(directory) - const storedModeId = this.sessionManager.get(sessionId).modeId - if (storedModeId && availableModes.some((mode) => mode.id === storedModeId)) { - return { availableModes, currentModeId: storedModeId } - } - - const currentModeId = await (async () => { - if (!availableModes.length) return undefined - const defaultAgent = await ACPRuntime.defaultAgentInfo(directory) - const resolvedModeId = availableModes.find((mode) => mode.name === defaultAgent.name)?.id ?? availableModes[0].id - this.sessionManager.setMode(sessionId, resolvedModeId) - return resolvedModeId - })() - - return { availableModes, currentModeId } - } - - private async loadSessionMode(params: LoadSessionRequest) { - const directory = params.cwd - const sessionId = params.sessionId - const model = this.sessionManager.get(sessionId).model ?? (await defaultModel(this.config, directory)) - - const providers = await this.sdk.config.providers({ directory }).then((x) => x.data!.providers) - const entries = sortProvidersByName(providers) - const availableVariants = modelVariantsFromProviders(entries, model) - const currentVariant = this.sessionManager.getVariant(sessionId) - if (currentVariant && !availableVariants.includes(currentVariant)) { - this.sessionManager.setVariant(sessionId, undefined) - } - const availableModels = buildAvailableModels(entries) - const modeState = await this.resolveModeState(directory, sessionId) - const currentModeId = modeState.currentModeId - const modes = currentModeId - ? { - availableModes: modeState.availableModes, - currentModeId, - } - : undefined - - const commands = await this.config.sdk.command - .list( - { - directory, - }, - { throwOnError: true }, - ) - .then((resp) => resp.data!) - - const availableCommands = commands.map((command) => ({ - name: command.name, - description: command.description ?? "", - })) - const names = new Set(availableCommands.map((c) => c.name)) - if (!names.has("compact")) - availableCommands.push({ - name: "compact", - description: "compact the session", - }) - - const mcpServers: Record = {} - for (const server of params.mcpServers) { - if ("type" in server) { - mcpServers[server.name] = { - url: server.url, - headers: server.headers.reduce>((acc, { name, value }) => { - acc[name] = value - return acc - }, {}), - type: "remote", - } - } else { - mcpServers[server.name] = { - type: "local", - command: [server.command, ...server.args], - environment: server.env.reduce>((acc, { name, value }) => { - acc[name] = value - return acc - }, {}), - } - } - } - - await Promise.all( - Object.entries(mcpServers).map(async ([key, mcp]) => { - await this.sdk.mcp - .add( - { - directory, - name: key, - config: mcp, - }, - { throwOnError: true }, - ) - .catch((error) => { - log.error("failed to add mcp server", { name: key, error }) - }) - }), - ) - - setTimeout(() => { - void this.connection.sessionUpdate({ - sessionId, - update: { - sessionUpdate: "available_commands_update", - availableCommands, - }, - }) - }, 0) - - return { - sessionId, - models: { - currentModelId: formatModelIdWithVariant(model, currentVariant, availableVariants, false), - availableModels, - }, - modes, - configOptions: buildConfigOptions({ - currentModelId: formatModelIdWithVariant(model, currentVariant, availableVariants, false), - availableModels, - currentVariant, - availableVariants, - modes, - }), - _meta: buildVariantMeta({ - model, - variant: this.sessionManager.getVariant(sessionId), - availableVariants, - }), - } - } - - async unstable_setSessionModel(params: SetSessionModelRequest) { - const session = this.sessionManager.get(params.sessionId) - const providers = await this.sdk.config - .providers({ directory: session.cwd }, { throwOnError: true }) - .then((x) => x.data!.providers) - - const selection = parseModelSelection(params.modelId, providers) - this.sessionManager.setModel(session.id, selection.model) - this.sessionManager.setVariant(session.id, selection.variant) - - const entries = sortProvidersByName(providers) - const availableVariants = modelVariantsFromProviders(entries, selection.model) - const modeState = await this.resolveModeState(session.cwd, session.id) - const modes = modeState.currentModeId - ? { availableModes: modeState.availableModes, currentModeId: modeState.currentModeId } - : undefined - - await this.connection.sessionUpdate({ - sessionId: session.id, - update: { - sessionUpdate: "config_option_update", - configOptions: buildConfigOptions({ - currentModelId: formatModelIdWithVariant(selection.model, selection.variant, availableVariants, false), - availableModels: buildAvailableModels(entries), - currentVariant: selection.variant, - availableVariants, - modes, - }), - }, - }) - - return { - _meta: buildVariantMeta({ - model: selection.model, - variant: selection.variant, - availableVariants, - }), - } - } - - async setSessionMode(params: SetSessionModeRequest): Promise { - const session = this.sessionManager.get(params.sessionId) - const availableModes = await this.loadAvailableModes(session.cwd) - if (!availableModes.some((mode) => mode.id === params.modeId)) { - throw new Error(`Agent not found: ${params.modeId}`) - } - this.sessionManager.setMode(params.sessionId, params.modeId) - } - - async setSessionConfigOption(params: SetSessionConfigOptionRequest): Promise { - const session = this.sessionManager.get(params.sessionId) - const providers = await this.sdk.config - .providers({ directory: session.cwd }, { throwOnError: true }) - .then((x) => x.data!.providers) - const entries = sortProvidersByName(providers) - - if (params.configId === "model") { - if (typeof params.value !== "string") throw RequestError.invalidParams("model value must be a string") - const selection = parseModelSelection(params.value, providers) - this.sessionManager.setModel(session.id, selection.model) - this.sessionManager.setVariant(session.id, selection.variant) - } else if (params.configId === "effort") { - if (typeof params.value !== "string") throw RequestError.invalidParams("effort value must be a string") - const current = session.model ?? (await defaultModel(this.config, session.cwd)) - const availableVariants = modelVariantsFromProviders(entries, current) - if (!availableVariants.includes(params.value)) { - throw RequestError.invalidParams(JSON.stringify({ error: `Effort not found: ${params.value}` })) - } - this.sessionManager.setVariant(session.id, params.value) - } else if (params.configId === "mode") { - if (typeof params.value !== "string") throw RequestError.invalidParams("mode value must be a string") - const availableModes = await this.loadAvailableModes(session.cwd) - if (!availableModes.some((mode) => mode.id === params.value)) { - throw RequestError.invalidParams(JSON.stringify({ error: `Mode not found: ${params.value}` })) - } - this.sessionManager.setMode(session.id, params.value) - } else { - throw RequestError.invalidParams(JSON.stringify({ error: `Unknown config option: ${params.configId}` })) - } - - const updatedSession = this.sessionManager.get(session.id) - const model = updatedSession.model ?? (await defaultModel(this.config, session.cwd)) - const availableVariants = modelVariantsFromProviders(entries, model) - const currentModelId = formatModelIdWithVariant(model, updatedSession.variant, availableVariants, false) - const availableModels = buildAvailableModels(entries) - const modeState = await this.resolveModeState(session.cwd, session.id) - const modes = modeState.currentModeId - ? { availableModes: modeState.availableModes, currentModeId: modeState.currentModeId } - : undefined - - return { - configOptions: buildConfigOptions({ - currentModelId, - availableModels, - currentVariant: updatedSession.variant, - availableVariants, - modes, - }), - } - } - - async prompt(params: PromptRequest) { - const sessionID = params.sessionId - const session = this.sessionManager.get(sessionID) - const directory = session.cwd - - const current = session.model - const model = current ?? (await defaultModel(this.config, directory)) - if (!current) { - this.sessionManager.setModel(session.id, model) - } - const agent = session.modeId ?? (await ACPRuntime.defaultAgentInfo(directory)).name - - const parts: Array< - | { type: "text"; text: string; synthetic?: boolean; ignored?: boolean } - | { type: "file"; url: string; filename: string; mime: string } - > = [] - for (const part of params.prompt) { - switch (part.type) { - case "text": - const audience = part.annotations?.audience - const forAssistant = audience?.length === 1 && audience[0] === "assistant" - const forUser = audience?.length === 1 && audience[0] === "user" - parts.push({ - type: "text" as const, - text: part.text, - ...(forAssistant && { synthetic: true }), - ...(forUser && { ignored: true }), - }) - break - case "image": { - const parsed = parseUri(part.uri ?? "") - const filename = parsed.type === "file" ? parsed.filename : "image" - if (part.data) { - parts.push({ - type: "file", - url: `data:${part.mimeType};base64,${part.data}`, - filename, - mime: part.mimeType, - }) - } else if (part.uri && part.uri.startsWith("http:")) { - parts.push({ - type: "file", - url: part.uri, - filename, - mime: part.mimeType, - }) - } - break - } - - case "resource_link": - const parsed = parseUri(part.uri) - // Use the name from resource_link if available - if (part.name && parsed.type === "file") { - parsed.filename = part.name - } - parts.push(parsed) - - break - - case "resource": { - const resource = part.resource - if ("text" in resource && resource.text) { - parts.push({ - type: "text", - text: resource.text, - }) - } else if ("blob" in resource && resource.blob && resource.mimeType) { - // Binary resource (PDFs, etc.): store as file part with data URL - const parsed = parseUri(resource.uri ?? "") - const filename = parsed.type === "file" ? parsed.filename : "file" - parts.push({ - type: "file", - url: `data:${resource.mimeType};base64,${resource.blob}`, - filename, - mime: resource.mimeType, - }) - } - break - } - - default: - break - } - } - - log.info("parts", { parts }) - - const cmd = (() => { - const text = parts - .filter((p): p is { type: "text"; text: string } => p.type === "text") - .map((p) => p.text) - .join("") - .trim() - - if (!text.startsWith("/")) return - - const [name, ...rest] = text.slice(1).split(/\s+/) - return { name, args: rest.join(" ").trim() } - })() - - const buildUsage = (msg: AssistantMessage): Usage => ({ - totalTokens: - msg.tokens.input + - msg.tokens.output + - msg.tokens.reasoning + - (msg.tokens.cache?.read ?? 0) + - (msg.tokens.cache?.write ?? 0), - inputTokens: msg.tokens.input, - outputTokens: msg.tokens.output, - thoughtTokens: msg.tokens.reasoning || undefined, - cachedReadTokens: msg.tokens.cache?.read || undefined, - cachedWriteTokens: msg.tokens.cache?.write || undefined, - }) - - if (!cmd) { - const response = await this.sdk.session.prompt({ - sessionID, - model: { - providerID: model.providerID, - modelID: model.modelID, - }, - variant: this.sessionManager.getVariant(sessionID), - parts, - agent, - directory, - }) - const msg = response.data?.info - - await sendUsageUpdate(this.connection, this.sdk, sessionID, directory) - - return { - stopReason: "end_turn" as const, - usage: msg ? buildUsage(msg) : undefined, - _meta: {}, - } - } - - const command = await this.config.sdk.command - .list({ directory }, { throwOnError: true }) - .then((x) => x.data!.find((c) => c.name === cmd.name)) - if (command) { - const response = await this.sdk.session.command({ - sessionID, - command: command.name, - arguments: cmd.args, - model: model.providerID + "/" + model.modelID, - agent, - directory, - }) - const msg = response.data?.info - - await sendUsageUpdate(this.connection, this.sdk, sessionID, directory) - - return { - stopReason: "end_turn" as const, - usage: msg ? buildUsage(msg) : undefined, - _meta: {}, - } - } - - switch (cmd.name) { - case "compact": - await this.config.sdk.session.summarize( - { - sessionID, - directory, - providerID: model.providerID, - modelID: model.modelID, - }, - { throwOnError: true }, - ) - break - } - - await sendUsageUpdate(this.connection, this.sdk, sessionID, directory) - - return { - stopReason: "end_turn" as const, - _meta: {}, - } - } - - async cancel(params: CancelNotification) { - const session = this.sessionManager.get(params.sessionId) - await this.config.sdk.session.abort( - { - sessionID: params.sessionId, - directory: session.cwd, - }, - { throwOnError: true }, - ) - } - - private async loadSessionMessages(directory: string, sessionId: string, limit?: number) { - return this.sdk.session - .messages( - { - sessionID: sessionId, - directory, - limit, - }, - { throwOnError: true }, - ) - .then((x) => x.data) - .catch((error) => { - log.error("unexpected error when fetching message", { error }) - return undefined - }) - } - - private restoreSessionStateFromMessages(sessionId: string, messages: SessionMessageResponse[] | undefined) { - const lastUser = messages?.findLast((message) => message.info.role === "user")?.info - if (lastUser?.role !== "user") return - - this.sessionManager.setModel(sessionId, { - providerID: ProviderID.make(lastUser.model.providerID), - modelID: ModelID.make(lastUser.model.modelID), - }) - this.sessionManager.setVariant(sessionId, lastUser.model.variant) - if (lastUser.agent) { - this.sessionManager.setMode(sessionId, lastUser.agent) - } + cancel(params: CancelNotification) { + return run(this.service.cancel(params)) } } -function toToolKind(toolName: string): ToolKind { - const tool = toolName.toLocaleLowerCase() - - switch (tool) { - case ShellID.ToolID: - return "execute" - - case "webfetch": - return "fetch" - - case "edit": - case "patch": - case "write": - return "edit" - - case "grep": - case "glob": - case "repo_clone": - case "repo_overview": - case "context7_resolve_library_id": - case "context7_get_library_docs": - return "search" - - case "read": - return "read" - - default: - return "other" - } -} - -function toLocations(toolName: string, input: Record): { path: string }[] { - const tool = toolName.toLocaleLowerCase() - - switch (tool) { - case "read": - case "edit": - case "write": - return input["filePath"] ? [{ path: input["filePath"] }] : [] - case "glob": - case "grep": - return input["path"] ? [{ path: input["path"] }] : [] - case "repo_clone": - return input["path"] ? [{ path: input["path"] }] : [] - case "repo_overview": - return input["path"] ? [{ path: input["path"] }] : [] - case ShellID.ToolID: - return [] - default: - return [] - } -} - -function completedToolContent(part: ToolPart, kind: ToolKind): ToolCallContent[] { - if (part.state.status !== "completed") return [] - - const content: ToolCallContent[] = [ - { - type: "content", - content: { - type: "text", - text: part.state.output, - }, - }, - ] - - if (kind === "edit") { - const input = part.state.input - const filePath = typeof input["filePath"] === "string" ? input["filePath"] : "" - const oldText = typeof input["oldString"] === "string" ? input["oldString"] : "" - const newText = - typeof input["newString"] === "string" - ? input["newString"] - : typeof input["content"] === "string" - ? input["content"] - : "" - content.push({ - type: "diff", - path: filePath, - oldText, - newText, - }) - } - - content.push(...imageContents(part.state.attachments ?? [])) - return content -} - -function completedToolRawOutput(part: ToolPart) { - if (part.state.status !== "completed") return {} - return { - output: part.state.output, - metadata: part.state.metadata, - ...(part.state.attachments?.length ? { attachments: part.state.attachments } : {}), - } -} - -function imageContents(attachments: Array<{ mime: string; url: string }>): ToolCallContent[] { - return attachments.flatMap((attachment): ToolCallContent[] => { - const match = attachment.url.match(/^data:([^;,]+)(?:;[^,]*)*;base64,(.*)$/) - const mime = match?.[1] ?? attachment.mime - if (!mime.startsWith("image/")) return [] - const data = match?.[2] - if (data === undefined) return [] - return [ - { - type: "content" as const, - content: { - type: "image" as const, - mimeType: mime, - data, - }, - }, - ] +function run(effect: Effect.Effect) { + return Effect.runPromise(effect.pipe(Effect.mapError(ACPError.toRequestError))).catch((defect: unknown) => { + if (defect instanceof RequestError) throw defect + throw ACPError.toRequestError(ACPError.fromUnknownDefect(defect)) }) } -async function defaultModel(config: ACPConfig, cwd?: string): Promise<{ providerID: ProviderID; modelID: ModelID }> { - const sdk = config.sdk - const configured = config.defaultModel - if (configured) return configured - - const directory = cwd ?? process.cwd() - - const specified = await sdk.config - .get({ directory }, { throwOnError: true }) - .then((resp) => { - const cfg = resp.data - if (!cfg || !cfg.model) return undefined - return Provider.parseModel(cfg.model) - }) - .catch((error) => { - log.error("failed to load user config for default model", { error }) - return undefined - }) - - const providers = await sdk.config - .providers({ directory }, { throwOnError: true }) - .then((x) => x.data?.providers ?? []) - .catch((error) => { - log.error("failed to list providers for default model", { error }) - return [] - }) - - if (specified && providers.length) { - const provider = providers.find((p) => p.id === specified.providerID) - if (provider && provider.models[specified.modelID]) return specified - } - - if (specified && !providers.length) return specified - - const lastUsed = await lastUsedModel(sdk, directory, providers) - if (lastUsed) return lastUsed - - const opencodeProvider = providers.find((p) => p.id === "opencode") - if (opencodeProvider) { - const [best] = Provider.sort(Object.values(opencodeProvider.models)) - if (best) { - return { - providerID: ProviderID.make(best.providerID), - modelID: ModelID.make(best.id), - } - } - } - - const models = providers.flatMap((p) => Object.values(p.models)) - const [best] = Provider.sort(models) - if (best) { - return { - providerID: ProviderID.make(best.providerID), - modelID: ModelID.make(best.id), - } - } - - if (specified) return specified - throw new Error("No models available") -} - -async function lastUsedModel( - sdk: KiloClient, - directory: string, - providers: Array<{ id: string; models: Record }>, -): Promise<{ providerID: ProviderID; modelID: ModelID } | undefined> { - const session = await sdk.session - .list({ directory, roots: true, limit: 1 }, { throwOnError: true }) - .then((x) => x.data?.[0]) - .catch((error) => { - log.error("failed to list sessions for default model", { error }) - return undefined - }) - if (!session) return - - const lastUser = await sdk.session - .messages({ sessionID: session.id, directory, limit: 20 }, { throwOnError: true }) - .then((x) => x.data?.findLast((message) => message.info.role === "user")?.info) - .catch((error) => { - log.error("failed to load session messages for default model", { error, sessionID: session.id }) - return undefined - }) - if (lastUser?.role !== "user") return - - const provider = providers.find((entry) => entry.id === lastUser.model.providerID) - if (!provider?.models[lastUser.model.modelID]) return - return { - providerID: ProviderID.make(lastUser.model.providerID), - modelID: ModelID.make(lastUser.model.modelID), - } -} - -function parseUri( - uri: string, -): { type: "file"; url: string; filename: string; mime: string } | { type: "text"; text: string } { - try { - if (uri.startsWith("file://")) { - const path = uri.slice(7) - const name = path.split("/").pop() || path - return { - type: "file", - url: uri, - filename: name, - mime: "text/plain", - } - } - if (uri.startsWith("zed://")) { - const url = new URL(uri) - const path = url.searchParams.get("path") - if (path) { - const name = path.split("/").pop() || path - return { - type: "file", - url: pathToFileURL(path).href, - filename: name, - mime: "text/plain", - } - } - } - return { - type: "text", - text: uri, - } - } catch { - return { - type: "text", - text: uri, - } - } -} - -function getNewContent(fileOriginal: string, unifiedDiff: string): string | undefined { - const result = applyPatch(fileOriginal, unifiedDiff) - if (result === false) { - log.error("Failed to apply unified diff (context mismatch)") - return undefined - } - return result -} - -function sortProvidersByName(providers: T[]): T[] { - return [...providers].sort((a, b) => { - const nameA = a.name.toLowerCase() - const nameB = b.name.toLowerCase() - if (nameA < nameB) return -1 - if (nameA > nameB) return 1 - return 0 - }) -} - -function modelVariantsFromProviders( - providers: Array<{ id: string; models: Record }> }>, - model: { providerID: ProviderID; modelID: ModelID }, -): string[] { - const provider = providers.find((entry) => entry.id === model.providerID) - if (!provider) return [] - const modelInfo = provider.models[model.modelID] - if (!modelInfo?.variants) return [] - return Object.keys(modelInfo.variants) -} - -function buildAvailableModels( - providers: Array<{ id: string; name: string; models: Record }>, - options: { includeVariants?: boolean } = {}, -): ModelOption[] { - const includeVariants = options.includeVariants ?? false - return providers.flatMap((provider) => { - const unsorted: Array<{ id: string; name: string; variants?: Record }> = Object.values(provider.models) - const models = Provider.sort(unsorted) - return models.flatMap((model) => { - const base: ModelOption = { - modelId: `${provider.id}/${model.id}`, - name: `${provider.name}/${model.name}`, - } - if (!includeVariants || !model.variants) return [base] - const variants = Object.keys(model.variants).filter((variant) => variant !== DEFAULT_VARIANT_VALUE) - const variantOptions = variants.map((variant) => ({ - modelId: `${provider.id}/${model.id}/${variant}`, - name: `${provider.name}/${model.name} (${variant})`, - })) - return [base, ...variantOptions] - }) - }) -} - -function formatModelIdWithVariant( - model: { providerID: ProviderID; modelID: ModelID }, - variant: string | undefined, - availableVariants: string[], - includeVariant: boolean, -) { - const base = `${model.providerID}/${model.modelID}` - if (!includeVariant || availableVariants.length === 0) return base - const selectedVariant = - variant && availableVariants.includes(variant) - ? variant - : availableVariants.includes(DEFAULT_VARIANT_VALUE) - ? DEFAULT_VARIANT_VALUE - : availableVariants[0] - return `${base}/${selectedVariant}` -} - -function buildVariantMeta(input: { - model: { providerID: ProviderID; modelID: ModelID } - variant?: string - availableVariants: string[] -}) { - return { - opencode: { - modelId: `${input.model.providerID}/${input.model.modelID}`, - variant: input.variant ?? null, - availableVariants: input.availableVariants, - }, - } -} - -function parseModelSelection( - modelId: string, - providers: Array<{ id: string; models: Record }> }>, -): { model: { providerID: ProviderID; modelID: ModelID }; variant?: string } { - const parsed = Provider.parseModel(modelId) - const provider = providers.find((p) => p.id === parsed.providerID) - if (!provider) { - return { model: parsed, variant: undefined } - } - - // Check if modelID exists directly - if (provider.models[parsed.modelID]) { - return { model: parsed, variant: undefined } - } - - // Try to extract variant from end of modelID (e.g., "claude-sonnet-4/high" -> model: "claude-sonnet-4", variant: "high") - const segments = parsed.modelID.split("/") - if (segments.length > 1) { - const candidateVariant = segments[segments.length - 1] - const baseModelId = segments.slice(0, -1).join("/") - const baseModelInfo = provider.models[baseModelId] - if (baseModelInfo?.variants && candidateVariant in baseModelInfo.variants) { - return { - model: { providerID: parsed.providerID, modelID: ModelID.make(baseModelId) }, - variant: candidateVariant, - } - } - } - - return { model: parsed, variant: undefined } -} - -function buildConfigOptions(input: { - currentModelId: string - availableModels: ModelOption[] - currentVariant?: string - availableVariants?: string[] - modes?: { availableModes: ModeOption[]; currentModeId: string } | undefined -}): SessionConfigOption[] { - const options: SessionConfigOption[] = [ - { - id: "model", - name: "Model", - category: "model", - type: "select", - currentValue: input.currentModelId, - options: input.availableModels.map((m) => ({ value: m.modelId, name: m.name })), - }, - ] - if (input.availableVariants?.length) { - options.push({ - id: "effort", - name: "Effort", - description: "Available effort levels for this model", - category: "thought_level", - type: "select", - currentValue: - input.currentVariant && input.availableVariants.includes(input.currentVariant) - ? input.currentVariant - : input.availableVariants.includes(DEFAULT_VARIANT_VALUE) - ? DEFAULT_VARIANT_VALUE - : input.availableVariants[0], - options: input.availableVariants.map((variant) => ({ value: variant, name: formatVariantName(variant) })), - }) - } - if (input.modes) { - options.push({ - id: "mode", - name: "Session Mode", - category: "mode", - type: "select", - currentValue: input.modes.currentModeId, - options: input.modes.availableModes.map((m) => ({ - value: m.id, - name: m.name, - ...(m.description ? { description: m.description } : {}), - })), - }) - } - return options -} - -function formatVariantName(variant: string) { - return variant - .split(/[_-]/) - .map((part) => (part ? part.charAt(0).toUpperCase() + part.slice(1) : part)) - .join(" ") -} - export * as ACP from "./agent" diff --git a/packages/opencode/src/acp/config-option.ts b/packages/opencode/src/acp/config-option.ts new file mode 100644 index 0000000000..b730ae0753 --- /dev/null +++ b/packages/opencode/src/acp/config-option.ts @@ -0,0 +1,203 @@ +import type { SessionConfigOption } from "@agentclientprotocol/sdk" + +export const DEFAULT_VARIANT_VALUE = "default" + +export type ConfigOptionModel = { + id: string + name: string + variants?: Record> +} + +export type ConfigOptionProvider = { + id: string + name: string + models: Record +} + +export type ConfigOptionMode = { + id: string + name: string + description?: string +} + +export type ModelSelection = { + model: { + providerID: string + modelID: string + } + variant?: string +} + +export function buildModelSelectOption(input: { + providers: readonly ConfigOptionProvider[] + currentModel: ModelSelection["model"] + currentVariant?: string + includeVariants?: boolean +}): SessionConfigOption { + return { + id: "model", + name: "Model", + category: "model", + type: "select", + currentValue: formatCurrentModelId({ + model: input.currentModel, + variant: input.currentVariant, + variants: variantsForModel(input.providers, input.currentModel), + includeVariant: input.includeVariants ?? false, + }), + options: buildModelSelectOptions(input.providers, { includeVariants: input.includeVariants ?? false }), + } +} + +export function buildEffortSelectOption(input: { + variants: readonly string[] + currentVariant?: string +}): SessionConfigOption | undefined { + if (input.variants.length === 0) return undefined + + return { + id: "effort", + name: "Effort", + description: "Available effort levels for this model", + category: "thought_level", + type: "select", + currentValue: selectVariant(input.currentVariant, input.variants), + options: input.variants.map((variant) => ({ + value: variant, + name: formatVariantName(variant), + })), + } +} + +export function buildModeSelectOption(input: { + modes: readonly ConfigOptionMode[] + currentModeId: string +}): SessionConfigOption { + return { + id: "mode", + name: "Session Mode", + category: "mode", + type: "select", + currentValue: input.currentModeId, + options: input.modes.map((mode) => ({ + value: mode.id, + name: mode.name, + ...(mode.description ? { description: mode.description } : {}), + })), + } +} + +export function buildConfigOptions(input: { + providers: readonly ConfigOptionProvider[] + currentModel: ModelSelection["model"] + currentVariant?: string + includeModelVariants?: boolean + modes?: readonly ConfigOptionMode[] + currentModeId?: string +}): SessionConfigOption[] { + const variants = variantsForModel(input.providers, input.currentModel) + const effort = buildEffortSelectOption({ variants, currentVariant: input.currentVariant }) + + return [ + buildModelSelectOption({ + providers: input.providers, + currentModel: input.currentModel, + currentVariant: input.currentVariant, + includeVariants: input.includeModelVariants ?? false, + }), + ...(effort ? [effort] : []), + ...(input.modes && input.currentModeId + ? [buildModeSelectOption({ modes: input.modes, currentModeId: input.currentModeId })] + : []), + ] +} + +export function parseModelSelection(modelId: string, providers: readonly ConfigOptionProvider[]): ModelSelection { + const provider = providers.find((item) => modelId.startsWith(`${item.id}/`)) + if (provider) { + const modelID = modelId.slice(provider.id.length + 1) + if (provider.models[modelID]) { + return { model: { providerID: provider.id, modelID } } + } + + const separator = modelID.lastIndexOf("/") + if (separator > -1) { + const baseModelID = modelID.slice(0, separator) + const variant = modelID.slice(separator + 1) + if (provider.models[baseModelID]?.variants?.[variant]) { + return { model: { providerID: provider.id, modelID: baseModelID }, variant } + } + } + + return { model: { providerID: provider.id, modelID } } + } + + const separator = modelId.indexOf("/") + if (separator === -1) { + return { model: { providerID: modelId, modelID: "" } } + } + + return { + model: { + providerID: modelId.slice(0, separator), + modelID: modelId.slice(separator + 1), + }, + } +} + +export function formatCurrentModelId(input: { + model: ModelSelection["model"] + variant?: string + variants?: readonly string[] + includeVariant?: boolean +}) { + const base = `${input.model.providerID}/${input.model.modelID}` + if (!input.includeVariant || !input.variants?.length) return base + return `${base}/${selectVariant(input.variant, input.variants)}` +} + +export function formatVariantName(variant: string) { + return variant + .split(/[_-]/) + .map((part) => (part ? part.charAt(0).toUpperCase() + part.slice(1) : part)) + .join(" ") +} + +function buildModelSelectOptions( + providers: readonly ConfigOptionProvider[], + options: { includeVariants: boolean }, +): Array<{ value: string; name: string }> { + return providers.flatMap((provider) => + Object.values(provider.models) + .sort((a, b) => a.name.localeCompare(b.name)) + .flatMap((model) => { + const base = { + value: `${provider.id}/${model.id}`, + name: `${provider.name}/${model.name}`, + } + if (!options.includeVariants || !model.variants) return [base] + + return [ + base, + ...Object.keys(model.variants) + .filter((variant) => variant !== DEFAULT_VARIANT_VALUE) + .map((variant) => ({ + value: `${provider.id}/${model.id}/${variant}`, + name: `${provider.name}/${model.name} (${formatVariantName(variant)})`, + })), + ] + }), + ) +} + +function variantsForModel(providers: readonly ConfigOptionProvider[], model: ModelSelection["model"]) { + return Object.keys( + providers.find((provider) => provider.id === model.providerID)?.models[model.modelID]?.variants ?? {}, + ) +} + +function selectVariant(variant: string | undefined, variants: readonly string[]) { + if (variant && variants.includes(variant)) return variant + if (variants.includes(DEFAULT_VARIANT_VALUE)) return DEFAULT_VARIANT_VALUE + return variants[0] +} diff --git a/packages/opencode/src/acp/content.ts b/packages/opencode/src/acp/content.ts new file mode 100644 index 0000000000..f83a75ef19 --- /dev/null +++ b/packages/opencode/src/acp/content.ts @@ -0,0 +1,250 @@ +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" + +export type PromptPart = MessageV2.TextPartInput | MessageV2.FilePartInput + +export type ReplayPart = + | { + type: "text" + text: string + synthetic?: boolean + ignored?: boolean + } + | { + type: "file" + url: string + mime: string + filename?: string + } + | { + type: "reasoning" + text: string + } + +export function promptContentToParts(content: readonly ContentBlock[]): PromptPart[] { + return content.flatMap(contentBlockToParts) +} + +export function contentBlockToParts(block: ContentBlock): PromptPart[] { + switch (block.type) { + case "text": + return [ + { + type: "text", + text: block.text, + ...audienceFlags(block.annotations?.audience ?? undefined), + }, + ] + + case "image": + if (block.data) { + return [ + { + type: "file", + url: `data:${block.mimeType};base64,${block.data}`, + filename: filenameFromUri(block.uri ?? undefined) ?? "image", + mime: block.mimeType, + }, + ] + } + if (block.uri?.startsWith("data:")) { + return [ + { + type: "file", + url: block.uri, + filename: filenameFromUri(block.uri) ?? "image", + mime: block.mimeType, + }, + ] + } + if (block.uri?.startsWith("http://") || block.uri?.startsWith("https://")) { + return [ + { + type: "file", + url: block.uri, + filename: filenameFromUri(block.uri) ?? "image", + mime: block.mimeType, + }, + ] + } + return [] + + case "resource_link": + return [resourceLinkToPart(block)] + + case "resource": + if ("text" in block.resource) { + return [{ type: "text", text: block.resource.text }] + } + if (block.resource.mimeType) { + return [ + { + type: "file", + url: block.resource.uri.startsWith("data:") + ? block.resource.uri + : `data:${block.resource.mimeType};base64,${block.resource.blob}`, + filename: filenameFromUri(block.resource.uri) ?? "file", + mime: block.resource.mimeType, + }, + ] + } + return [] + + default: + return [] + } +} + +export function partsToContentChunks(parts: readonly ReplayPart[]): ContentChunk[] { + return parts.flatMap(partToContentChunks) +} + +export function partToContentChunks(part: ReplayPart): ContentChunk[] { + switch (part.type) { + case "text": + if (!part.text) return [] + return [ + { + content: { + type: "text", + text: part.text, + ...partAudience(part), + }, + }, + ] + + case "file": + return filePartToContentChunks(part) + + case "reasoning": + if (!part.text) return [] + return [ + { + content: { + type: "text", + text: part.text, + }, + }, + ] + } +} + +function resourceLinkToPart(link: ResourceLink): PromptPart { + const parsed = uriToFilePart(link.uri, link.mimeType ?? "text/plain", link.name) + if (parsed.type === "file") return parsed + return { type: "text", text: parsed.text } +} + +function uriToFilePart( + uri: string, + mime: string, + filename?: string, +): MessageV2.FilePartInput | MessageV2.TextPartInput { + try { + if (uri.startsWith("file://")) { + return { + type: "file", + url: uri, + filename: filename ?? filenameFromUri(uri) ?? "file", + mime, + } + } + if (uri.startsWith("zed://")) { + const pathname = new URL(uri).searchParams.get("path") + if (pathname) { + return { + type: "file", + url: pathToFileURL(pathname).href, + filename: filename ?? (path.basename(pathname) || "file"), + mime, + } + } + } + return { type: "text", text: uri } + } catch { + return { type: "text", text: uri } + } +} + +function filePartToContentChunks(part: Extract): ContentChunk[] { + if (part.url.startsWith("file://")) { + return [ + { + content: { + type: "resource_link", + uri: part.url, + name: part.filename ?? "file", + mimeType: part.mime, + }, + }, + ] + } + if (!part.url.startsWith("data:")) return [] + + const data = decodeDataUrl(part.url) + if (!data) return [] + if (data.mime.startsWith("image/")) { + return [ + { + content: { + type: "image", + mimeType: data.mime, + data: data.base64, + uri: pathToFileURL(part.filename ?? "image").href, + }, + }, + ] + } + + return [ + { + content: { + type: "resource", + resource: + data.mime.startsWith("text/") || data.mime === "application/json" + ? { + uri: pathToFileURL(part.filename ?? "file").href, + mimeType: data.mime, + text: Buffer.from(data.base64, "base64").toString("utf8"), + } + : { + uri: pathToFileURL(part.filename ?? "file").href, + mimeType: data.mime, + blob: data.base64, + }, + }, + }, + ] +} + +function decodeDataUrl(url: string) { + const match = /^data:([^;]+);base64,(.*)$/.exec(url) + if (!match) return + return { mime: match[1], base64: match[2] } +} + +function audienceFlags(audience: readonly Role[] | null | undefined) { + if (audience?.length === 1 && audience[0] === "assistant") return { synthetic: true } + if (audience?.length === 1 && audience[0] === "user") return { ignored: true } + return {} +} + +function partAudience(part: Extract) { + const audience: Role[] | undefined = part.synthetic ? ["assistant"] : part.ignored ? ["user"] : undefined + if (!audience) return {} + return { annotations: { audience } } +} + +function filenameFromUri(uri: string | undefined) { + if (!uri) return + if (uri.startsWith("data:")) return + try { + const parsed = new URL(uri) + const name = path.basename(parsed.pathname) + return name || undefined + } catch { + return path.basename(uri) || undefined + } +} diff --git a/packages/opencode/src/acp/directory.ts b/packages/opencode/src/acp/directory.ts new file mode 100644 index 0000000000..c49613dd06 --- /dev/null +++ b/packages/opencode/src/acp/directory.ts @@ -0,0 +1,209 @@ +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 { Provider } from "@/provider/provider" +import { Context, Effect, Layer, SynchronizedRef } from "effect" +import type * as ACPError from "./error" + +export type ModelOption = { + readonly providerID: ProviderID + readonly providerName: string + readonly modelID: ModelID + readonly modelName: string +} + +export type ModeOption = { + readonly id: string + readonly name: string + readonly description?: string +} + +export type ModelVariants = NonNullable + +export type DefaultModel = { + readonly providerID: ProviderID + readonly modelID: ModelID +} + +export type Snapshot = { + readonly directory: string + readonly providers: Record + readonly modelOptions: readonly ModelOption[] + readonly variantsByModel: Readonly> + readonly availableModes: readonly ModeOption[] + readonly defaultModeID: string + readonly availableCommands: readonly Command.Info[] + readonly defaultModel?: DefaultModel +} + +export interface LoaderInterface { + readonly load: (directory: string) => Effect.Effect +} + +export interface Interface { + readonly get: (directory: string) => Effect.Effect + readonly refresh: (directory: string) => Effect.Effect + readonly variants: (snapshot: Snapshot, model: DefaultModel) => ModelVariants | undefined +} + +export class Loader extends Context.Service()("@opencode/ACPDirectoryLoader") {} + +export class Service extends Context.Service()("@opencode/ACPDirectory") {} + +export const modelKey = (model: DefaultModel) => `${model.providerID}/${model.modelID}` + +export const variants = (snapshot: Snapshot, model: DefaultModel) => snapshot.variantsByModel[modelKey(model)] + +export const build = (input: { + readonly directory: string + readonly providers: Record + readonly modes: readonly ModeOption[] + readonly defaultModeID: string + readonly commands: readonly Command.Info[] + readonly defaultModel?: DefaultModel +}): Snapshot => { + const modelOptions = Provider.sort( + Object.values(input.providers).flatMap((provider) => + Object.values(provider.models).map((model) => ({ + id: model.id, + providerID: provider.id, + providerName: provider.name, + modelID: model.id, + modelName: model.name, + })), + ), + ).map((model) => ({ + providerID: model.providerID, + providerName: model.providerName, + modelID: model.modelID, + modelName: model.modelName, + })) + + return { + directory: input.directory, + providers: input.providers, + modelOptions, + variantsByModel: Object.fromEntries( + Object.values(input.providers).flatMap((provider) => + Object.values(provider.models).flatMap((model) => + model.variants ? [[modelKey({ providerID: provider.id, modelID: model.id }), model.variants]] : [], + ), + ), + ), + availableModes: input.modes, + defaultModeID: input.modes.some((mode) => mode.id === input.defaultModeID) + ? input.defaultModeID + : (input.modes[0]?.id ?? input.defaultModeID), + availableCommands: input.commands, + ...(input.defaultModel ? { defaultModel: input.defaultModel } : {}), + } +} + +export const loaderLayer = Layer.effect( + Loader, + Effect.gen(function* () { + const store = yield* InstanceStore.Service + const provider = yield* Provider.Service + const agent = yield* Agent.Service + const command = yield* Command.Service + + return Loader.of({ + load: Effect.fn("ACPDirectoryLoader.load")(function* (directory) { + const ctx = yield* store.load({ directory }) + return yield* Effect.gen(function* () { + const providers = yield* provider.list() + const [agents, defaultAgent, commands, defaultModel] = yield* Effect.all( + [agent.list(), agent.defaultInfo(), command.list(), provider.defaultModel().pipe(Effect.option)], + { concurrency: "unbounded" }, + ) + return build({ + directory, + providers, + modes: agents + .filter((item) => item.mode !== "subagent" && item.hidden !== true) + .map((item) => ({ + id: item.name, + name: item.name, + ...(item.description ? { description: item.description } : {}), + })), + defaultModeID: defaultAgent.name, + commands: commands.toSorted((a, b) => a.name.localeCompare(b.name)), + ...(defaultModel._tag === "Some" ? { defaultModel: defaultModel.value } : {}), + }) + }).pipe(Effect.provideService(InstanceRef, ctx)) + }), + }) + }), +) + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const loader = yield* Loader + const snapshots = yield* SynchronizedRef.make(new Map>()) + + const cached = Effect.fnUntraced(function* (directory: string) { + return yield* SynchronizedRef.modifyEffect( + snapshots, + Effect.fnUntraced(function* (items) { + const current = items.get(directory) + if (current) return [current, items] as const + const next = yield* Effect.cached( + loader.load(directory).pipe( + Effect.tapError(() => + SynchronizedRef.update(snapshots, (state) => { + const next = new Map(state) + next.delete(directory) + return next + }), + ), + ), + ) + return [next, new Map(items).set(directory, next)] as const + }), + ) + }) + + const get = Effect.fn("ACPDirectory.get")(function* (directory: string) { + return yield* yield* cached(directory) + }) + + const refresh = Effect.fn("ACPDirectory.refresh")(function* (directory: string) { + return yield* SynchronizedRef.modifyEffect( + snapshots, + Effect.fnUntraced(function* (items) { + const next = yield* Effect.cached( + loader.load(directory).pipe( + Effect.tapError(() => + SynchronizedRef.update(snapshots, (state) => { + const next = new Map(state) + next.delete(directory) + return next + }), + ), + ), + ) + return [next, new Map(items).set(directory, next)] as const + }), + ).pipe(Effect.flatten) + }) + + return Service.of({ + get, + refresh, + variants, + }) + }), +) + +export const defaultLayer = layer.pipe( + Layer.provide(loaderLayer), + Layer.provide(Provider.defaultLayer), + Layer.provide(Agent.defaultLayer), + Layer.provide(Command.defaultLayer), + Layer.provide(InstanceStore.defaultLayer), +) + +export * as Directory from "./directory" diff --git a/packages/opencode/src/acp/error.ts b/packages/opencode/src/acp/error.ts new file mode 100644 index 0000000000..0c66a0e049 --- /dev/null +++ b/packages/opencode/src/acp/error.ts @@ -0,0 +1,90 @@ +import { RequestError } from "@agentclientprotocol/sdk" +import { Schema } from "effect" + +export class SessionNotFoundError extends Schema.TaggedErrorClass()("ACPSessionNotFoundError", { + sessionId: Schema.String, +}) {} + +export class InvalidConfigOptionError extends Schema.TaggedErrorClass()( + "ACPInvalidConfigOptionError", + { + configId: Schema.String, + }, +) {} + +export class InvalidModelError extends Schema.TaggedErrorClass()("ACPInvalidModelError", { + modelId: Schema.String, + providerId: Schema.optional(Schema.String), +}) {} + +export class InvalidEffortError extends Schema.TaggedErrorClass()("ACPInvalidEffortError", { + effort: Schema.String, +}) {} + +export class InvalidModeError extends Schema.TaggedErrorClass()("ACPInvalidModeError", { + mode: Schema.String, +}) {} + +export class AuthRequiredError extends Schema.TaggedErrorClass()("ACPAuthRequiredError", { + providerId: Schema.optional(Schema.String), +}) {} + +export class UnknownAuthMethodError extends Schema.TaggedErrorClass()( + "ACPUnknownAuthMethodError", + { + methodId: Schema.String, + }, +) {} + +export class UnsupportedOperationError extends Schema.TaggedErrorClass()( + "ACPUnsupportedOperationError", + { + method: Schema.String, + }, +) {} + +export class ServiceFailureError extends Schema.TaggedErrorClass()("ACPServiceFailureError", { + safeMessage: Schema.String, + service: Schema.optional(Schema.String), +}) {} + +export type Error = + | SessionNotFoundError + | InvalidConfigOptionError + | InvalidModelError + | InvalidEffortError + | InvalidModeError + | AuthRequiredError + | UnknownAuthMethodError + | UnsupportedOperationError + | ServiceFailureError + +export function toRequestError(error: Error) { + switch (error._tag) { + case "ACPSessionNotFoundError": + return RequestError.invalidParams({ sessionId: error.sessionId }, `session not found: ${error.sessionId}`) + case "ACPInvalidConfigOptionError": + return RequestError.invalidParams({ configId: error.configId }, `unknown config option: ${error.configId}`) + case "ACPInvalidModelError": + return RequestError.invalidParams( + { providerId: error.providerId, modelId: error.modelId }, + `model not found: ${error.modelId}`, + ) + case "ACPInvalidEffortError": + return RequestError.invalidParams({ effort: error.effort }, `effort not found: ${error.effort}`) + case "ACPInvalidModeError": + return RequestError.invalidParams({ mode: error.mode }, `mode not found: ${error.mode}`) + case "ACPAuthRequiredError": + return RequestError.authRequired({ providerId: error.providerId }, "provider authentication required") + case "ACPUnknownAuthMethodError": + return RequestError.invalidParams({ methodId: error.methodId }, `unknown auth method: ${error.methodId}`) + case "ACPUnsupportedOperationError": + return RequestError.methodNotFound(error.method) + case "ACPServiceFailureError": + return RequestError.internalError({ service: error.service }, error.safeMessage) + } +} + +export function fromUnknownDefect(_defect: unknown, safeMessage = "Internal service failure") { + return new ServiceFailureError({ safeMessage }) +} diff --git a/packages/opencode/src/acp/event.ts b/packages/opencode/src/acp/event.ts new file mode 100644 index 0000000000..32f2ecce29 --- /dev/null +++ b/packages/opencode/src/acp/event.ts @@ -0,0 +1,319 @@ +import type { AgentSideConnection } from "@agentclientprotocol/sdk" +import * as Log from "@opencode-ai/core/util/log" +import type { + Event, + EventMessagePartDelta, + EventMessagePartUpdated, + KiloClient, + Part, + SessionMessageResponse, + ToolPart, +} from "@kilocode/sdk/v2" +import { Effect } from "effect" +import { ACPSession } from "./session" +import { ACPPermission } from "./permission" +import { + duplicateRunningToolUpdate, + errorToolUpdate, + pendingToolCall, + runningToolUpdate, + shellOutputSnapshot, + completedToolUpdate, +} from "./tool" + +const log = Log.create({ service: "acp-event" }) + +type Connection = Pick & + Partial> +type GlobalEventEnvelope = { + payload?: Event +} +type GlobalEventStream = { + stream: AsyncIterable +} + +export function start(input: { sdk: KiloClient; connection: Connection; session: ACPSession.Interface }) { + const subscription = new Subscription(input) + subscription.start() + return subscription +} + +export class Subscription { + private readonly abort = new AbortController() + private readonly shellSnapshots = new Map() + private readonly toolStarts = new Set() + private readonly permission: ACPPermission.Handler + private started = false + + constructor( + private readonly input: { + sdk: KiloClient + connection: Connection + session: ACPSession.Interface + }, + ) { + this.permission = new ACPPermission.Handler(input) + } + + start() { + if (this.started) return + this.started = true + this.run().catch((error: unknown) => { + if (this.abort.signal.aborted) return + log.error("event subscription failed", { error }) + }) + } + + stop() { + this.abort.abort() + } + + async handle(event: Event) { + switch (event.type) { + case "permission.asked": + this.permission.handle(event) + return + case "message.part.updated": + return this.handlePartUpdated(event) + case "message.part.delta": + return this.handlePartDelta(event) + } + } + + async replayMessage(message: SessionMessageResponse) { + if (message.info.role !== "assistant" && message.info.role !== "user") return + + for (const part of message.parts) { + await this.recordFetchedPart(message.info.sessionID, message, part) + if (part.type === "tool") { + await this.handleToolPart(message.info.sessionID, part) + } + } + } + + private async run() { + while (!this.abort.signal.aborted) { + const events = (await this.input.sdk.global.event({ + signal: this.abort.signal, + })) as GlobalEventStream + + for await (const event of events.stream) { + if (this.abort.signal.aborted) return + if (!event.payload) continue + await this.handle(event.payload).catch((error: unknown) => { + log.error("failed to handle event", { error, type: event.payload?.type }) + }) + } + if (!this.abort.signal.aborted) await new Promise((resolve) => setTimeout(resolve, 1000)) + } + } + + private async handlePartUpdated(event: EventMessagePartUpdated) { + const part = event.properties.part + const sessionId = part.sessionID || event.properties.sessionID + const session = await Effect.runPromise(this.input.session.tryGet(sessionId)) + if (!session) return + + await Effect.runPromise( + this.input.session.recordPartMetadata({ + sessionId: session.id, + messageId: part.messageID, + partId: part.id, + partType: part.type, + role: part.type === "reasoning" ? "assistant" : undefined, + ignored: part.type === "text" ? part.ignored : undefined, + toolCallId: part.type === "tool" ? part.callID : undefined, + metadata: "metadata" in part ? part.metadata : undefined, + }), + ) + if (part.type === "tool") { + await this.handleToolPart(session.id, part) + } + } + + private async handlePartDelta(event: EventMessagePartDelta) { + const props = event.properties + const session = await Effect.runPromise(this.input.session.tryGet(props.sessionID)) + if (!session) return + + const known = await Effect.runPromise( + this.input.session.tryGetPartMetadata({ + sessionId: session.id, + messageId: props.messageID, + partId: props.partID, + }), + ) + const metadata = + known?.role && known.partType + ? known + : await this.fetchPartMetadata(session.id, session.cwd, props.messageID, props.partID) + if (metadata?.role !== "assistant") return + if (metadata.partType === "text" && props.field === "text" && metadata.ignored !== true) { + await this.input.connection.sessionUpdate({ + sessionId: session.id, + update: { + sessionUpdate: "agent_message_chunk", + messageId: props.messageID, + content: { + type: "text", + text: props.delta, + }, + }, + }) + return + } + + if (metadata.partType === "reasoning" && props.field === "text") { + await this.input.connection.sessionUpdate({ + sessionId: session.id, + update: { + sessionUpdate: "agent_thought_chunk", + messageId: props.messageID, + content: { + type: "text", + text: props.delta, + }, + }, + }) + } + } + + private async fetchPartMetadata(sessionId: string, cwd: string, messageId: string, partId: string) { + const message = await this.input.sdk.session + .message( + { + sessionID: sessionId, + messageID: messageId, + directory: cwd, + }, + { throwOnError: true }, + ) + .then((response) => response.data) + .catch((error: unknown) => { + log.error("unexpected error when fetching message for delta metadata", { error, messageId, partId }) + return undefined + }) + if (!message) return + + const part = message.parts.find((item) => item.id === partId) + if (!part) return + return await this.recordFetchedPart(sessionId, message, part) + } + + private async recordFetchedPart(sessionId: string, message: SessionMessageResponse, part: Part) { + return await Effect.runPromise( + this.input.session.recordPartMetadata({ + sessionId, + messageId: part.messageID, + partId: part.id, + partType: part.type, + role: message.info.role, + ignored: part.type === "text" ? part.ignored : undefined, + toolCallId: part.type === "tool" ? part.callID : undefined, + metadata: "metadata" in part ? part.metadata : undefined, + }), + ) + } + + private async handleToolPart(sessionId: string, part: ToolPart) { + await this.toolStart(sessionId, part) + + switch (part.state.status) { + case "pending": + this.shellSnapshots.delete(part.callID) + return + + case "running": + await this.runningTool(sessionId, part) + return + + case "completed": + this.clearTool(part.callID) + await this.input.connection.sessionUpdate({ + sessionId, + update: { + sessionUpdate: "tool_call_update", + ...completedToolUpdate({ + toolCallId: part.callID, + toolName: part.tool, + state: part.state, + }), + }, + }) + return + + case "error": + this.clearTool(part.callID) + await this.input.connection.sessionUpdate({ + sessionId, + update: { + sessionUpdate: "tool_call_update", + ...errorToolUpdate({ + toolCallId: part.callID, + toolName: part.tool, + state: part.state, + }), + }, + }) + return + } + } + + private async runningTool(sessionId: string, part: ToolPart) { + if (part.state.status !== "running") return + + const output = part.tool === "bash" ? shellOutputSnapshot(part.state) : undefined + if (output !== undefined) { + if (this.shellSnapshots.get(part.callID) === output) { + await this.input.connection.sessionUpdate({ + sessionId, + update: { + sessionUpdate: "tool_call_update", + ...duplicateRunningToolUpdate({ + toolCallId: part.callID, + toolName: part.tool, + state: part.state, + }), + }, + }) + return + } + this.shellSnapshots.set(part.callID, output) + } + + await this.input.connection.sessionUpdate({ + sessionId, + update: { + sessionUpdate: "tool_call_update", + ...runningToolUpdate({ + toolCallId: part.callID, + toolName: part.tool, + state: part.state, + output, + }), + }, + }) + } + + private async toolStart(sessionId: string, part: ToolPart) { + if (this.toolStarts.has(part.callID)) return + this.toolStarts.add(part.callID) + await this.input.connection.sessionUpdate({ + sessionId, + update: { + sessionUpdate: "tool_call", + ...pendingToolCall({ + toolCallId: part.callID, + toolName: part.tool, + }), + }, + }) + } + + private clearTool(toolCallId: string) { + this.toolStarts.delete(toolCallId) + this.shellSnapshots.delete(toolCallId) + } +} + +export * as ACPEvent from "./event" diff --git a/packages/opencode/src/acp/permission.ts b/packages/opencode/src/acp/permission.ts new file mode 100644 index 0000000000..b79fac7586 --- /dev/null +++ b/packages/opencode/src/acp/permission.ts @@ -0,0 +1,145 @@ +import type { AgentSideConnection, PermissionOption, RequestPermissionResponse } from "@agentclientprotocol/sdk" +import * as Log from "@opencode-ai/core/util/log" +import type { Event, KiloClient } from "@kilocode/sdk/v2" +import { applyPatch } from "diff" +import { exists, readText } from "@/util/filesystem" +import type { ACPSession } from "./session" +import { toLocations, toToolKind, type ToolInput } from "./tool" +import { Effect } from "effect" + +const log = Log.create({ service: "acp-permission" }) + +type PermissionEvent = Extract +type Reply = "once" | "always" | "reject" +type Connection = Partial> + +const permissionOptions: PermissionOption[] = [ + { optionId: "once", kind: "allow_once", name: "Allow once" }, + { optionId: "always", kind: "allow_always", name: "Always allow" }, + { optionId: "reject", kind: "reject_once", name: "Reject" }, +] + +export class Handler { + private readonly queues = new Map>() + + constructor( + private readonly input: { + sdk: KiloClient + connection: Connection + session: ACPSession.Interface + }, + ) {} + + handle(event: PermissionEvent) { + const permission = event.properties + const previous = this.queues.get(permission.sessionID) ?? Promise.resolve() + const next = previous + .then(() => this.process(event)) + .catch((error: unknown) => { + log.error("failed to handle permission", { error, permissionID: permission.id }) + }) + .finally(() => { + if (this.queues.get(permission.sessionID) === next) { + this.queues.delete(permission.sessionID) + } + }) + this.queues.set(permission.sessionID, next) + } + + private async process(event: PermissionEvent) { + const permission = event.properties + const session = await Effect.runPromise(this.input.session.tryGet(permission.sessionID)) + if (!session) return + + if (!this.input.connection.requestPermission) { + log.error("ACP connection cannot request permission", { + permissionID: permission.id, + sessionID: permission.sessionID, + }) + await this.reply(permission.id, "reject", session.cwd) + return + } + + const result = await this.input.connection + .requestPermission({ + sessionId: permission.sessionID, + toolCall: { + toolCallId: permission.tool?.callID ?? permission.id, + status: "pending", + title: permission.permission, + rawInput: permission.metadata, + kind: toToolKind(permission.permission), + locations: toLocations(permission.permission, permission.metadata), + }, + options: permissionOptions, + }) + .catch(async (error: unknown) => { + log.error("failed to request permission from ACP", { + error, + permissionID: permission.id, + sessionID: permission.sessionID, + }) + await this.reply(permission.id, "reject", session.cwd) + return undefined + }) + + if (!result) return + + const reply = selectedReply(result) + if (reply !== "once" && reply !== "always") { + await this.reply(permission.id, "reject", session.cwd) + return + } + + if (permission.permission === "edit") { + await this.writeProposedEdit(session.id, permission.metadata).catch((error: unknown) => { + log.error("failed to write proposed edit through ACP", { + error, + permissionID: permission.id, + sessionID: permission.sessionID, + }) + }) + } + + await this.reply(permission.id, reply, session.cwd) + } + + private async reply(requestID: string, reply: Reply, directory: string) { + await this.input.sdk.permission.reply({ + requestID, + reply, + directory, + }) + } + + private async writeProposedEdit(sessionId: string, metadata: ToolInput) { + const filepath = stringValue(metadata.filepath) + const diff = stringValue(metadata.diff) + if (!filepath || !diff || !this.input.connection.writeTextFile) return + + const content = (await exists(filepath)) ? await readText(filepath) : "" + const next = applyPatch(content, diff) + if (next === false) { + log.error("Failed to apply unified diff (context mismatch)") + return + } + + void this.input.connection.writeTextFile({ + sessionId, + path: filepath, + content: next, + }) + } +} + +function selectedReply(result: RequestPermissionResponse): Reply { + if (result.outcome.outcome !== "selected") return "reject" + if (result.outcome.optionId === "once" || result.outcome.optionId === "always") return result.outcome.optionId + return "reject" +} + +function stringValue(value: unknown) { + return typeof value === "string" ? value : undefined +} + +export * as ACPPermission from "./permission" diff --git a/packages/opencode/src/acp/profile.ts b/packages/opencode/src/acp/profile.ts new file mode 100644 index 0000000000..4d6b184a90 --- /dev/null +++ b/packages/opencode/src/acp/profile.ts @@ -0,0 +1,42 @@ +const enabled = process.env.KILO_ACP_PROFILE === "1" +const started = performance.now() + +export function mark(name: string, fields?: Record) { + if (!enabled) return + write(`${name}.mark`, performance.now() - started, fields) +} + +export function duration( + name: string, + startedAt: number, + fields?: Record, +) { + if (!enabled) return + write(name, performance.now() - startedAt, fields) +} + +export async function measure( + name: string, + fn: () => Promise, + fields?: Record, +) { + if (!enabled) return fn() + const start = performance.now() + try { + return await fn() + } finally { + write(name, performance.now() - start, fields) + } +} + +function write(name: string, durationMs: number, fields?: Record) { + const extra = fields + ? Object.entries(fields) + .filter((entry): entry is [string, string | number | boolean] => entry[1] !== undefined) + .map(([key, value]) => `${key}=${value}`) + .join(" ") + : "" + console.error(`[acp-profile] ${name} ${Math.round(durationMs)}ms${extra ? ` ${extra}` : ""}`) +} + +export * as ACPProfile from "./profile" diff --git a/packages/opencode/src/acp/runtime.ts b/packages/opencode/src/acp/runtime.ts deleted file mode 100644 index b08c73cf2e..0000000000 --- a/packages/opencode/src/acp/runtime.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { Agent } from "@/agent/agent" -import { AppRuntime, type AppServices } from "@/effect/app-runtime" -import { InstanceRef } from "@/effect/instance-ref" -import { InstanceRuntime } from "@/project/instance-runtime" -import { Effect } from "effect" - -// Global ACP Effect re-entry: no project InstanceRef is provided. -export const runGlobal = AppRuntime.runPromise - -// Directory-scoped ACP Effect re-entry: load the project instance and provide InstanceRef. -export async function runDirectory(input: { directory: string; effect: Effect.Effect }) { - const ctx = await InstanceRuntime.load({ directory: input.directory }) - return AppRuntime.runPromise(input.effect.pipe(Effect.provideService(InstanceRef, ctx))) -} - -export const defaultAgentInfo = (directory: string) => - runDirectory({ - directory, - effect: Agent.Service.use((svc) => svc.defaultInfo()), - }) - -export * as ACPRuntime from "./runtime" diff --git a/packages/opencode/src/acp/service.ts b/packages/opencode/src/acp/service.ts new file mode 100644 index 0000000000..12cabd0f3a --- /dev/null +++ b/packages/opencode/src/acp/service.ts @@ -0,0 +1,1058 @@ +import { + type AgentSideConnection, + type AuthenticateRequest, + type AuthenticateResponse, + type AuthMethod, + type CancelNotification, + type CloseSessionRequest, + type CloseSessionResponse, + type ForkSessionRequest, + type ForkSessionResponse, + type InitializeRequest, + type InitializeResponse, + type ListSessionsRequest, + type ListSessionsResponse, + type LoadSessionRequest, + type LoadSessionResponse, + type McpServer, + type NewSessionRequest, + type NewSessionResponse, + type PromptRequest, + type PromptResponse, + type ResumeSessionRequest, + type ResumeSessionResponse, + type SessionInfo, + type SetSessionConfigOptionRequest, + type SetSessionConfigOptionResponse, + type SetSessionModelRequest, + type SetSessionModelResponse, + type SetSessionModeRequest, + type SetSessionModeResponse, +} from "@agentclientprotocol/sdk" +import { InstallationVersion } from "@opencode-ai/core/installation/version" +import * as Log from "@opencode-ai/core/util/log" +import type { Message, KiloClient, SessionMessageResponse } from "@kilocode/sdk/v2" +import { Context, Effect, Layer, ManagedRuntime } from "effect" +import * as ACPError from "./error" +import { buildConfigOptions, parseModelSelection } from "./config-option" +import { promptContentToParts } from "./content" +import { Directory } from "./directory" +import { ACPEvent } from "./event" +import { ACPSession } from "./session" +import { UsageService } from "./usage" +import { ACPProfile } from "./profile" +import { ModelID, ProviderID } from "@/provider/schema" +import { Provider } from "@/provider/provider" +import type { Command } from "@/command" + +export const AuthMethodID = "opencode-login" +const log = Log.create({ service: "acp-service" }) + +export type Error = ACPError.Error +type ServiceConnection = Pick & + Partial> + +export type Interface = { + readonly initialize: (input: InitializeRequest) => Effect.Effect + readonly authenticate: (input: AuthenticateRequest) => Effect.Effect + readonly newSession: (input: NewSessionRequest) => Effect.Effect + readonly loadSession: (input: LoadSessionRequest) => Effect.Effect + readonly listSessions: (input: ListSessionsRequest) => Effect.Effect + readonly resumeSession: (input: ResumeSessionRequest) => Effect.Effect + readonly closeSession: (input: CloseSessionRequest) => Effect.Effect + readonly forkSession: (input: ForkSessionRequest) => Effect.Effect + readonly setSessionConfigOption: ( + input: SetSessionConfigOptionRequest, + ) => Effect.Effect + readonly setSessionMode: (input: SetSessionModeRequest) => Effect.Effect + readonly setSessionModel: (input: SetSessionModelRequest) => Effect.Effect + readonly prompt: (input: PromptRequest) => Effect.Effect + readonly cancel: (input: CancelNotification) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/ACP/Service") {} + +export function make(input: { + sdk: KiloClient + connection?: ServiceConnection + directory?: Directory.Interface + session?: ACPSession.Interface + usage?: UsageService.Interface + eventSubscription?: (subscription: ACPEvent.Subscription) => void +}): Interface { + const session = input.session ?? makeSessionService() + const directoryService = input.directory ?? makeDirectoryService(input.sdk) + const registeredMcp = new Map>() + const sessionSnapshots = new Map() + const events = input.connection + ? ACPEvent.start({ sdk: input.sdk, connection: input.connection, session }) + : undefined + if (events) input.eventSubscription?.(events) + + const initialize = Effect.fn("ACP.initialize")(function* (params: InitializeRequest) { + const started = performance.now() + const authMethod: AuthMethod = { + description: "Run `opencode auth login` in the terminal", + name: "Login with opencode", + id: AuthMethodID, + } + + if (params.clientCapabilities?._meta?.["terminal-auth"] === true) { + authMethod._meta = { + "terminal-auth": { + command: "opencode", + args: ["auth", "login"], + label: "OpenCode Login", + }, + } + } + + const response = { + protocolVersion: 1, + agentCapabilities: { + loadSession: true, + mcpCapabilities: { + http: true, + sse: true, + }, + promptCapabilities: { + embeddedContext: true, + image: true, + }, + sessionCapabilities: { + close: {}, + fork: {}, + list: {}, + resume: {}, + }, + }, + authMethods: [authMethod], + agentInfo: { + name: "OpenCode", + version: InstallationVersion, + }, + } + ACPProfile.duration("acp.initialize", started) + return response + }) + + const authenticate = Effect.fn("ACP.authenticate")(function* (params: AuthenticateRequest) { + if (params.methodId !== AuthMethodID) { + return yield* new ACPError.UnknownAuthMethodError({ methodId: params.methodId }) + } + return {} + }) + + const directorySnapshot = Effect.fn("ACP.directorySnapshot")(function* (cwd: string) { + const started = performance.now() + const snapshot = yield* directoryService.get(cwd) + ACPProfile.duration("acp.directory.snapshot", started) + return snapshot + }) + + const configSnapshot = Effect.fn("ACP.configSnapshot")(function* (state: ACPSession.Info) { + const snapshot = sessionSnapshots.get(state.id) + if (snapshot) return snapshot + const loaded = yield* directorySnapshot(state.cwd) + sessionSnapshots.set(state.id, loaded) + return loaded + }) + + const newSession = Effect.fn("ACP.newSession")(function* (params: NewSessionRequest) { + const started = performance.now() + const snapshot = yield* directorySnapshot(params.cwd) + const selected = selectDefaultModel(snapshot) + const variant = selectVariant(snapshot, selected) + const modeId = snapshot.availableModes.length > 0 ? snapshot.defaultModeID : undefined + const created = yield* profiledRequest( + "acp.newSession.session.create", + () => + input.sdk.session.create( + { + directory: params.cwd, + ...(modeId ? { agent: modeId } : {}), + model: { + providerID: selected.providerID, + id: selected.modelID, + ...(variant ? { variant } : {}), + }, + }, + { throwOnError: true }, + ), + "session", + ) + const state = yield* session.create({ + id: created.id, + cwd: params.cwd, + mcpServers: params.mcpServers, + model: selected, + variant, + modeId, + }) + sessionSnapshots.set(state.id, snapshot) + + yield* registerMcpServers(input.sdk, registeredMcp, params.cwd, state.id, params.mcpServers) + yield* sendAvailableCommands(input.connection, state.id, snapshot) + + const response = { + sessionId: state.id, + configOptions: configOptions(snapshot, { + model: state.model ?? selected, + variant: state.variant, + modeId: state.modeId, + }), + } + ACPProfile.duration("acp.newSession", started) + return response + }) + + const loadSession = Effect.fn("ACP.loadSession")(function* (params: LoadSessionRequest) { + const snapshot = yield* directorySnapshot(params.cwd) + yield* request( + () => input.sdk.session.get({ directory: params.cwd, sessionID: params.sessionId }, { throwOnError: true }), + "session", + ) + const messages = yield* request( + () => + input.sdk.session.messages( + { directory: params.cwd, sessionID: params.sessionId, limit: 100 }, + { throwOnError: true }, + ), + "session", + ) + const restored = restoreFromMessages(messages.map((item) => item.info)) + const model = restored.model ?? selectDefaultModel(snapshot) + const state = yield* session.load({ + id: params.sessionId, + cwd: params.cwd, + mcpServers: params.mcpServers, + model, + variant: restored.variant ?? selectVariant(snapshot, model), + modeId: restored.modeId ?? (snapshot.availableModes.length > 0 ? snapshot.defaultModeID : undefined), + }) + sessionSnapshots.set(state.id, snapshot) + + yield* registerMcpServers(input.sdk, registeredMcp, params.cwd, state.id, params.mcpServers) + yield* sendAvailableCommands(input.connection, state.id, snapshot) + yield* replayMessages(events, messages) + + return { + configOptions: configOptions(snapshot, { + model: state.model ?? model, + variant: state.variant, + modeId: state.modeId, + }), + } + }) + + const listSessions = Effect.fn("ACP.listSessions")(function* (params: ListSessionsRequest) { + const cursor = params.cursor ? Number(params.cursor) : undefined + const limit = 100 + const sessions = yield* request( + () => + input.sdk.session.list( + { + ...(params.cwd ? { directory: params.cwd } : {}), + roots: true, + }, + { throwOnError: true }, + ), + "session", + ) + const serverEntries = sessions.map( + (item): SessionInfo => ({ + sessionId: item.id, + cwd: item.directory, + title: item.title, + updatedAt: new Date(item.time.updated).toISOString(), + }), + ) + const liveEntries = (yield* session.list(params.cwd ?? undefined)) + .filter((item) => !serverEntries.some((entry) => entry.sessionId === item.id)) + .map( + (item): SessionInfo => ({ + sessionId: item.id, + cwd: item.cwd, + updatedAt: item.createdAt.toISOString(), + }), + ) + const sorted = [...liveEntries, ...serverEntries].toSorted( + (a, b) => new Date(b.updatedAt ?? 0).getTime() - new Date(a.updatedAt ?? 0).getTime(), + ) + const filtered = + cursor === undefined || !Number.isFinite(cursor) + ? sorted + : sorted.filter((item) => new Date(item.updatedAt ?? 0).getTime() < cursor) + const page = filtered.slice(0, limit) + const last = page.at(-1) + return { + sessions: page, + ...(filtered.length > limit && last ? { nextCursor: String(new Date(last.updatedAt ?? 0).getTime()) } : {}), + } + }) + + const resumeSession = Effect.fn("ACP.resumeSession")(function* (params: ResumeSessionRequest) { + const snapshot = yield* directorySnapshot(params.cwd) + yield* request( + () => input.sdk.session.get({ directory: params.cwd, sessionID: params.sessionId }, { throwOnError: true }), + "session", + ) + const messages = yield* request( + () => + input.sdk.session.messages( + { directory: params.cwd, sessionID: params.sessionId, limit: 20 }, + { throwOnError: true }, + ), + "session", + ) + const restored = restoreFromMessages(messages.map((item) => item.info)) + const model = restored.model ?? selectDefaultModel(snapshot) + const state = yield* session.load({ + id: params.sessionId, + cwd: params.cwd, + mcpServers: params.mcpServers ?? [], + model, + variant: restored.variant ?? selectVariant(snapshot, model), + modeId: restored.modeId ?? (snapshot.availableModes.length > 0 ? snapshot.defaultModeID : undefined), + }) + sessionSnapshots.set(state.id, snapshot) + + yield* registerMcpServers(input.sdk, registeredMcp, params.cwd, state.id, params.mcpServers ?? []) + yield* sendAvailableCommands(input.connection, state.id, snapshot) + yield* replayMessages(events, messages) + + return { + configOptions: configOptions(snapshot, { + model: state.model ?? model, + variant: state.variant, + modeId: state.modeId, + }), + } + }) + + const closeSession = Effect.fn("ACP.closeSession")(function* (params: CloseSessionRequest) { + const removed = yield* session.remove(params.sessionId) + registeredMcp.delete(params.sessionId) + sessionSnapshots.delete(params.sessionId) + if (!removed) return {} + + yield* request( + () => input.sdk.session.abort({ directory: removed.cwd, sessionID: params.sessionId }, { throwOnError: true }), + "session", + ).pipe( + Effect.catch((error) => + Effect.sync(() => { + log.error("failed to abort session while closing ACP session", { error, sessionID: params.sessionId }) + }), + ), + ) + return {} + }) + + const forkSession = Effect.fn("ACP.forkSession")(function* (params: ForkSessionRequest) { + const snapshot = yield* directorySnapshot(params.cwd) + const forked = yield* request( + () => + input.sdk.session.fork( + { + directory: params.cwd, + sessionID: params.sessionId, + }, + { throwOnError: true }, + ), + "session", + ) + const messages = yield* request( + () => + input.sdk.session.messages({ directory: params.cwd, sessionID: forked.id, limit: 20 }, { throwOnError: true }), + "session", + ) + const restored = restoreFromMessages(messages.map((item) => item.info)) + const model = restored.model ?? selectDefaultModel(snapshot) + const state = yield* session.load({ + id: forked.id, + cwd: params.cwd, + mcpServers: params.mcpServers ?? [], + model, + variant: restored.variant ?? selectVariant(snapshot, model), + modeId: restored.modeId ?? (snapshot.availableModes.length > 0 ? snapshot.defaultModeID : undefined), + }) + sessionSnapshots.set(state.id, snapshot) + + yield* registerMcpServers(input.sdk, registeredMcp, params.cwd, state.id, params.mcpServers ?? []) + yield* sendAvailableCommands(input.connection, state.id, snapshot) + yield* replayMessages(events, messages) + + return { + sessionId: state.id, + configOptions: configOptions(snapshot, { + model: state.model ?? model, + variant: state.variant, + modeId: state.modeId, + }), + } + }) + + const setSessionConfigOption = Effect.fn("ACP.setSessionConfigOption")(function* ( + params: SetSessionConfigOptionRequest, + ) { + const current = yield* session.get(params.sessionId) + const snapshot = yield* configSnapshot(current) + if (typeof params.value !== "string") { + return yield* new ACPError.InvalidConfigOptionError({ configId: params.configId }) + } + + if (params.configId === "model") { + const selected = yield* parseSelectedModel(snapshot, params.value) + const variant = selected.variant ?? selectVariant(snapshot, selected.model) + const state = yield* session + .setVariant(params.sessionId, Directory.variants(snapshot, selected.model) ? variant : undefined) + .pipe(Effect.andThen(session.setModel(params.sessionId, selected.model))) + return { + configOptions: configOptions(snapshot, { + model: state.model ?? selected.model, + variant: state.variant, + modeId: state.modeId, + }), + } + } + + if (params.configId === "effort") { + const model = current.model ?? selectDefaultModel(snapshot) + const variants = Directory.variants(snapshot, model) + if (!variants || !Object.keys(variants).includes(params.value)) { + return yield* new ACPError.InvalidEffortError({ effort: params.value }) + } + const state = yield* session.setVariant(params.sessionId, params.value) + return { + configOptions: configOptions(snapshot, { + model: state.model ?? model, + variant: state.variant, + modeId: state.modeId, + }), + } + } + + if (params.configId === "mode") { + if (!snapshot.availableModes.some((mode) => mode.id === params.value)) { + return yield* new ACPError.InvalidModeError({ mode: params.value }) + } + const state = yield* session.setMode(params.sessionId, params.value) + return { + configOptions: configOptions(snapshot, { + model: state.model ?? selectDefaultModel(snapshot), + variant: state.variant, + modeId: state.modeId, + }), + } + } + + return yield* new ACPError.InvalidConfigOptionError({ configId: params.configId }) + }) + + const setSessionMode = Effect.fn("ACP.setSessionMode")(function* (params: SetSessionModeRequest) { + const current = yield* session.get(params.sessionId) + const snapshot = yield* configSnapshot(current) + if (!snapshot.availableModes.some((mode) => mode.id === params.modeId)) { + return yield* new ACPError.InvalidModeError({ mode: params.modeId }) + } + yield* session.setMode(params.sessionId, params.modeId) + return {} + }) + + const setSessionModel = Effect.fn("ACP.setSessionModel")(function* (params: SetSessionModelRequest) { + const current = yield* session.get(params.sessionId) + const snapshot = yield* configSnapshot(current) + const selected = yield* parseSelectedModel(snapshot, params.modelId) + yield* session + .setVariant( + params.sessionId, + Directory.variants(snapshot, selected.model) + ? (selected.variant ?? selectVariant(snapshot, selected.model)) + : undefined, + ) + .pipe(Effect.andThen(session.setModel(params.sessionId, selected.model))) + return {} + }) + + return { + initialize, + authenticate, + newSession, + loadSession, + listSessions, + resumeSession, + closeSession, + forkSession, + setSessionConfigOption, + setSessionMode, + setSessionModel, + prompt: Effect.fn("ACP.prompt")(function* (params: PromptRequest) { + const current = yield* session.get(params.sessionId) + const snapshot = yield* directorySnapshot(current.cwd) + const selected = current.model ?? selectDefaultModel(snapshot) + if (!current.model) { + yield* session.setModel(params.sessionId, selected) + } + const variant = current.variant ?? selectVariant(snapshot, selected) + const modeId = current.modeId ?? (snapshot.availableModes.length > 0 ? snapshot.defaultModeID : undefined) + const parts = promptContentToParts(params.prompt) + const command = detectSlashCommand(parts) + + if (!command) { + const response = yield* request( + () => + input.sdk.session.prompt( + { + sessionID: current.id, + model: { + providerID: selected.providerID, + modelID: selected.modelID, + }, + ...(variant ? { variant } : {}), + parts, + ...(modeId ? { agent: modeId } : {}), + directory: current.cwd, + }, + { throwOnError: true }, + ), + "session", + ) + yield* sendUsageUpdate(input.usage, input.sdk, input.connection, current.id, current.cwd) + return promptResponse(response.info, params.messageId) + } + + const known = snapshot.availableCommands.find((item) => item.name === command.name) + if (known) { + const response = yield* request( + () => + input.sdk.session.command( + { + sessionID: current.id, + command: known.name, + arguments: command.args, + model: `${selected.providerID}/${selected.modelID}`, + ...(variant ? { variant } : {}), + ...(modeId ? { agent: modeId } : {}), + directory: current.cwd, + }, + { throwOnError: true }, + ), + "session", + ) + yield* sendUsageUpdate(input.usage, input.sdk, input.connection, current.id, current.cwd) + return promptResponse(response.info, params.messageId) + } + + if (command.name === "compact") { + yield* request( + () => + input.sdk.session.summarize( + { + sessionID: current.id, + directory: current.cwd, + providerID: selected.providerID, + modelID: selected.modelID, + }, + { throwOnError: true }, + ), + "session", + ) + } + + 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" }) + }), + } +} + +function makeSessionService() { + return ManagedRuntime.make(ACPSession.defaultLayer).runSync( + ACPSession.Service.use((service) => Effect.succeed(service)), + ) +} + +function makeDirectoryService(sdk: KiloClient) { + return ManagedRuntime.make( + Directory.layer.pipe( + Layer.provide( + Layer.succeed( + Directory.Loader, + Directory.Loader.of({ + load: (directory) => request(() => loadDirectorySnapshot(sdk, directory), "directory"), + }), + ), + ), + ), + ).runSync(Directory.Service.use((service) => Effect.succeed(service))) +} + +function makeUsageService(sdk: KiloClient) { + const limits = new Map>() + const contextLimit: UsageService.Interface["contextLimit"] = Effect.fn("ACP.promptUsage.contextLimit")( + function* (params) { + const key = `${params.directory}\u0000${params.providerID}\u0000${params.modelID}` + const current = limits.get(key) + if (current) return yield* Effect.promise(() => current) + + const next = sdk.config + .providers({ directory: params.directory }, { throwOnError: true }) + .then((response) => { + const providers = Object.fromEntries( + (response.data?.providers ?? []).map((provider) => [provider.id, provider]), + ) as Record + return UsageService.findContextLimit(providers, params.providerID, params.modelID) + }) + .catch((error: unknown) => { + log.error("failed to get providers for usage context limit", { error }) + return undefined + }) + limits.set(key, next) + return yield* Effect.promise(() => next) + }, + ) + + const sendUpdate: UsageService.Interface["sendUpdate"] = Effect.fn("ACP.promptUsage.sendUpdate")(function* (params) { + const messages = yield* request( + () => + sdk.session.messages( + { + sessionID: params.sessionID, + directory: params.directory, + }, + { throwOnError: true }, + ), + "session", + ).pipe( + Effect.map((messages) => messages as readonly UsageService.SessionMessage[]), + Effect.catch((error) => + Effect.sync(() => { + log.error("failed to fetch messages for usage update", { error }) + return undefined + }), + ), + ) + if (!messages) return + + const message = UsageService.latestAssistantMessage(messages) + if (!message?.providerID || !message.modelID) return + + const size = yield* contextLimit({ + directory: params.directory, + providerID: ProviderID.make(message.providerID), + modelID: ModelID.make(message.modelID), + }) + if (!size) return + + yield* Effect.promise(() => + params.connection + .sessionUpdate({ + sessionId: params.sessionID, + update: { + sessionUpdate: "usage_update", + used: message.tokens.input + message.tokens.cache.read, + size, + cost: { amount: UsageService.totalSessionCost(messages), currency: "USD" }, + }, + }) + .catch((error) => { + log.error("failed to send usage update", { error }) + }), + ) + }) + + return UsageService.Service.of({ + buildUsage: UsageService.buildUsage, + latestAssistantMessage: UsageService.latestAssistantMessage, + totalSessionCost: UsageService.totalSessionCost, + contextLimit, + sendUpdate, + }) +} + +function replayMessages(subscription: ACPEvent.Subscription | undefined, messages: SessionMessageResponse[]) { + if (!subscription) return Effect.void + return Effect.promise(async () => { + for (const message of messages) { + await subscription.replayMessage(message).catch((error: unknown) => { + log.error("failed to replay ACP message", { error, messageID: message.info.id }) + }) + } + }) +} + +type ConfigState = { + readonly model: Directory.DefaultModel + readonly variant?: string + readonly modeId?: string +} + +type SdkResponse = { + readonly data?: T + readonly error?: unknown +} + +type MessageInfo = { + readonly role?: Message["role"] + readonly model?: Extract["model"] + readonly providerID?: Extract["providerID"] + readonly modelID?: Extract["modelID"] + readonly variant?: Extract["variant"] + readonly mode?: Extract["mode"] + readonly agent?: Message["agent"] +} + +type AssistantInfo = UsageService.AssistantTokenCost | undefined + +function request(fn: () => Promise>, service?: string) { + return Effect.tryPromise({ + try: async () => { + const result = await fn() + if (isSdkResponse(result)) { + if (result.error) throw result.error + if (result.data !== undefined) return result.data + } + return result as T + }, + catch: (error) => fromUnknownError(error, service), + }) +} + +function profiledRequest(name: string, fn: () => Promise>, service?: string) { + return request(() => ACPProfile.measure(name, fn), service) +} + +async function loadDirectorySnapshot(sdk: KiloClient, directory: string) { + return ACPProfile.measure("acp.directory.load", async () => { + const [providersResponse, agentsResponse, commandsResponse, skillsResponse, configResponse] = await Promise.all([ + ACPProfile.measure("acp.directory.provider.list", () => + sdk.config.providers({ directory }, { throwOnError: true }), + ), + ACPProfile.measure("acp.directory.mode.defaultAgent.load", () => + sdk.app.agents({ directory }, { throwOnError: true }), + ), + ACPProfile.measure("acp.directory.command.list", () => sdk.command.list({ directory }, { throwOnError: true })), + ACPProfile.measure("acp.directory.skill.list", () => sdk.app.skills({ directory }, { throwOnError: true })), + ACPProfile.measure("acp.directory.defaultModel.config", () => + sdk.config.get({ directory }, { throwOnError: true }).catch(() => undefined), + ), + ]) + const providersData = providersResponse.data! + const agents = agentsResponse.data! + const commandsData = commandsResponse.data! + const skills = skillsResponse.data! + const providers = Object.fromEntries(providersData.providers.map((provider) => [provider.id, provider])) as Record< + ProviderID, + Provider.Info + > + const defaultModelStarted = performance.now() + const defaultModel = defaultModelFromConfig(configResponse?.data?.model, providers) + ACPProfile.duration("acp.directory.defaultModel.resolve", defaultModelStarted, { configured: !!defaultModel }) + const modes = agents + .filter((agent) => agent.mode !== "subagent" && agent.hidden !== true) + .map((agent) => ({ + id: agent.name, + name: agent.name, + ...(agent.description ? { description: agent.description } : {}), + })) + const commands = [ + ...commandsData, + ...skills + .filter((skill) => !commandsData.some((command) => command.name === skill.name)) + .map((skill) => ({ + name: skill.name, + description: skill.description, + source: "skill" as const, + template: skill.content, + hints: [], + })), + ] as Command.Info[] + + return Directory.build({ + directory, + providers, + modes, + defaultModeID: agents.find((agent) => agent.mode === "primary" && agent.hidden !== true)?.name ?? "build", + commands: commands.toSorted((a, b) => a.name.localeCompare(b.name)), + ...(defaultModel ? { defaultModel } : {}), + }) + }) +} + +function defaultModelFromConfig( + configuredModel: string | undefined, + providers: Record, +): Directory.DefaultModel | undefined { + const configured = configuredModel ? Provider.parseModel(configuredModel) : undefined + if (configured && providers[configured.providerID]?.models[configured.modelID]) return configured + + // 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 opencodeModel = opencodeProvider ? Provider.sort(Object.values(opencodeProvider.models))[0] : undefined + if (opencodeProvider && opencodeModel) return { providerID: opencodeProvider.id, modelID: opencodeModel.id } + + const best = Provider.sort(Object.values(providers).flatMap((provider) => Object.values(provider.models)))[0] + if (best) return { providerID: best.providerID, modelID: best.id } + if (configured) return configured +} + +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 } +} + +function detectSlashCommand(parts: ReturnType) { + const text = parts + .filter((part): part is Extract<(typeof parts)[number], { type: "text" }> => part.type === "text") + .map((part) => part.text) + .join("") + .trim() + if (!text.startsWith("/")) return + + const [name, ...rest] = text.slice(1).split(/\s+/) + if (!name) return + return { name, args: rest.join(" ").trim() } +} + +function promptResponse(info: AssistantInfo, messageId: string | null | undefined): PromptResponse { + return { + stopReason: "end_turn", + ...(info ? { usage: UsageService.buildUsage(info) } : {}), + ...(messageId ? { userMessageId: messageId } : {}), + _meta: {}, + } +} + +function sendUsageUpdate( + usage: UsageService.Interface | undefined, + sdk: KiloClient, + connection: ServiceConnection | undefined, + sessionID: string, + directory: string, +) { + if (!connection) return Effect.void + return (usage ?? makeUsageService(sdk)).sendUpdate({ + connection, + sessionID, + directory, + }) +} + +function selectVariant(snapshot: Directory.Snapshot, model: Directory.DefaultModel) { + const variants = Directory.variants(snapshot, model) + if (!variants) return + if (variants.default) return "default" + return Object.keys(variants)[0] +} + +function configOptions(snapshot: Directory.Snapshot, session: ConfigState) { + return buildConfigOptions({ + providers: Object.values(snapshot.providers), + currentModel: session.model, + currentVariant: session.variant, + modes: snapshot.availableModes, + currentModeId: session.modeId, + }) +} + +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)] + if (!model) { + return Effect.fail( + new ACPError.InvalidModelError({ + providerId: selected.model.providerID, + modelId, + }), + ) + } + if (selected.variant && !model.variants?.[selected.variant]) { + return Effect.fail(new ACPError.InvalidEffortError({ effort: selected.variant })) + } + return Effect.succeed({ + model: { + providerID: provider.id, + modelID: model.id, + }, + variant: selected.variant, + }) +} + +function sendAvailableCommands( + connection: Pick | undefined, + sessionId: string, + snapshot: Directory.Snapshot, +) { + if (!connection) return Effect.void + return Effect.sync(() => { + setTimeout(() => { + void connection.sessionUpdate({ + sessionId, + update: { + sessionUpdate: "available_commands_update", + availableCommands: snapshot.availableCommands.map((command) => ({ + name: command.name, + description: command.description ?? "", + })), + }, + }) + }, 0) + }) +} + +function registerMcpServers( + sdk: KiloClient, + registered: Map>, + directory: string, + sessionId: string, + servers: readonly McpServer[], +) { + const started = performance.now() + const current = registered.get(sessionId) ?? new Set() + registered.set(sessionId, current) + const pending = new Set() + + return Effect.all( + servers + .map((server) => ({ server, config: mcpConfig(server) })) + .filter((entry) => { + const key = mcpRegistrationKey(entry.server.name, entry.config) + if (current.has(key) || pending.has(key)) return false + pending.add(key) + return true + }) + .map((entry) => + request( + () => + sdk.mcp.add( + { + directory, + name: entry.server.name, + config: entry.config, + }, + { throwOnError: true }, + ), + "mcp", + ).pipe( + Effect.tap(() => Effect.sync(() => current.add(mcpRegistrationKey(entry.server.name, entry.config)))), + Effect.ignore, + ), + ), + { concurrency: "unbounded" }, + ).pipe( + Effect.tap(() => + Effect.sync(() => + ACPProfile.duration("acp.mcp.register", started, { + count: pending.size, + }), + ), + ), + Effect.asVoid, + ) +} + +function mcpRegistrationKey(name: string, config: ReturnType) { + return `${name}:${stableStringify(config)}` +} + +function mcpConfig(server: McpServer) { + if ("type" in server) { + return { + type: "remote" as const, + url: server.url, + headers: Object.fromEntries(server.headers.map((header) => [header.name, header.value])), + } + } + return { + type: "local" as const, + command: [server.command, ...server.args], + environment: Object.fromEntries(server.env.map((entry) => [entry.name, entry.value])), + } +} + +function stableStringify(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]` + if (!value || typeof value !== "object") return JSON.stringify(value) + return `{${Object.entries(value) + .toSorted(([a], [b]) => a.localeCompare(b)) + .map(([key, item]) => `${JSON.stringify(key)}:${stableStringify(item)}`) + .join(",")}}` +} + +function restoreFromMessages(messages: readonly MessageInfo[]) { + const user = messages.findLast( + (message) => message.role === "user" && message.model?.providerID && message.model.modelID, + ) + if (user?.model?.providerID && user.model.modelID) { + return { + model: { providerID: user.model.providerID as ProviderID, modelID: user.model.modelID as ModelID }, + variant: user.model.variant, + modeId: user.agent, + } + } + + 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 }, + variant: assistant.variant, + modeId: assistant.mode ?? assistant.agent, + } + } + + return {} +} + +function isSdkResponse(value: T | SdkResponse): value is SdkResponse { + return typeof value === "object" && value !== null && ("data" in value || "error" in value) +} + +function fromUnknownError(error: unknown, service?: string): Error { + if (isACPError(error)) return error + if (isAuthRequired(error)) { + return new ACPError.AuthRequiredError({ providerId: findProviderID(error) }) + } + return new ACPError.ServiceFailureError({ safeMessage: "OpenCode service failure", service }) +} + +function isACPError(error: unknown): error is Error { + return ( + typeof error === "object" && + error !== null && + "_tag" in error && + typeof error._tag === "string" && + error._tag.startsWith("ACP") + ) +} + +function isAuthRequired(value: unknown): boolean { + if (typeof value !== "object" || value === null) return false + if (value instanceof Error && (value.name === "ProviderAuthError" || value.name === "LoadAPIKeyError")) return true + if ( + value instanceof Error && + (value.message.includes("ProviderAuthError") || value.message.includes("LoadAPIKeyError")) + ) { + return true + } + if ("name" in value && (value.name === "ProviderAuthError" || value.name === "LoadAPIKeyError")) return true + if ("_tag" in value && (value._tag === "ProviderAuthError" || value._tag === "LoadAPIKeyError")) return true + if ("error" in value && isAuthRequired(value.error)) return true + if ("data" in value && isAuthRequired(value.data)) return true + return false +} + +function findProviderID(value: unknown): string | undefined { + if (typeof value !== "object" || value === null) return + if ("providerID" in value && typeof value.providerID === "string") return value.providerID + if ("providerId" in value && typeof value.providerId === "string") return value.providerId + if ("data" in value) return findProviderID(value.data) + if ("error" in value) return findProviderID(value.error) +} diff --git a/packages/opencode/src/acp/session.ts b/packages/opencode/src/acp/session.ts index 5ccbc4d84f..4aced85949 100644 --- a/packages/opencode/src/acp/session.ts +++ b/packages/opencode/src/acp/session.ts @@ -1,122 +1,230 @@ -import { RequestError, type McpServer } from "@agentclientprotocol/sdk" -import type { ACPSessionState } from "./types" -import * as Log from "@opencode-ai/core/util/log" -import type { KiloClient } from "@kilocode/sdk/v2" +import type { McpServer } from "@agentclientprotocol/sdk" +import type { Message, Part } from "@kilocode/sdk/v2" +import { Context, Effect, Layer, Ref } from "effect" +import type { ModelID, ProviderID } from "../provider/schema" +import * as ACPError from "./error" -const log = Log.create({ service: "acp-session-manager" }) +export type SelectedModel = { + providerID: ProviderID + modelID: ModelID +} -export class ACPSessionManager { - private sessions = new Map() - private sdk: KiloClient +export type KnownMessagePartMetadata = { + messageId: string + partId: string + partType?: Part["type"] + role?: Message["role"] + ignored?: boolean + toolCallId?: string + metadata?: unknown +} - constructor(sdk: KiloClient) { - this.sdk = sdk - } +export type Info = { + id: string + cwd: string + mcpServers: readonly McpServer[] + createdAt: Date + model?: SelectedModel + variant?: string + modeId?: string + knownParts: ReadonlyMap +} - tryGet(sessionId: string): ACPSessionState | undefined { - return this.sessions.get(sessionId) - } +export type StoreInput = { + id: string + cwd: string + mcpServers?: readonly McpServer[] + createdAt?: Date + model?: SelectedModel + variant?: string + modeId?: string +} - async create(cwd: string, mcpServers: McpServer[], model?: ACPSessionState["model"]): Promise { - const session = await this.sdk.session - .create( - { - directory: cwd, - }, - { throwOnError: true }, - ) - .then((x) => x.data!) +export type RecordPartMetadataInput = { + sessionId: string + messageId: string + partId: string + partType?: Part["type"] + role?: Message["role"] + ignored?: boolean + toolCallId?: string + metadata?: unknown +} - const sessionId = session.id - const resolvedModel = model +export type PartMetadataLookupInput = { + sessionId: string + messageId: string + partId: string +} - const state: ACPSessionState = { - id: sessionId, - cwd, - mcpServers, - createdAt: new Date(), - model: resolvedModel, - } - log.info("creating_session", { state }) - - this.sessions.set(sessionId, state) - return state - } - - async load( +export type Interface = { + readonly create: (input: StoreInput) => Effect.Effect + readonly load: (input: StoreInput) => Effect.Effect + readonly list: (cwd?: string) => Effect.Effect + readonly get: (sessionId: string) => Effect.Effect + readonly tryGet: (sessionId: string) => Effect.Effect + readonly remove: (sessionId: string) => Effect.Effect + readonly setModel: ( sessionId: string, - cwd: string, - mcpServers: McpServer[], - model?: ACPSessionState["model"], - ): Promise { - const session = await this.sdk.session - .get( - { - sessionID: sessionId, - directory: cwd, - }, - { throwOnError: true }, - ) - .then((x) => x.data!) + model: SelectedModel | undefined, + ) => Effect.Effect + readonly getModel: (sessionId: string) => Effect.Effect + readonly setVariant: ( + sessionId: string, + variant: string | undefined, + ) => Effect.Effect + readonly getVariant: (sessionId: string) => Effect.Effect + readonly setMode: ( + sessionId: string, + modeId: string | undefined, + ) => Effect.Effect + readonly getMode: (sessionId: string) => Effect.Effect + readonly recordPartMetadata: ( + input: RecordPartMetadataInput, + ) => Effect.Effect + readonly getPartMetadata: ( + input: PartMetadataLookupInput, + ) => Effect.Effect + readonly tryGetPartMetadata: (input: PartMetadataLookupInput) => Effect.Effect +} - const resolvedModel = model +export class Service extends Context.Service()("@opencode/ACP/Session") {} - const state: ACPSessionState = { - id: sessionId, - cwd, - mcpServers, - createdAt: new Date(session.time.created), - model: resolvedModel, - } - log.info("loading_session", { state }) +type State = Map - this.sessions.set(sessionId, state) - return state - } +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const sessions = yield* Ref.make(new Map()) - get(sessionId: string): ACPSessionState { - const session = this.sessions.get(sessionId) - if (!session) { - log.error("session not found", { sessionId }) - throw RequestError.invalidParams(JSON.stringify({ error: `Session not found: ${sessionId}` })) - } - return session - } + const store = Effect.fn("ACP.Session.store")(function* (input: StoreInput) { + const session = makeSession(input) + yield* Ref.update(sessions, (state) => new Map(state).set(session.id, session)) + return snapshot(session) + }) - getModel(sessionId: string) { - const session = this.get(sessionId) - return session.model - } + const tryGet = Effect.fn("ACP.Session.tryGet")(function* (sessionId: string) { + const session = (yield* Ref.get(sessions)).get(sessionId) + if (!session) return + return snapshot(session) + }) - setModel(sessionId: string, model: ACPSessionState["model"]) { - const session = this.get(sessionId) - session.model = model - this.sessions.set(sessionId, session) - return session - } + const get = Effect.fn("ACP.Session.get")(function* (sessionId: string) { + const session = yield* tryGet(sessionId) + if (session) return session + return yield* new ACPError.SessionNotFoundError({ sessionId }) + }) - getVariant(sessionId: string) { - const session = this.get(sessionId) - return session.variant - } + const update = Effect.fn("ACP.Session.update")(function* (sessionId: string, fn: (session: Info) => Info) { + const result = yield* Ref.modify(sessions, (state) => { + const session = state.get(sessionId) + if (!session) return [undefined, state] as const + const next = fn(session) + return [snapshot(next), new Map(state).set(sessionId, next)] as const + }) + if (result) return result + return yield* new ACPError.SessionNotFoundError({ sessionId }) + }) - setVariant(sessionId: string, variant?: string) { - const session = this.get(sessionId) - session.variant = variant - this.sessions.set(sessionId, session) - return session - } + const remove = Effect.fn("ACP.Session.remove")(function* (sessionId: string) { + return yield* Ref.modify(sessions, (state) => { + const session = state.get(sessionId) + if (!session) return [undefined, state] as const + const next = new Map(state) + next.delete(sessionId) + return [snapshot(session), next] as const + }) + }) - setMode(sessionId: string, modeId: string) { - const session = this.get(sessionId) - session.modeId = modeId - this.sessions.set(sessionId, session) - return session - } + const setModel: Interface["setModel"] = Effect.fn("ACP.Session.setModel")((sessionId, model) => + update(sessionId, (session) => ({ ...session, model })), + ) - remove(sessionId: string): ACPSessionState | undefined { - const session = this.sessions.get(sessionId) - this.sessions.delete(sessionId) - return session + const setVariant: Interface["setVariant"] = Effect.fn("ACP.Session.setVariant")((sessionId, variant) => + update(sessionId, (session) => ({ ...session, variant })), + ) + + const setMode: Interface["setMode"] = Effect.fn("ACP.Session.setMode")((sessionId, modeId) => + update(sessionId, (session) => ({ ...session, modeId })), + ) + + const recordPartMetadata: Interface["recordPartMetadata"] = Effect.fn("ACP.Session.recordPartMetadata")((input) => { + const metadata = { + messageId: input.messageId, + partId: input.partId, + partType: input.partType, + role: input.role, + ignored: input.ignored, + toolCallId: input.toolCallId, + metadata: input.metadata, + } + return update(input.sessionId, (session) => ({ + ...session, + knownParts: new Map(session.knownParts).set(partMetadataKey(input), metadata), + })).pipe(Effect.as(metadata)) + }) + + return Service.of({ + create: store, + load: store, + list: Effect.fn("ACP.Session.list")(function* (cwd?: string) { + return [...(yield* Ref.get(sessions)).values()] + .filter((session) => !cwd || session.cwd === cwd) + .map(snapshot) + .toSorted((a, b) => b.createdAt.getTime() - a.createdAt.getTime()) + }), + get, + tryGet, + remove, + setModel, + getModel: Effect.fn("ACP.Session.getModel")(function* (sessionId) { + return (yield* get(sessionId)).model + }), + setVariant, + getVariant: Effect.fn("ACP.Session.getVariant")(function* (sessionId) { + return (yield* get(sessionId)).variant + }), + setMode, + getMode: Effect.fn("ACP.Session.getMode")(function* (sessionId) { + return (yield* get(sessionId)).modeId + }), + recordPartMetadata, + getPartMetadata: Effect.fn("ACP.Session.getPartMetadata")(function* (input) { + return (yield* get(input.sessionId)).knownParts.get(partMetadataKey(input)) + }), + tryGetPartMetadata: Effect.fn("ACP.Session.tryGetPartMetadata")(function* (input) { + return (yield* tryGet(input.sessionId))?.knownParts.get(partMetadataKey(input)) + }), + }) + }), +) + +export const defaultLayer = layer + +function makeSession(input: StoreInput): Info { + return { + id: input.id, + cwd: input.cwd, + mcpServers: [...(input.mcpServers ?? [])], + createdAt: input.createdAt ? new Date(input.createdAt) : new Date(), + model: input.model, + variant: input.variant, + modeId: input.modeId, + knownParts: new Map(), } } + +function snapshot(session: Info): Info { + return { + ...session, + mcpServers: [...session.mcpServers], + createdAt: new Date(session.createdAt), + knownParts: new Map(session.knownParts), + } +} + +function partMetadataKey(input: { messageId: string; partId: string }) { + return `${input.messageId}:${input.partId}` +} + +export * as ACPSession from "./session" diff --git a/packages/opencode/src/acp/tool.ts b/packages/opencode/src/acp/tool.ts new file mode 100644 index 0000000000..08cf7ff845 --- /dev/null +++ b/packages/opencode/src/acp/tool.ts @@ -0,0 +1,290 @@ +import type { ToolCall, ToolCallContent, ToolCallLocation, ToolCallUpdate, ToolKind } from "@agentclientprotocol/sdk" + +export type ToolInput = Record + +export type ToolAttachment = { + readonly mime?: string + readonly url?: string + readonly [key: string]: unknown +} + +export type CompletedToolState = { + readonly status: "completed" + readonly input: ToolInput + readonly output: string + readonly metadata?: unknown + readonly attachments?: ReadonlyArray +} + +export type RunningToolState = { + readonly status: "running" + readonly input: ToolInput + readonly title?: string +} + +export type ErrorToolState = { + readonly status: "error" + readonly input: ToolInput + readonly error: string + readonly metadata?: unknown +} + +export type ImageAttachment = { + readonly mimeType: string + readonly data: string +} + +export function toToolKind(toolName: string): ToolKind { + const tool = toolName.toLocaleLowerCase() + + switch (tool) { + case "bash": + case "shell": + return "execute" + + case "webfetch": + return "fetch" + + case "edit": + case "patch": + case "write": + return "edit" + + case "grep": + case "glob": + case "repo_clone": + case "repo_overview": + case "context": + case "context7_resolve_library_id": + case "context7_get_library_docs": + return "search" + + case "read": + return "read" + + default: + return "other" + } +} + +export function toLocations(toolName: string, input: ToolInput): ToolCallLocation[] { + const tool = toolName.toLocaleLowerCase() + + switch (tool) { + case "read": + case "edit": + case "write": + return locationFrom(input.filePath ?? input.filepath) + + case "grep": + case "glob": + case "repo_clone": + case "repo_overview": + case "context": + case "context7_resolve_library_id": + case "context7_get_library_docs": + return locationFrom(input.path) + + case "bash": + case "shell": + return [] + + default: + return [] + } +} + +export function completedToolContent(toolName: string, state: CompletedToolState): ToolCallContent[] { + const content: ToolCallContent[] = [ + { + type: "content", + content: { + type: "text", + text: state.output, + }, + }, + ] + + if (toToolKind(toolName) === "edit") { + content.push(...diffContent(state.input)) + } + + content.push(...imageContents(state.attachments ?? [])) + return content +} + +export function pendingToolCall(input: { readonly toolCallId: string; readonly toolName: string }): ToolCall { + return { + toolCallId: input.toolCallId, + title: input.toolName, + kind: toToolKind(input.toolName), + status: "pending", + locations: [], + rawInput: {}, + } +} + +export function runningToolUpdate(input: { + readonly toolCallId: string + readonly toolName: string + readonly state: RunningToolState + readonly output?: string +}): ToolCallUpdate { + const content = input.output + ? [ + { + type: "content" as const, + content: { + type: "text" as const, + text: input.output, + }, + }, + ] + : undefined + + return { + toolCallId: input.toolCallId, + status: "in_progress", + kind: toToolKind(input.toolName), + title: input.state.title ?? input.toolName, + locations: toLocations(input.toolName, input.state.input), + rawInput: input.state.input, + ...(content ? { content } : {}), + } +} + +export function duplicateRunningToolUpdate(input: { + readonly toolCallId: string + readonly toolName: string + readonly state: RunningToolState +}): ToolCallUpdate { + return { + toolCallId: input.toolCallId, + status: "in_progress", + kind: toToolKind(input.toolName), + title: input.state.title ?? input.toolName, + locations: toLocations(input.toolName, input.state.input), + rawInput: input.state.input, + } +} + +export function completedToolUpdate(input: { + readonly toolCallId: string + readonly toolName: string + readonly state: CompletedToolState & { readonly title: string } +}): ToolCallUpdate { + return { + toolCallId: input.toolCallId, + status: "completed", + kind: toToolKind(input.toolName), + title: input.state.title, + content: completedToolContent(input.toolName, input.state), + rawInput: input.state.input, + rawOutput: completedToolRawOutput(input.state), + } +} + +export function errorToolUpdate(input: { + readonly toolCallId: string + readonly toolName: string + readonly state: ErrorToolState +}): ToolCallUpdate { + return { + toolCallId: input.toolCallId, + status: "failed", + kind: toToolKind(input.toolName), + title: input.toolName, + rawInput: input.state.input, + content: [ + { + type: "content", + content: { + type: "text", + text: input.state.error, + }, + }, + ], + rawOutput: { + error: input.state.error, + metadata: input.state.metadata, + }, + } +} + +export function completedToolRawOutput(state: CompletedToolState) { + return { + output: state.output, + ...(state.metadata !== undefined ? { metadata: state.metadata } : {}), + ...(state.attachments?.length ? { attachments: state.attachments } : {}), + } +} + +export function imageContents(attachments: ReadonlyArray): ToolCallContent[] { + return extractImageAttachments(attachments).map((attachment): ToolCallContent => { + return { + type: "content", + content: { + type: "image", + mimeType: attachment.mimeType, + data: attachment.data, + }, + } + }) +} + +export function extractImageAttachments(attachments: ReadonlyArray): ImageAttachment[] { + return attachments.flatMap((attachment): ImageAttachment[] => { + const data = dataUrlImage(attachment) + return data ? [data] : [] + }) +} + +export function shellOutputSnapshot(state: { readonly metadata?: unknown }) { + if (!state.metadata || typeof state.metadata !== "object") return undefined + return stringValue((state.metadata as Record).output) +} + +export const mapToolKind = toToolKind +export const extractLocations = toLocations +export const buildCompletedToolContent = completedToolContent +export const buildCompletedRawOutput = completedToolRawOutput +export const extractShellOutputSnapshot = shellOutputSnapshot +export const buildPendingToolCall = pendingToolCall +export const buildRunningToolUpdate = runningToolUpdate +export const buildDuplicateRunningToolUpdate = duplicateRunningToolUpdate +export const buildCompletedToolUpdate = completedToolUpdate +export const buildErrorToolUpdate = errorToolUpdate + +function locationFrom(value: unknown): ToolCallLocation[] { + const path = stringValue(value) + return path ? [{ path }] : [] +} + +function diffContent(input: ToolInput): ToolCallContent[] { + const oldText = stringValue(input.oldString) + const newText = stringValue(input.newString) ?? stringValue(input.content) + if (oldText === undefined || newText === undefined) return [] + + return [ + { + type: "diff", + path: stringValue(input.filePath) ?? "", + oldText, + newText, + }, + ] +} + +function dataUrlImage(attachment: ToolAttachment) { + const match = stringValue(attachment.url)?.match(/^data:([^;,]+)(?:;[^,]*)*;base64,(.*)$/) + const mime = match?.[1] ?? stringValue(attachment.mime) + if (!mime?.startsWith("image/")) return undefined + + const data = match?.[2] + if (data === undefined) return undefined + return { mimeType: mime, data } +} + +function stringValue(value: unknown) { + return typeof value === "string" ? value : undefined +} diff --git a/packages/opencode/src/acp/types.ts b/packages/opencode/src/acp/types.ts deleted file mode 100644 index a0d4fdbfcc..0000000000 --- a/packages/opencode/src/acp/types.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { McpServer } from "@agentclientprotocol/sdk" -import type { KiloClient } from "@kilocode/sdk/v2" -import type { ProviderID, ModelID } from "../provider/schema" - -export interface ACPSessionState { - id: string - cwd: string - mcpServers: McpServer[] - createdAt: Date - model?: { - providerID: ProviderID - modelID: ModelID - } - variant?: string - modeId?: string -} - -export interface ACPConfig { - sdk: KiloClient - defaultModel?: { - providerID: ProviderID - modelID: ModelID - } -} diff --git a/packages/opencode/src/acp/usage.ts b/packages/opencode/src/acp/usage.ts new file mode 100644 index 0000000000..dcb21d5cea --- /dev/null +++ b/packages/opencode/src/acp/usage.ts @@ -0,0 +1,238 @@ +import type { AgentSideConnection, Usage } from "@agentclientprotocol/sdk" +import * as Log from "@opencode-ai/core/util/log" +import type { AssistantMessage as OpenCodeAssistantMessage, Message } from "@kilocode/sdk/v2" +import { InstanceRef } from "@/effect/instance-ref" +import { InstanceStore } from "@/project/instance-store" +import { ModelID, ProviderID } from "@/provider/schema" +import { Provider } from "@/provider/provider" +import { Context, Effect, Layer, SynchronizedRef } from "effect" + +const log = Log.create({ service: "acp-usage" }) + +export type AssistantTokenCost = Pick + +export type AssistantMessage = AssistantTokenCost & + Pick & + Partial> + +export type SessionMessage = { + readonly info: { readonly role: Message["role"] } | AssistantMessage +} + +export type MessagesInput = { + readonly sessionID: string + readonly directory: string +} + +export type SDK = { + readonly session: { + readonly messages: ( + parameters: { readonly sessionID: string; readonly directory: string }, + options: { readonly throwOnError: true }, + ) => Promise<{ readonly data?: readonly SessionMessage[] | null }> + } +} + +export interface MessageLoaderInterface { + readonly messages: (input: MessagesInput) => Effect.Effect +} + +export interface ContextLimitLoaderInterface { + readonly providers: (directory: string) => Effect.Effect, unknown> +} + +export type UsageConnection = Pick + +export interface Interface { + readonly buildUsage: (message: AssistantTokenCost) => Usage + readonly latestAssistantMessage: (messages: readonly SessionMessage[]) => AssistantMessage | undefined + readonly totalSessionCost: (messages: readonly SessionMessage[]) => number + readonly contextLimit: (input: { + readonly directory: string + readonly providerID: ProviderID + readonly modelID: ModelID + }) => Effect.Effect + readonly sendUpdate: (input: { + readonly connection: UsageConnection + readonly sessionID: string + readonly directory: string + }) => Effect.Effect +} + +export class MessageLoader extends Context.Service()( + "@opencode/ACPUsageMessageLoader", +) {} + +export class ContextLimitLoader extends Context.Service()( + "@opencode/ACPUsageContextLimitLoader", +) {} + +export class Service extends Context.Service()("@opencode/ACPUsage") {} + +export function messageLoaderFromSDK(sdk: SDK): MessageLoaderInterface { + return MessageLoader.of({ + messages: (input) => + Effect.promise(() => + sdk.session + .messages({ sessionID: input.sessionID, directory: input.directory }, { throwOnError: true }) + .then((response) => response.data ?? []), + ), + }) +} + +export const messageLoaderLayer = (sdk: SDK) => Layer.succeed(MessageLoader, messageLoaderFromSDK(sdk)) + +export function buildUsage(message: AssistantTokenCost): Usage { + const cachedReadTokens = message.tokens.cache.read + const cachedWriteTokens = message.tokens.cache.write + const thoughtTokens = message.tokens.reasoning + + return { + inputTokens: message.tokens.input, + outputTokens: message.tokens.output, + totalTokens: message.tokens.input + message.tokens.output + thoughtTokens + cachedReadTokens + cachedWriteTokens, + ...(thoughtTokens > 0 ? { thoughtTokens } : {}), + ...(cachedReadTokens > 0 ? { cachedReadTokens } : {}), + ...(cachedWriteTokens > 0 ? { cachedWriteTokens } : {}), + } +} + +export function latestAssistantMessage(messages: readonly SessionMessage[]): AssistantMessage | undefined { + return messages + .filter((message): message is { readonly info: AssistantMessage } => message.info.role === "assistant") + .at(-1)?.info +} + +export function totalSessionCost(messages: readonly SessionMessage[]): number { + return messages + .filter((message): message is { readonly info: AssistantMessage } => message.info.role === "assistant") + .reduce((sum, message) => sum + message.info.cost, 0) +} + +export function findContextLimit( + providers: Record, + providerID: ProviderID, + modelID: ModelID, +): number | undefined { + return providers[providerID]?.models[modelID]?.limit.context +} + +export const contextLimitLoaderLayer = Layer.effect( + ContextLimitLoader, + Effect.gen(function* () { + const store = yield* InstanceStore.Service + const provider = yield* Provider.Service + + return ContextLimitLoader.of({ + providers: Effect.fn("ACPUsageContextLimitLoader.providers")(function* (directory) { + const ctx = yield* store.load({ directory }) + return yield* Effect.gen(function* () { + return yield* provider.list() + }).pipe(Effect.provideService(InstanceRef, ctx)) + }), + }) + }), +) + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const messageLoader = yield* MessageLoader + const contextLimitLoader = yield* ContextLimitLoader + const limits = yield* SynchronizedRef.make(new Map>()) + + const cachedLimit = Effect.fnUntraced(function* (input: { + readonly directory: string + readonly providerID: ProviderID + readonly modelID: ModelID + }) { + return yield* SynchronizedRef.modifyEffect( + limits, + Effect.fnUntraced(function* (items) { + const key = `${input.directory}\u0000${input.providerID}\u0000${input.modelID}` + const current = items.get(key) + if (current) return [current, items] as const + const next = yield* Effect.cached( + contextLimitLoader.providers(input.directory).pipe( + Effect.map((providers) => findContextLimit(providers, input.providerID, input.modelID)), + Effect.catch((error) => + Effect.sync(() => { + log.error("failed to get providers for usage context limit", { error }) + return undefined + }), + ), + ), + ) + return [next, new Map(items).set(key, next)] as const + }), + ) + }) + + const contextLimit = Effect.fn("ACPUsage.contextLimit")(function* (input: { + readonly directory: string + readonly providerID: ProviderID + readonly modelID: ModelID + }) { + return yield* yield* cachedLimit(input) + }) + + const sendUpdate = Effect.fn("ACPUsage.sendUpdate")(function* (input: { + readonly connection: UsageConnection + readonly sessionID: string + readonly directory: string + }) { + const messages = yield* messageLoader.messages({ sessionID: input.sessionID, directory: input.directory }).pipe( + Effect.catch((error) => + Effect.sync(() => { + log.error("failed to fetch messages for usage update", { error }) + return undefined + }), + ), + ) + if (!messages) return + + const message = latestAssistantMessage(messages) + if (!message) return + if (!message.providerID || !message.modelID) return + + const size = yield* contextLimit({ + directory: input.directory, + providerID: ProviderID.make(message.providerID), + modelID: ModelID.make(message.modelID), + }) + if (!size) return + + yield* Effect.promise(() => + input.connection + .sessionUpdate({ + sessionId: input.sessionID, + update: { + sessionUpdate: "usage_update", + used: message.tokens.input + message.tokens.cache.read, + size, + cost: { amount: totalSessionCost(messages), currency: "USD" }, + }, + }) + .catch((error) => { + log.error("failed to send usage update", { error }) + }), + ) + }) + + return Service.of({ + buildUsage, + latestAssistantMessage, + totalSessionCost, + contextLimit, + sendUpdate, + }) + }), +) + +export const defaultLayer = layer.pipe( + Layer.provide(contextLimitLoaderLayer), + Layer.provide(Provider.defaultLayer), + Layer.provide(InstanceStore.defaultLayer), +) + +export * as UsageService from "./usage" diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index f7e2c134a3..064a59f59e 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -1,5 +1,5 @@ import { Config } from "@/config/config" -import { serviceUse } from "@/effect/service-use" +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" diff --git a/packages/opencode/src/bus/index.ts b/packages/opencode/src/bus/index.ts index b5f4320bda..73ec18d73b 100644 --- a/packages/opencode/src/bus/index.ts +++ b/packages/opencode/src/bus/index.ts @@ -5,7 +5,7 @@ import { BusEvent } from "./bus-event" import { GlobalBus } from "./global" import { InstanceState } from "@/effect/instance-state" import { makeRuntime } from "@/effect/run-service" -import { serviceUse } from "@/effect/service-use" +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" diff --git a/packages/opencode/src/cli/cmd/acp.ts b/packages/opencode/src/cli/cmd/acp.ts index 8497417ca4..4271df95d3 100644 --- a/packages/opencode/src/cli/cmd/acp.ts +++ b/packages/opencode/src/cli/cmd/acp.ts @@ -7,6 +7,7 @@ import { Server } from "@/server/server" import { ServerAuth } from "@/server/auth" import { createKiloClient } from "@kilocode/sdk/v2" import { withNetworkOptions, resolveNetworkOptions } from "../network" +import { ACPProfile } from "@/acp/profile" const log = Log.create({ service: "acp-command" }) @@ -21,9 +22,10 @@ export const AcpCommand = effectCmd({ }) }, handler: Effect.fn("Cli.acp")(function* (args) { + ACPProfile.mark("cli.acp.handler") process.env.KILO_CLIENT = "acp" const opts = yield* resolveNetworkOptions(args) - const server = yield* Effect.promise(() => Server.listen(opts)) + const server = yield* Effect.promise(() => ACPProfile.measure("cli.acp.server.listen", () => Server.listen(opts))) const sdk = createKiloClient({ baseUrl: `http://${server.hostname}:${server.port}`, @@ -57,7 +59,8 @@ export const AcpCommand = effectCmd({ const agent = ACP.init({ sdk }) new AgentSideConnection((conn) => { - return agent.create(conn, { sdk }) + ACPProfile.mark("cli.acp.connection.create") + return agent.create(conn) }, stream) log.info("setup connection") diff --git a/packages/opencode/src/cli/cmd/debug/v2.ts b/packages/opencode/src/cli/cmd/debug/v2.ts index 56866a0e02..aab7018982 100644 --- a/packages/opencode/src/cli/cmd/debug/v2.ts +++ b/packages/opencode/src/cli/cmd/debug/v2.ts @@ -3,6 +3,7 @@ import { Effect, Option } from "effect" import { Catalog } from "@opencode-ai/core/catalog" import { LocationServiceMap } from "@opencode-ai/core/location-layer" import { PluginBoot } from "@opencode-ai/core/plugin/boot" +import { AbsolutePath } from "@opencode-ai/core/schema" import { effectCmd } from "../../effect-cmd" export const V2Command = effectCmd({ @@ -37,7 +38,7 @@ export const V2Command = effectCmd({ Effect.withSpan("Cli.debug.v2"), Effect.provide( LocationServiceMap.get({ - directory: process.cwd(), + directory: AbsolutePath.make(process.cwd()), }), ), Effect.provide(LocationServiceMap.layer), diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 1c4d4a29fe..30dea01f3e 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -3,7 +3,7 @@ import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui" import * as Clipboard from "@tui/util/clipboard" import * as Selection from "@tui/util/selection" import * as TuiAudio from "@tui/util/audio" -import { createCliRenderer, MouseButton, type CliRendererConfig } from "@opentui/core" +import { createCliRenderer, MouseButton, type CliRenderer, type CliRendererConfig } from "@opentui/core" import { RouteProvider, useRoute } from "@tui/context/route" import { Switch, @@ -18,7 +18,7 @@ import { Show, on, } from "solid-js" -import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32" +import { win32DisableProcessedInput, win32FlushInputBuffer, win32InstallCtrlCGuard } from "./win32" import { Flag } from "@opencode-ai/core/flag/flag" import semver from "semver" import { DialogProvider, useDialog } from "@tui/ui/dialog" @@ -41,6 +41,7 @@ import { DialogThemeList } from "@tui/component/dialog-theme-list" import { DialogHelp } from "./ui/dialog-help" import { DialogAgent } from "@tui/component/dialog-agent" import { DialogSessionList } from "@tui/component/dialog-session-list" +import { DialogWorkspaceList } from "@tui/component/dialog-workspace-list" import { DialogConsoleOrg } from "@tui/component/dialog-console-org" import { ThemeProvider, useTheme } from "@tui/context/theme" import { Home } from "@tui/routes/home" @@ -51,7 +52,7 @@ import { PromptStashProvider } from "./component/prompt/stash" import { DialogAlert } from "./ui/dialog-alert" import { DialogConfirm } from "./ui/dialog-confirm" import { ToastProvider, useToast } from "./ui/toast" -import { ExitProvider, useExit } from "./context/exit" +import { createExit, ExitProvider, useExit, type Exit } from "./context/exit" import { Session as SessionApi } from "@/session/session" import { TuiEvent } from "./event" import { KVProvider, useKV } from "./context/kv" @@ -79,8 +80,7 @@ import { import type { EventSource } from "./context/sdk" import { DialogVariant } from "./component/dialog-variant" -const appBindingCommands = [ - "command.palette.show", +const appGlobalBindingCommands = [ "session.list", "session.new", "session.quick_switch.1", @@ -92,6 +92,10 @@ const appBindingCommands = [ "session.quick_switch.7", "session.quick_switch.8", "session.quick_switch.9", +] as const + +const appBindingCommands = [ + "command.palette.show", "model.list", "model.cycle_recent", "model.cycle_recent_reverse", @@ -111,6 +115,7 @@ const appBindingCommands = [ "theme.mode.lock", "help.show", "docs.open", + "workspace.list", "app.debug", "app.console", "app.heap_snapshot", @@ -123,7 +128,7 @@ const appBindingCommands = [ "app.toggle.session_directory_filter", ] as const -function rendererConfig(_config: TuiConfig.Resolved): CliRendererConfig { +export function tuiRendererConfig(_config: TuiConfig.Resolved): CliRendererConfig { const mouseEnabled = !Flag.KILO_DISABLE_MOUSE && (_config.mouse ?? true) return { @@ -146,6 +151,34 @@ function rendererConfig(_config: TuiConfig.Resolved): CliRendererConfig { } } +export function createTuiRenderer(config: TuiConfig.Resolved) { + return createCliRenderer(tuiRendererConfig(config)) +} + +export type TuiHandle = { + ready: Promise + done: Promise + exit: Exit +} + +type TuiInput = { + url: string + args: Args + config: TuiConfig.Resolved + renderer: CliRenderer + onSnapshot?: () => Promise + directory?: string + fetch?: typeof fetch + headers?: RequestInit["headers"] + events?: EventSource +} + +type TuiLifecycle = { + exit: Exit + exited: Promise + fail(error: unknown): Promise +} + function errorMessage(error: unknown) { const formatted = FormatError(error) if (formatted !== undefined) return formatted @@ -163,105 +196,175 @@ function errorMessage(error: unknown) { return FormatUnknownError(error) } -export function tui(input: { - url: string - args: Args - config: TuiConfig.Resolved - onSnapshot?: () => Promise - directory?: string - fetch?: typeof fetch - headers?: RequestInit["headers"] - events?: EventSource -}) { - // promise to prevent immediate exit - // oxlint-disable-next-line no-async-promise-executor -- intentional: async executor used for sequential setup before resolve - return new Promise(async (resolve) => { - const unguard = win32InstallCtrlCGuard() - win32DisableProcessedInput() +export function tui(input: TuiInput): TuiHandle { + const unguard = win32InstallCtrlCGuard() + win32DisableProcessedInput() - const onExit = async () => { - unguard?.() - resolve() - } - const onBeforeExit = async () => { - offKeymap() + const renderer = input.renderer + const keymap = createDefaultOpenTuiKeymap(renderer) + const unregisterKeymap = registerOpencodeKeymap(keymap, renderer, input.config) + const lifecycle = createTuiLifecycle({ + renderer, + unguard, + cleanup: async () => { + unregisterKeymap() await TuiPluginRuntime.dispose() TuiAudio.dispose() - } - - const renderer = await createCliRenderer(rendererConfig(input.config)) - // Prewarm palette before ThemeProvider mounts so `system` theme avoids a first-paint fallback flash. - void renderer.getPalette({ size: 16 }).catch(() => undefined) - const mode = (await renderer.waitForThemeMode(1000)) ?? "dark" - - const keymap = createDefaultOpenTuiKeymap(renderer) - const offKeymap = registerOpencodeKeymap(keymap, renderer, input.config) - - await render(() => { - return ( - ( - - )} - > - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ) - }, renderer) + }, }) + const ready = mountTui({ ...input, keymap, exit: lifecycle.exit }).catch((error) => lifecycle.fail(error)) + const done = waitUntilDone(ready, lifecycle.exited) + + return { ready, done, exit: lifecycle.exit } +} + +async function mountTui(input: TuiInput & { keymap: ReturnType; exit: Exit }) { + const renderer = input.renderer + // Prewarm palette before ThemeProvider mounts so `system` theme avoids a first-paint fallback flash. + void renderer.getPalette({ size: 16 }).catch(() => undefined) + const mode = (await renderer.waitForThemeMode(1000)) ?? "dark" + if (renderer.isDestroyed) return + + await render(() => { + return ( + } + > + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) + }, renderer) +} + +function createTuiLifecycle(input: { + renderer: CliRenderer + unguard?: () => void + cleanup: () => Promise +}): TuiLifecycle { + let resolveExited!: () => void + const exited = new Promise((resolve) => { + resolveExited = resolve + }) + let exitCompleted = false + let exiting = false + let cleanupTask: Promise | undefined + + const completeExit = () => { + if (exitCompleted) return + exitCompleted = true + resolveExited() + } + + const cleanup = () => { + cleanupTask ??= (async () => { + process.off("SIGHUP", onSighup) + try { + await input.cleanup() + } finally { + input.unguard?.() + } + })() + return cleanupTask + } + + const exit = createExit(async (reason, message) => { + exiting = true + await cleanup() + if (!input.renderer.isDestroyed) { + input.renderer.setTerminalTitle("") + input.renderer.destroy() + } + win32FlushInputBuffer() + if (reason) { + const formatted = FormatError(reason) ?? FormatUnknownError(reason) + if (formatted) process.stderr.write(formatted + "\n") + } + const text = message() + if (text) process.stdout.write(text + "\n") + completeExit() + }) + const onSighup = () => { + void exit() + } + + input.renderer.once("destroy", () => { + if (exiting) return + void cleanup().finally(() => { + win32FlushInputBuffer() + completeExit() + }) + }) + process.on("SIGHUP", onSighup) + + return { + exit, + exited, + async fail(error) { + exiting = true + await cleanup().catch(() => {}) + if (!input.renderer.isDestroyed) input.renderer.destroy() + completeExit() + throw error + }, + } +} + +async function waitUntilDone(ready: Promise, exited: Promise) { + await ready + await exited } function App(props: { onSnapshot?: () => Promise }) { @@ -505,6 +608,16 @@ function App(props: { onSnapshot?: () => Promise }) { dialog.clear() }, }, + { + name: "workspace.list", + title: "Manage workspaces", + category: "Workspace", + hidden: !Flag.KILO_EXPERIMENTAL_WORKSPACES, + slashName: "workspaces", + run: () => { + dialog.replace(() => ) + }, + }, ...Array.from({ length: 9 }, (_, i) => ({ name: `session.quick_switch.${i + 1}`, title: `Switch to session in quick slot ${i + 1}`, @@ -831,6 +944,10 @@ function App(props: { onSnapshot?: () => Promise }) { bindings: tuiConfig.keybinds.gather("app", appBindingCommands), })) + useBindings(() => ({ + bindings: tuiConfig.keybinds.gather("app.global", appGlobalBindingCommands), + })) + useBindings(() => ({ mode: KILO_BASE_MODE, enabled: () => { diff --git a/packages/opencode/src/cli/cmd/tui/attach.ts b/packages/opencode/src/cli/cmd/tui/attach.ts index aa24159e61..c732d928c9 100644 --- a/packages/opencode/src/cli/cmd/tui/attach.ts +++ b/packages/opencode/src/cli/cmd/tui/attach.ts @@ -67,7 +67,6 @@ export const AttachCommand = cmd({ })() const headers = ServerAuth.headers({ password: args.password, username: args.username }) const config = await TuiConfig.get() - const { tui } = await import("./app") try { await validateSession({ @@ -82,9 +81,12 @@ export const AttachCommand = cmd({ return } - await tui({ + const { createTuiRenderer, tui } = await import("./app") + const renderer = await createTuiRenderer(config) + const handle = tui({ url: args.url, config, + renderer, args: { continue: args.continue, sessionID: args.session, @@ -93,6 +95,7 @@ export const AttachCommand = cmd({ directory, headers, }) + await handle.done } finally { unguard?.() } diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx index 09c2d64b00..4b4484b798 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx @@ -73,6 +73,7 @@ export function DialogModel(props: { providerID?: string }) { map(([model, info]) => ({ value: { providerID: provider.id, modelID: model }, title: info.name ?? model, + releaseDate: info.release_date, description: favorites.some((item) => item.providerID === provider.id && item.modelID === model) ? "(Favorite)" : undefined, @@ -91,10 +92,7 @@ export function DialogModel(props: { providerID?: string }) { return false return true }), - sortBy( - (x) => x.footer !== "Free", - (x) => x.title, - ), + (options) => sortModelOptions(options, props.providerID !== undefined), ), ), ) @@ -173,3 +171,15 @@ export function DialogModel(props: { providerID?: string }) { /> ) } + +export function sortModelOptions( + options: T[], + newestFirst: boolean, +) { + if (newestFirst) return sortBy(options, [(option) => option.releaseDate, "desc"], (option) => option.title) + return sortBy( + options, + (option) => option.footer !== "Free", + (option) => option.title, + ) +} diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx index 0d5803492d..e87a31cdf1 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx @@ -262,6 +262,13 @@ function AutoMethod(props: AutoMethodProps) { method: props.index, }) if (result.error) { + toast.show({ + variant: "error", + message: + "name" in result.error && result.error.name === "ProviderAuthOauthCallbackFailed" + ? "OAuth authorization failed. Try /connect again." + : JSON.stringify(result.error), + }) dialog.clear() return } @@ -373,7 +380,7 @@ function ApiMethod(props: ApiMethodProps) { with generous usage limits. - Go to https://opencode.ai/zen and enable OpenCode Go + Go to https://opencode.ai/go and enable OpenCode Go ), diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx index 0c993eaba3..4a592c9697 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx @@ -53,13 +53,22 @@ export function DialogSessionList() { const workspaceID = await (async () => { if (selection.type === "none") return null if (selection.type === "existing") return selection.workspaceID - const result = await sdk.client.experimental.workspace - .create({ type: selection.workspaceType, branch: null }) - .catch(() => undefined) + let result + try { + result = await sdk.client.experimental.workspace.create({ type: selection.workspaceType, branch: null }) + } catch (err) { + toast.show({ + title: "Failed to create workspace", + message: errorMessage(err), + variant: "error", + }) + return + } const workspace = result?.data if (!workspace) { toast.show({ - message: `Failed to create workspace: ${errorMessage(result?.error ?? "no response")}`, + title: "Failed to create workspace", + message: errorMessage(result?.error ?? "no response"), variant: "error", }) return diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx index 542e6fd9d4..a5ac6c0de3 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx @@ -61,15 +61,17 @@ async function loadWorkspaceAdapters(input: { const dir = input.sync.path.directory || input.sdk.directory const url = new URL("/experimental/workspace/adapter", input.sdk.url) if (dir) url.searchParams.set("directory", dir) - const res = await input.sdk - .fetch(url) - .then((x) => x.json() as Promise) - .catch(() => undefined) - if (res) return res - input.toast.show({ - message: "Failed to load workspace adapters", - variant: "error", - }) + try { + const response = await input.sdk.fetch(url) + return (await response.json()) as Adapter[] + } catch (err) { + input.toast.show({ + title: "Failed to load workspace adapters", + message: errorMessage(err), + variant: "error", + }) + return undefined + } } export async function openWorkspaceSelect(input: { @@ -100,13 +102,21 @@ export async function warpWorkspaceSession(input: { copyChanges: boolean done?: () => void }): Promise { - const result = await input.sdk.client.experimental.workspace - .warp({ + let result + try { + result = await input.sdk.client.experimental.workspace.warp({ id: input.workspaceID, sessionID: input.sessionID, copyChanges: input.copyChanges, }) - .catch(() => undefined) + } catch (err) { + input.toast.show({ + title: "Failed to warp session", + message: errorMessage(err), + variant: "error", + }) + return false + } if (!result?.data) { if (result?.error && "name" in result.error && result.error.name === "VcsApplyError") { await DialogAlert.show( @@ -118,7 +128,8 @@ export async function warpWorkspaceSession(input: { } input.toast.show({ - message: `Failed to warp session: ${errorMessage(result?.error ?? "no response")}`, + title: "Failed to warp session", + message: errorMessage(result?.error ?? "no response"), variant: "error", }) return false diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-list.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-list.tsx new file mode 100644 index 0000000000..3b36523f2b --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-list.tsx @@ -0,0 +1,112 @@ +import type { Workspace } from "@kilocode/sdk/v2" +import { useDialog } from "@tui/ui/dialog" +import { DialogSelect, type DialogSelectOption } from "@tui/ui/dialog-select" +import { useProject } from "@tui/context/project" +import { useRoute } from "@tui/context/route" +import { useSync } from "@tui/context/sync" +import { useTheme } from "@tui/context/theme" +import { createMemo, createSignal, onMount } from "solid-js" +import { createStore } from "solid-js/store" +import { errorMessage } from "@/util/error" +import { useSDK } from "../context/sdk" +import { useToast } from "../ui/toast" + +type WorkspaceOption = { workspace: Workspace } + +export function DialogWorkspaceList() { + const dialog = useDialog() + const route = useRoute() + const sync = useSync() + const sdk = useSDK() + const toast = useToast() + const project = useProject() + const { theme } = useTheme() + const [deleting, setDeleting] = createSignal() + const [removing, setRemoving] = createSignal() + const [expanded, setExpanded] = createStore>({}) + + const current = createMemo(() => { + if (route.data.type === "session") return sync.session.get(route.data.sessionID)?.workspaceID + return project.workspace.current() + }) + + const options = createMemo[]>(() => + project.workspace + .list() + .toSorted((a, b) => a.name.localeCompare(b.name)) + .map((workspace) => { + const status = project.workspace.status(workspace.id) + return { + title: + removing() === workspace.id + ? "Deleting..." + : deleting() === workspace.id + ? `Delete ${workspace.name}? Press delete again` + : workspace.name, + value: { workspace }, + footer: workspace.type, + details: expanded[workspace.id] && workspace.directory ? [workspace.directory] : undefined, + gutter: () => , + } + }), + ) + + function showDetails(workspace: Workspace) { + setExpanded(workspace.id, (open) => !open) + } + + async function remove(workspace: Workspace) { + if (removing()) return + if (deleting() !== workspace.id) { + setDeleting(workspace.id) + return + } + + setDeleting(undefined) + setRemoving(workspace.id) + const result = await sdk.client.experimental.workspace.remove({ id: workspace.id }).catch((err) => ({ + error: err, + })) + if (result?.error) { + setRemoving(undefined) + toast.show({ + variant: "error", + title: "Failed to delete workspace", + message: errorMessage(result.error), + }) + return + } + + if (current() === workspace.id) { + project.workspace.set(undefined) + route.navigate({ type: "home" }) + } + await project.workspace.sync() + await sync.bootstrap({ fatal: false }).catch(() => undefined) + setRemoving(undefined) + } + + onMount(() => { + dialog.setSize("large") + void sdk.client.experimental.workspace.syncList().catch(() => undefined) + void project.workspace.sync() + }) + + return ( + { + setDeleting(undefined) + }} + onSelect={(option) => showDetails(option.value.workspace)} + actions={[ + { + command: "session.delete", + title: "delete", + onTrigger: (option) => void remove(option.value.workspace), + }, + ]} + /> + ) +} diff --git a/packages/opencode/src/cli/cmd/tui/component/error-component.tsx b/packages/opencode/src/cli/cmd/tui/component/error-component.tsx index fcbd27ca9b..e67dc249c8 100644 --- a/packages/opencode/src/cli/cmd/tui/component/error-component.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/error-component.tsx @@ -1,32 +1,21 @@ import { TextAttributes } from "@opentui/core" -import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid" +import { useKeyboard, useTerminalDimensions } from "@opentui/solid" import * as Clipboard from "@tui/util/clipboard" import { createSignal } from "solid-js" import { InstallationVersion } from "@opencode-ai/core/installation/version" -import { win32FlushInputBuffer } from "../win32" import { getScrollAcceleration } from "../util/scroll" export function ErrorComponent(props: { error: Error reset: () => void - onBeforeExit?: () => Promise - onExit: () => Promise + exit: () => Promise mode?: "dark" | "light" }) { const term = useTerminalDimensions() - const renderer = useRenderer() - - const handleExit = async () => { - await props.onBeforeExit?.() - renderer.setTerminalTitle("") - renderer.destroy() - win32FlushInputBuffer() - await props.onExit() - } useKeyboard((evt) => { if (evt.ctrl && evt.name === "c") { - void handleExit() + void props.exit() } }) const [copied, setCopied] = createSignal(false) @@ -79,7 +68,7 @@ export function ErrorComponent(props: { Reset TUI - + void props.exit()} backgroundColor={colors.primary} padding={1}> Exit 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 a8e0d9f4fe..d80e5c5ce2 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -40,6 +40,7 @@ import type { AssistantMessage, FilePart, UserMessage } from "@kilocode/sdk/v2" import { TuiEvent } from "../../event" import { iife } from "@/util/iife" import { Locale } from "@/util/locale" +import { errorMessage } from "@/util/error" import { formatDuration } from "@/util/format" import { createColors, createFrames } from "../../ui/spinner.ts" import { useDialog } from "@tui/ui/dialog" @@ -216,14 +217,25 @@ export function Prompt(props: PromptProps) { async function createWorkspace(selection: Extract) { setCreatingWorkspace(true) - const result = await sdk.client.experimental.workspace - .create({ type: selection.workspaceType, branch: null }) - .catch(() => undefined) - if (result == undefined || result.error || !result.data) { + let result + try { + result = await sdk.client.experimental.workspace.create({ type: selection.workspaceType, branch: null }) + } catch (err) { selectWorkspace(undefined) setCreatingWorkspace(false) toast.show({ - message: "Creating workspace failed", + title: "Creating workspace failed", + message: errorMessage(err), + variant: "error", + }) + return + } + if (result.error || !result.data) { + selectWorkspace(undefined) + setCreatingWorkspace(false) + toast.show({ + title: "Creating workspace failed", + message: errorMessage(result.error ?? "no response"), variant: "error", }) return @@ -509,7 +521,14 @@ export function Prompt(props: PromptProps) { const nonTextParts = store.prompt.parts.filter((p) => p.type !== "text") const value = text - const content = await Editor.open({ value, renderer }) + const content = await Editor.open({ + value, + renderer, + cwd: + (project.instance.path().worktree === "/" ? undefined : project.instance.path().worktree) || + project.instance.directory() || + process.cwd(), + }) if (!content) return input.setText(content) @@ -1460,11 +1479,13 @@ export function Prompt(props: PromptProps) { }), } }) + const maxHeight = createMemo(() => tuiConfig.prompt?.max_height ?? Math.max(6, Math.floor(dimensions().height / 3))) return ( <> - (anchor = r)} visible={props.visible !== false}> + (anchor = r)} visible={props.visible !== false} width="100%">