Compare commits

..

44 Commits

Author SHA1 Message Date
Aiden Cline 4224979f53 feat(cli): add unsafe permission alias 2026-07-27 03:58:49 +00:00
Aiden Cline 4216d35e4b fix(server): declare schema dependency (#39043) 2026-07-26 22:46:46 -05:00
Dax Raad 863645c671 test(core): update grep error assertion 2026-07-26 20:17:31 -04:00
Dax Raad 5592f5225b fix(app): update remote sdk contracts 2026-07-26 20:16:58 -04:00
Dax Raad 8db7487c89 refactor(core): consolidate tool architecture 2026-07-26 20:16:58 -04:00
Aiden Cline 0fd73a2976 fix(core): align grep behavior and guidance (#38999) 2026-07-26 17:29:27 -05:00
Dax Raad 80865407e0 refactor(sdk): remove local legacy package 2026-07-26 02:47:43 -04:00
Dax Raad 28f4284bd7 fix(www): canonicalize production routes 2026-07-26 02:39:12 -04:00
Aiden Cline 7affee529b fix(core): harden grep search behavior (#38922) 2026-07-25 23:23:53 -05:00
Aiden Cline 79c7e9446e fix(core): clarify custom question answers (#38919) 2026-07-25 23:00:26 -05:00
Shoubhit Dash efb629a33a feat(core): add pluggable web search (#35558)
Co-authored-by: Dax Raad <d@ironbay.co>
2026-07-26 03:55:05 +00:00
Dax Raad 2ddc91a0e8 fix(tui): show shell working directory in prompt 2026-07-25 23:42:53 -04:00
Dax Raad c7871e14d4 fix(www): remove deployment environment gate 2026-07-25 21:21:57 -04:00
Dax Raad 9840f63b12 chore(www): simplify worker routes 2026-07-25 21:17:25 -04:00
Dax Raad 56a9c0150a fix(www): mark deploy script as module 2026-07-25 21:17:25 -04:00
Dax Raad 203a0613b8 feat(www): migrate docs to Blume 2026-07-25 21:17:25 -04:00
Aiden Cline 7d8f1bdab3 tweak(core): simplify skill tool description (#38900) 2026-07-25 17:09:20 -05:00
Aiden Cline 9eea5bc925 fix(core): tweak glob tool description/parameters (#38899) 2026-07-25 16:52:36 -05:00
Aiden Cline f753103e82 fix(core): reject file glob roots (#38890) 2026-07-25 14:43:18 -05:00
Aiden Cline 1e35d33ecb fix(codemode): search nested namespaces (#38887) 2026-07-25 14:17:49 -05:00
Aiden Cline c5bf4edb10 fix(ai): preserve response message phases (#38777) 2026-07-25 14:06:00 -05:00
Aiden Cline cce8bb0e1c fix(core): clarify empty Code Mode guidance (#38883) 2026-07-25 13:20:16 -05:00
Dax Raad 02c66c5fc1 docs(core): fix OpenCode skill links 2026-07-25 14:08:29 -04:00
Aiden Cline 33390cc457 fix(core): keep execute tool cache stable (#38783) 2026-07-25 13:01:05 -05:00
Kit Langton b2afb35527 refactor(core): settle steps lock-free by joining tool fibers first (#38743) 2026-07-25 13:43:17 -04:00
opencode-agent[bot] 828148909d fix(tui): preserve workspace while reconnecting (#38788) 2026-07-25 03:48:42 +00:00
opencode-agent[bot] 454145fe65 fix(tui): preserve workspace while reconnecting (#38788) 2026-07-25 02:22:47 +00:00
Aiden Cline 1291dc1f11 fix(core): clarify code mode tool boundary (#38785) 2026-07-24 20:49:55 -05:00
Aiden Cline c7d7f61146 fix(ai): preserve Anthropic usage metadata (#38751) 2026-07-24 16:23:51 -05:00
Aiden Cline 13b6845e7e fix(core): scope MCP execute guidance to Code Mode (#38753) 2026-07-24 16:02:57 -05:00
Aiden Cline d1d97014b4 refactor(core): move static Code Mode guidance (#38746) 2026-07-24 14:46:59 -05:00
Aiden Cline b31747124b fix(core): stream Code Mode tool progress (#38718) 2026-07-24 14:46:46 -05:00
Aiden Cline 49bec25ae5 fix(ai): align Anthropic stream handling (#38733) 2026-07-24 14:23:35 -05:00
Aiden Cline d66d0cb904 fix(core): clarify Code Mode tool availability (#38745) 2026-07-24 13:44:59 -05:00
Kit Langton 3193f3aa95 fix(tui): flag likely cache busts accurately (#38727) 2026-07-24 18:35:13 +00:00
Aiden Cline ee5460a152 fix(codemode): report interrupted tool calls (#38741) 2026-07-24 13:32:54 -05:00
Kit Langton b09a066fb5 fix(ai): report OpenAI cache writes (#38735) 2026-07-24 18:26:30 +00:00
Aiden Cline 423fad730c fix(core): authorize external glob paths (#38714) 2026-07-24 13:24:23 -05:00
Kit Langton 5ae2d6d3f6 refactor(core): settle declined tool calls durably with typed reasons (#38734) 2026-07-24 18:18:02 +00:00
Kit Langton 993f046dd9 fix(ai): layer prompt cache breakpoints (#38725) 2026-07-24 18:02:35 +00:00
opencode-agent[bot] 9b640cf97d test(core): remove flaky npm install test (#38729)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-07-24 12:33:28 -05:00
Kit Langton 2200d100d0 refactor(core): name the unsettled-tool sweep scope and untangle hosted settlement (#38724) 2026-07-24 13:32:14 -04:00
Kit Langton 68ef893818 fix(core): start all suspended sessions promptly (#38720) 2026-07-24 16:58:50 +00:00
Kit Langton 7ab3dd04ad refactor(core): unify tool fiber bookkeeping into one owned structure (#38719) 2026-07-24 16:55:43 +00:00
693 changed files with 50776 additions and 95032 deletions
-7
View File
@@ -1,7 +0,0 @@
---
"@opencode-ai/client": patch
"@opencode-ai/protocol": patch
"@opencode-ai/cli": patch
---
Expose background-service lifecycle status, preserve one process-held owner through startup and failure, reconnect TUIs without activating replacement, and stop exact service instances gracefully.
-5
View File
@@ -1,5 +0,0 @@
---
"@opencode-ai/cli": patch
---
Expose a TUI plugin slot at the top of the session view.
-8
View File
@@ -1,8 +0,0 @@
---
"@opencode-ai/plugin": minor
"@opencode-ai/sdk": minor
"@opencode-ai/client": minor
"@opencode-ai/protocol": minor
---
Replace the V2 tool result model with one canonical representation per fact. Tools lose `structured`, projection callbacks, the `Structured` generic, and the exported `Tool.settle` interpreter; tool responses carry schema-validated `output`, model-visible `content`, and optional compact JSON `metadata`. Code Mode receives the validated encoded output. Durable tool success stores non-empty model content plus optional metadata; failure stores one error plus the final bounded partial snapshot. Progress carries metadata only, while `execute.after` hooks receive the canonical terminal outcome and managed `outputPaths`. A one-time migration rewrites existing projected tool rows and moves provider-hosted result payloads into provider-owned result state.
-7
View File
@@ -1,7 +0,0 @@
---
"@opencode-ai/client": patch
"@opencode-ai/plugin": patch
"@opencode-ai/protocol": patch
---
Expose transient, read-only session generation through the HTTP API, generated clients, and V2 plugin session context.
-5
View File
@@ -1,5 +0,0 @@
---
"@opencode-ai/cli": patch
---
Expose a TUI plugin slot above the session composer.
+37
View File
@@ -0,0 +1,37 @@
name: deploy-www
on:
push:
branches:
- dev
- v2
workflow_dispatch:
concurrency:
group: deploy-www-${{ github.ref_name }}
cancel-in-progress: false
permissions:
contents: read
jobs:
deploy:
if: github.repository == 'anomalyco/opencode' && (github.ref_name == 'dev' || github.ref_name == 'v2')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
- uses: ./.github/actions/setup-bun
- name: Build
working-directory: packages/www
run: bun run build
env:
BLUME_ENV: ${{ github.ref_name == 'v2' && 'production' || 'dev' }}
CLOUDFLARE_ENV: ${{ github.ref_name == 'v2' && 'production' || 'dev' }}
- name: Deploy
working-directory: packages/www
run: bun run deploy
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
+1 -1
View File
@@ -99,7 +99,7 @@ jobs:
- name: Check generated documentation
if: runner.os == 'Linux'
working-directory: packages/docs
working-directory: packages/www
run: bun run check:generated
e2e:
-1
View File
@@ -1,4 +1,3 @@
- To regenerate the legacy JavaScript SDK, run `./packages/sdk/js/script/build.ts`.
- After changing the public Protocol or Server `HttpApi`, run `bun run generate` from `packages/client`. Do not edit `src/generated` or `src/generated-effect` directly.
- Keep runtime dependencies directed from Schema to Core and Protocol, then from Core and Protocol to Server. Client runtime code may depend on Schema and Protocol but never Core or Server; `sdk-next` composes Client, Core, and Server.
- Do not modify `packages/opencode` unless the user explicitly asks for V1 work. `packages/opencode` is the V1 implementation and is present for reference only. New implementation changes should land in the V2 package set: `packages/core`, `packages/cli`, `packages/server`, `packages/protocol`, `packages/schema`, and related generated client surfaces when required.
+1607 -1655
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -2,7 +2,7 @@
exact = true
# Only install newly resolved package versions published at least 3 days ago.
minimumReleaseAge = 259200
minimumReleaseAgeExcludes = ["@ai-sdk/amazon-bedrock", "@ai-sdk/anthropic", "@opentui/core", "@opentui/core-darwin-arm64", "@opentui/core-darwin-x64", "@opentui/core-linux-arm64", "@opentui/core-linux-arm64-musl", "@opentui/core-linux-x64", "@opentui/core-linux-x64-musl", "@opentui/core-win32-arm64", "@opentui/core-win32-x64", "@opentui/keymap", "@opentui/solid", "opentui-spinner", "gitlab-ai-provider", "opencode-gitlab-auth", "@ff-labs/fff-node", "@ff-labs/fff-bun", "@ff-labs/fff-bin-darwin-arm64", "@ff-labs/fff-bin-darwin-x64", "@ff-labs/fff-bin-linux-arm64-gnu", "@ff-labs/fff-bin-linux-arm64-musl", "@ff-labs/fff-bin-linux-x64-gnu", "@ff-labs/fff-bin-linux-x64-musl", "@ff-labs/fff-bin-win32-arm64", "@ff-labs/fff-bin-win32-x64", "@pierre/diffs", "@pierre/theming", "app-builder-lib", "dmg-builder", "electron-builder", "electron-publish"]
minimumReleaseAgeExcludes = ["@ai-sdk/amazon-bedrock", "@ai-sdk/anthropic", "@opencode-ai/sdk", "@opentui/core", "@opentui/core-darwin-arm64", "@opentui/core-darwin-x64", "@opentui/core-linux-arm64", "@opentui/core-linux-arm64-musl", "@opentui/core-linux-x64", "@opentui/core-linux-x64-musl", "@opentui/core-win32-arm64", "@opentui/core-win32-x64", "@opentui/keymap", "@opentui/solid", "opentui-spinner", "gitlab-ai-provider", "opencode-gitlab-auth", "@ff-labs/fff-node", "@ff-labs/fff-bun", "@ff-labs/fff-bin-darwin-arm64", "@ff-labs/fff-bin-darwin-x64", "@ff-labs/fff-bin-linux-arm64-gnu", "@ff-labs/fff-bin-linux-arm64-musl", "@ff-labs/fff-bin-linux-x64-gnu", "@ff-labs/fff-bin-linux-x64-musl", "@ff-labs/fff-bin-win32-arm64", "@ff-labs/fff-bin-win32-x64", "@pierre/diffs", "@pierre/theming", "app-builder-lib", "dmg-builder", "electron-builder", "electron-publish"]
[test]
root = "./do-not-run-tests-from-root"
+1 -1
View File
@@ -15,6 +15,6 @@
"@actions/github": "6.0.1",
"@octokit/graphql": "9.0.1",
"@octokit/rest": "catalog:",
"@opencode-ai/sdk": "workspace:*"
"@opencode-ai/sdk": "1.18.5"
}
}
+9 -8
View File
@@ -33,13 +33,12 @@
"packages/*",
"packages/console/*",
"packages/stats/*",
"packages/sdk/js",
"packages/slack"
],
"catalog": {
"@effect/opentelemetry": "4.0.0-beta.98",
"@effect/platform-node": "4.0.0-beta.98",
"@effect/sql-sqlite-bun": "4.0.0-beta.98",
"@effect/opentelemetry": "4.0.0-beta.101",
"@effect/platform-node": "4.0.0-beta.101",
"@effect/sql-sqlite-bun": "4.0.0-beta.101",
"@npmcli/arborist": "9.4.0",
"@types/bun": "1.3.13",
"@types/cross-spawn": "6.0.6",
@@ -51,6 +50,7 @@
"@opentui/solid": "0.4.5",
"@tanstack/solid-virtual": "3.13.32",
"@shikijs/stream": "4.2.0",
"@standard-schema/spec": "1.1.0",
"ulid": "3.0.1",
"@kobalte/core": "0.13.11",
"@corvu/drawer": "0.2.4",
@@ -69,7 +69,7 @@
"dompurify": "3.3.1",
"drizzle-kit": "1.0.0-rc.2",
"drizzle-orm": "1.0.0-rc.2",
"effect": "4.0.0-beta.98",
"effect": "4.0.0-beta.101",
"ai": "6.0.168",
"cross-spawn": "7.0.6",
"hono": "4.10.7",
@@ -124,7 +124,7 @@
"@aws-sdk/client-s3": "3.933.0",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/script": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/sdk": "1.18.5",
"heap-snapshot-toolkit": "1.1.3",
"typescript": "catalog:"
},
@@ -152,7 +152,8 @@
"@opentui/keymap": "catalog:",
"@opentui/solid": "catalog:",
"@types/bun": "catalog:",
"@types/node": "catalog:"
"@types/node": "catalog:",
"effect": "catalog:"
},
"patchedDependencies": {
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
@@ -166,7 +167,7 @@
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
"effect@4.0.0-beta.98": "patches/effect@4.0.0-beta.98.patch",
"effect@4.0.0-beta.101": "patches/effect@4.0.0-beta.101.patch",
"@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch"
}
}
+6 -4
View File
@@ -207,7 +207,9 @@ Prompt caching is **on by default**. Every `LLMRequest` resolves to `cache: "aut
### Auto placement
`"auto"` places three breakpoints — last tool definition, last system part, latest user message. The last-user-message boundary is the load-bearing detail: in a tool-use loop, a single user turn expands into many assistant/tool round-trips, all sharing that prefix. Caching at that boundary lets every intra-turn API call hit.
`"auto"` places up to four breakpoints — the last tool definition, the first system part, the last system part when distinct, and the final message boundary. These expose successively larger reusable prefixes for tools, the base agent, project instructions, and the active conversation. The rolling final-message boundary is the load-bearing detail in tool loops: it advances on every request so the previous cache entry stays within Anthropic's 20-block lookback.
Tools precede every system and conversation block in the provider prefix, so tool definitions must remain byte-stable and deterministically ordered for downstream breakpoints to remain reusable.
The math justifies the default: Anthropic's 5-minute cache write is 1.25× base, read is 0.1×, so a single reuse within 5 minutes already wins. One-shot completions below the per-model minimum-cacheable-token threshold silently no-op on the wire, so the worst case is harmless.
@@ -235,7 +237,7 @@ cache: {
### Manual hints
Inline `CacheHint` on any text / system / tool / tool-result part overrides automatic placement. The auto policy preserves manual hints; it only fills gaps.
Inline `CacheHint` on any text / system / tool / tool-result part overrides automatic placement. The auto policy preserves manual hints, counts them against Anthropic and Bedrock's four-breakpoint limit, and only fills the remaining slots.
```ts
LLM.request({
@@ -251,8 +253,8 @@ LLM.request({
| Protocol | `cache: "auto"` |
| ----------------------- | ------------------------------------------------------------------------- |
| Anthropic Messages | emits up to 3 `cache_control` markers (4-breakpoint cap enforced) |
| Bedrock Converse | emits up to 3 `cachePoint` blocks (4-breakpoint cap enforced) |
| Anthropic Messages | emits up to 4 `cache_control` markers (4-breakpoint cap enforced) |
| Bedrock Converse | emits up to 4 `cachePoint` blocks (4-breakpoint cap enforced) |
| OpenAI Chat / Responses | no-op (implicit caching above 1024 tokens) |
| Gemini | no-op (implicit caching on 2.5+; explicit `CachedContent` is out-of-band) |
+61 -25
View File
@@ -2,32 +2,31 @@
// the policy designates. Runs once at compile time, before the per-protocol
// body builder, so the existing inline-hint lowering path handles the rest.
//
// The default `"auto"` shape places one breakpoint at the last tool definition,
// one at the last system part, and one at the latest user message. This
// matches what production agent harnesses (LangChain's caching middleware,
// kern-ai's 10x cost-reduction playbook) converge on for tool-use loops: the
// latest user message stays put while a single turn explodes into many
// assistant/tool round-trips, so caching at that boundary lets every
// intra-turn API call hit the prefix.
// The default `"auto"` shape places breakpoints at the last tool definition,
// the first and last distinct system parts, and the conversation tail. This
// exposes reusable tool, base-agent, project, and session prefixes while
// advancing the tail after each tool result keeps the previous cache entry
// within Anthropic's 20-block lookback during long agent turns.
//
// Manual `cache: CacheHint` placements on individual parts are preserved
// this function only fills gaps the caller left empty.
// Manual `cache: CacheHint` placements on individual parts are preserved and
// count against the four-breakpoint budget; auto only fills remaining slots.
import { CacheHint, type CachePolicy, type CachePolicyObject } from "./schema/options"
import { LLMRequest, Message, ToolDefinition, type ContentPart } from "./schema/messages"
const AUTO: CachePolicyObject = {
tools: true,
system: true,
messages: "latest-user-message",
messages: { tail: 1 },
}
const NONE: CachePolicyObject = {}
const BREAKPOINT_CAP = 4
// Resolution rules:
// - undefined → "auto" — caching is on by default. The math favors it:
// Anthropic 5m-cache write is 1.25x base, read is 0.1x,
// so a single reuse within 5 minutes already wins.
// - "auto" → tools + system + latest user msg.
// - "auto" → tools + first/last system + final message boundary.
// - "none" → no auto placement; manual `CacheHint`s still flow.
// - object form → exactly what the caller asked for.
const resolve = (policy: CachePolicy | undefined): CachePolicyObject => {
@@ -44,18 +43,32 @@ const RESPECTS_INLINE_HINTS = new Set(["anthropic-messages", "bedrock-converse"]
const makeHint = (ttlSeconds: number | undefined): CacheHint =>
ttlSeconds !== undefined ? new CacheHint({ type: "ephemeral", ttlSeconds }) : new CacheHint({ type: "ephemeral" })
const markLastTool = (tools: ReadonlyArray<ToolDefinition>, hint: CacheHint): ReadonlyArray<ToolDefinition> => {
interface Budget {
remaining: number
}
const markLastTool = (
tools: ReadonlyArray<ToolDefinition>,
hint: CacheHint,
budget: Budget,
): ReadonlyArray<ToolDefinition> => {
if (tools.length === 0) return tools
const last = tools.length - 1
if (tools[last]!.cache) return tools
if (tools[last]!.cache || budget.remaining === 0) return tools
budget.remaining -= 1
return tools.map((tool, i) => (i === last ? new ToolDefinition({ ...tool, cache: hint }) : tool))
}
const markLastSystem = (system: LLMRequest["system"], hint: CacheHint): LLMRequest["system"] => {
const markSystemBoundaries = (system: LLMRequest["system"], hint: CacheHint, budget: Budget): LLMRequest["system"] => {
if (system.length === 0) return system
const last = system.length - 1
if (system[last]!.cache) return system
return system.map((part, i) => (i === last ? { ...part, cache: hint } : part))
let changed = false
const next = system.map((part, index) => {
if ((index !== 0 && index !== system.length - 1) || part.cache || budget.remaining === 0) return part
budget.remaining -= 1
changed = true
return { ...part, cache: hint }
})
return changed ? next : system
}
const lastIndexOfRole = (messages: ReadonlyArray<Message>, role: Message["role"]): number =>
@@ -64,14 +77,20 @@ const lastIndexOfRole = (messages: ReadonlyArray<Message>, role: Message["role"]
// Mark the last text part of `messages[index]`. If no text part exists, mark
// the last content part regardless of type — that's the breakpoint position
// in tool-result-only messages too.
const markMessageAt = (messages: ReadonlyArray<Message>, index: number, hint: CacheHint): ReadonlyArray<Message> => {
const markMessageAt = (
messages: ReadonlyArray<Message>,
index: number,
hint: CacheHint,
budget: Budget,
): ReadonlyArray<Message> => {
if (index < 0 || index >= messages.length) return messages
const target = messages[index]!
if (target.content.length === 0) return messages
const lastTextIndex = target.content.findLastIndex((part) => part.type === "text")
const markAt = lastTextIndex >= 0 ? lastTextIndex : target.content.length - 1
const existing = target.content[markAt]!
if ("cache" in existing && existing.cache) return messages
if (("cache" in existing && existing.cache) || budget.remaining === 0) return messages
budget.remaining -= 1
const nextContent = target.content.map((part, i) => (i === markAt ? ({ ...part, cache: hint } as ContentPart) : part))
const next = new Message({ ...target, content: nextContent })
// Single pass over `messages`, substituting the one updated entry. Long
@@ -86,25 +105,42 @@ const markMessages = (
messages: ReadonlyArray<Message>,
strategy: NonNullable<CachePolicyObject["messages"]>,
hint: CacheHint,
budget: Budget,
): ReadonlyArray<Message> => {
if (messages.length === 0) return messages
if (strategy === "latest-user-message") return markMessageAt(messages, lastIndexOfRole(messages, "user"), hint)
if (strategy === "latest-assistant") return markMessageAt(messages, lastIndexOfRole(messages, "assistant"), hint)
if (strategy === "latest-user-message")
return markMessageAt(messages, lastIndexOfRole(messages, "user"), hint, budget)
if (strategy === "latest-assistant")
return markMessageAt(messages, lastIndexOfRole(messages, "assistant"), hint, budget)
const start = Math.max(0, messages.length - strategy.tail)
let next = messages
for (let i = start; i < messages.length; i++) next = markMessageAt(next, i, hint)
for (let i = start; i < messages.length; i++) next = markMessageAt(next, i, hint, budget)
return next
}
const countHints = (request: LLMRequest) =>
request.tools.reduce((count, tool) => count + (tool.cache === undefined ? 0 : 1), 0) +
request.system.reduce((count, part) => count + (part.cache === undefined ? 0 : 1), 0) +
request.messages.reduce(
(count, message) =>
count +
message.content.reduce(
(contentCount, part) => contentCount + ("cache" in part && part.cache !== undefined ? 1 : 0),
0,
),
0,
)
export const applyCachePolicy = (request: LLMRequest): LLMRequest => {
if (!RESPECTS_INLINE_HINTS.has(request.model.route.id)) return request
const policy = resolve(request.cache)
if (!policy.tools && !policy.system && !policy.messages) return request
const hint = makeHint(policy.ttlSeconds)
const tools = policy.tools ? markLastTool(request.tools, hint) : request.tools
const system = policy.system ? markLastSystem(request.system, hint) : request.system
const messages = policy.messages ? markMessages(request.messages, policy.messages, hint) : request.messages
const budget = { remaining: Math.max(0, BREAKPOINT_CAP - countHints(request)) }
const tools = policy.tools ? markLastTool(request.tools, hint, budget) : request.tools
const system = policy.system ? markSystemBoundaries(request.system, hint, budget) : request.system
const messages = policy.messages ? markMessages(request.messages, policy.messages, hint, budget) : request.messages
if (tools === request.tools && system === request.system && messages === request.messages) return request
return LLMRequest.update(request, { tools, system, messages })
+98 -39
View File
@@ -1,4 +1,5 @@
import { Effect, Schema } from "effect"
import { Tool } from "@opencode-ai/schema/tool"
import { Route } from "../route/client"
import { Auth } from "../route/auth"
import { Endpoint } from "../route/endpoint"
@@ -7,8 +8,10 @@ import { Protocol } from "../route/protocol"
import {
LLMError,
LLMEvent,
mergeJsonRecords,
Usage,
type CacheHint,
type FinishReasonDetails,
type FinishReason,
type JsonSchema,
type LLMRequest,
@@ -17,7 +20,6 @@ import {
type ProviderMetadata,
type ToolCallPart,
type ToolDefinition,
type ToolContent,
type ToolResultPart,
} from "../schema"
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared"
@@ -233,12 +235,27 @@ const AnthropicBodyFields = {
export const AnthropicMessagesBody = Schema.Struct(AnthropicBodyFields)
export type AnthropicMessagesBody = Schema.Schema.Type<typeof AnthropicMessagesBody>
const AnthropicUsage = Schema.Struct({
input_tokens: Schema.optional(Schema.Number),
output_tokens: Schema.optional(Schema.Number),
cache_creation_input_tokens: optionalNull(Schema.Number),
cache_read_input_tokens: optionalNull(Schema.Number),
})
const AnthropicUsage = Schema.StructWithRest(
Schema.Struct({
input_tokens: Schema.optional(Schema.Number),
output_tokens: Schema.optional(Schema.Number),
cache_creation_input_tokens: optionalNull(Schema.Number),
cache_read_input_tokens: optionalNull(Schema.Number),
server_tool_use: optionalNull(
Schema.StructWithRest(
Schema.Struct({ web_search_requests: Schema.optional(Schema.Number) }),
[Schema.Record(Schema.String, Schema.Unknown)],
),
),
output_tokens_details: optionalNull(
Schema.StructWithRest(
Schema.Struct({ thinking_tokens: Schema.optional(Schema.Number) }),
[Schema.Record(Schema.String, Schema.Unknown)],
),
),
}),
[Schema.Record(Schema.String, Schema.Unknown)],
)
type AnthropicUsage = Schema.Schema.Type<typeof AnthropicUsage>
const AnthropicStreamBlock = Schema.Struct({
@@ -288,7 +305,12 @@ type AnthropicEvent = Schema.Schema.Type<typeof AnthropicEvent>
interface ParserState {
readonly tools: ToolStream.State<number>
readonly reasoningSignatures: Readonly<Record<number, string>>
readonly usage?: Usage
readonly pendingFinish?: {
readonly reason: FinishReasonDetails
readonly providerMetadata?: ProviderMetadata
}
readonly lifecycle: Lifecycle.State
}
@@ -403,7 +425,7 @@ const lowerMedia = Effect.fn("AnthropicMessages.lowerMedia")(function* (part: Me
// Tool results may carry structured text, images, and documents. Keep media as provider-native
// content instead of JSON-stringifying base64 into a prompt string.
const lowerToolResultContentItem = Effect.fn("AnthropicMessages.lowerToolResultContentItem")(function* (
item: ToolContent,
item: Tool.Content,
) {
if (item.type === "text") return { type: "text" as const, text: item.text } satisfies AnthropicTextBlock
return yield* lowerMedia({ type: "media", mediaType: item.mime, data: item.uri, filename: item.name })
@@ -414,7 +436,7 @@ const lowerToolResultContent = Effect.fn("AnthropicMessages.lowerToolResultConte
// with existing cassettes and provider expectations.
if (part.result.type !== "content") return ProviderShared.toolResultText(part)
// Preserve the narrowed array element type when compiled through a consumer package.
const content: ReadonlyArray<ToolContent> = part.result.value
const content: ReadonlyArray<Tool.Content> = part.result.value
return yield* Effect.forEach(content, lowerToolResultContentItem)
})
@@ -660,9 +682,8 @@ const mapFinishReason = (reason: string | null | undefined): FinishReason => {
// `input_tokens` is the *non-cached* count per the Messages API docs, with
// cache reads and writes as separate fields. We sum them to derive the
// inclusive `inputTokens` the rest of the contract expects. Extended
// thinking tokens are *not* broken out by Anthropic — they're billed as
// part of `output_tokens`, so `reasoningTokens` stays `undefined` and
// `outputTokens` carries the combined total.
// thinking tokens are included in `output_tokens`; newer responses also
// expose that subset through `output_tokens_details.thinking_tokens`.
const mapUsage = (usage: AnthropicUsage | undefined): Usage | undefined => {
if (!usage) return undefined
const nonCached = usage.input_tokens
@@ -675,6 +696,7 @@ const mapUsage = (usage: AnthropicUsage | undefined): Usage | undefined => {
nonCachedInputTokens: nonCached,
cacheReadInputTokens: cacheRead,
cacheWriteInputTokens: cacheWrite,
reasoningTokens: usage.output_tokens_details?.thinking_tokens,
totalTokens: ProviderShared.totalTokens(inputTokens, usage.output_tokens, undefined),
providerMetadata: { anthropic: usage },
})
@@ -693,18 +715,18 @@ const mergeUsage = (left: Usage | undefined, right: Usage | undefined) => {
const cacheWriteInputTokens = right.cacheWriteInputTokens ?? left.cacheWriteInputTokens
const inputTokens = ProviderShared.sumTokens(nonCachedInputTokens, cacheReadInputTokens, cacheWriteInputTokens)
const outputTokens = right.outputTokens ?? left.outputTokens
const reasoningTokens = right.reasoningTokens ?? left.reasoningTokens
return new Usage({
inputTokens,
outputTokens,
nonCachedInputTokens,
cacheReadInputTokens,
cacheWriteInputTokens,
reasoningTokens,
totalTokens: ProviderShared.totalTokens(inputTokens, outputTokens, undefined),
providerMetadata: {
anthropic: {
...left.providerMetadata?.["anthropic"],
...right.providerMetadata?.["anthropic"],
},
anthropic:
mergeJsonRecords(left.providerMetadata?.["anthropic"], right.providerMetadata?.["anthropic"]) ?? {},
},
})
}
@@ -763,6 +785,10 @@ const onContentBlockStart = (state: ParserState, event: AnthropicEvent): StepRes
tools: ToolStream.start(state.tools, event.index, {
id: block.id ?? String(event.index),
name: block.name ?? "",
input:
block.input !== undefined && (!ProviderShared.isRecord(block.input) || Object.keys(block.input).length > 0)
? ProviderShared.encodeJson(block.input)
: undefined,
providerExecuted: block.type === "server_tool_use",
}),
},
@@ -777,20 +803,31 @@ const onContentBlockStart = (state: ParserState, event: AnthropicEvent): StepRes
]
}
if (block.type === "text" && block.text) {
if (block.type === "text" && block.text !== undefined) {
const events: LLMEvent[] = []
const id = `text-${event.index ?? 0}`
const lifecycle = Lifecycle.textStart(state.lifecycle, events, id)
return [
{ ...state, lifecycle: Lifecycle.textDelta(state.lifecycle, events, `text-${event.index ?? 0}`, block.text) },
{ ...state, lifecycle: block.text ? Lifecycle.textDelta(lifecycle, events, id, block.text) : lifecycle },
events,
]
}
if (block.type === "thinking" && block.thinking) {
if (block.type === "thinking" && block.thinking !== undefined) {
const events: LLMEvent[] = []
const id = `reasoning-${event.index ?? 0}`
const providerMetadata = block.signature === undefined ? undefined : anthropicMetadata({ signature: block.signature })
const lifecycle = Lifecycle.reasoningStart(state.lifecycle, events, id, providerMetadata)
return [
{
...state,
lifecycle: Lifecycle.reasoningDelta(state.lifecycle, events, `reasoning-${event.index ?? 0}`, block.thinking),
lifecycle: block.thinking
? Lifecycle.reasoningDelta(lifecycle, events, id, block.thinking, providerMetadata)
: lifecycle,
reasoningSignatures:
event.index === undefined || block.signature === undefined
? state.reasoningSignatures
: { ...state.reasoningSignatures, [event.index]: block.signature },
},
events,
]
@@ -799,7 +836,7 @@ const onContentBlockStart = (state: ParserState, event: AnthropicEvent): StepRes
// Redacted thinking surfaces as an empty reasoning part carrying the opaque
// payload as `redactedData` metadata (same model as Vercel's
// @ai-sdk/anthropic). The existing content_block_stop closes the part.
if (block.type === "redacted_thinking" && block.data) {
if (block.type === "redacted_thinking" && block.data !== undefined) {
const events: LLMEvent[] = []
return [
{
@@ -847,18 +884,13 @@ const onContentBlockDelta = Effect.fn("AnthropicMessages.onContentBlockDelta")(f
}
if (delta?.type === "signature_delta" && delta.signature) {
const events: LLMEvent[] = []
const index = event.index ?? 0
return [
{
...state,
lifecycle: Lifecycle.reasoningEnd(
state.lifecycle,
events,
`reasoning-${event.index ?? 0}`,
anthropicMetadata({ signature: delta.signature }),
),
reasoningSignatures: { ...state.reasoningSignatures, [index]: delta.signature },
},
events,
NO_EVENTS,
] satisfies StepResult
}
@@ -889,31 +921,53 @@ const onContentBlockStop = Effect.fn("AnthropicMessages.onContentBlockStop")(fun
const result = yield* ToolStream.finish(ADAPTER, state.tools, event.index)
const events: LLMEvent[] = []
const resultEvents = result.events ?? []
const signature = state.reasoningSignatures[event.index]
const lifecycle = resultEvents.length
? Lifecycle.stepStart(state.lifecycle, events)
: Lifecycle.reasoningEnd(
Lifecycle.textEnd(state.lifecycle, events, `text-${event.index}`),
events,
`reasoning-${event.index}`,
signature === undefined ? undefined : anthropicMetadata({ signature }),
)
events.push(...resultEvents)
return [{ ...state, lifecycle, tools: result.tools }, events] satisfies StepResult
const reasoningSignatures = { ...state.reasoningSignatures }
delete reasoningSignatures[event.index]
return [{ ...state, lifecycle, tools: result.tools, reasoningSignatures }, events] satisfies StepResult
})
const onMessageDelta = (state: ParserState, event: AnthropicEvent): StepResult => {
const usage = mergeUsage(state.usage, mapUsage(event.usage))
return [
{
...state,
usage,
pendingFinish: {
reason: {
normalized: mapFinishReason(event.delta?.stop_reason),
raw: event.delta?.stop_reason ?? undefined,
},
providerMetadata:
event.delta?.stop_sequence === null || event.delta?.stop_sequence === undefined
? undefined
: anthropicMetadata({ stopSequence: event.delta.stop_sequence }),
},
},
NO_EVENTS,
]
}
const onMessageStop = (state: ParserState): StepResult => {
const events: LLMEvent[] = []
const lifecycle = Lifecycle.finish(state.lifecycle, events, {
reason: {
normalized: mapFinishReason(event.delta?.stop_reason),
raw: event.delta?.stop_reason ?? undefined,
reason: state.pendingFinish?.reason ?? {
normalized: "unknown",
raw: undefined,
},
usage,
providerMetadata: event.delta?.stop_sequence
? anthropicMetadata({ stopSequence: event.delta.stop_sequence })
: undefined,
usage: state.usage,
providerMetadata: state.pendingFinish?.providerMetadata,
})
return [{ ...state, lifecycle, usage }, events]
return [{ ...state, lifecycle }, events]
}
// Prefix `error.type` so overloads, rate limits, and quota errors are visible
@@ -938,6 +992,7 @@ const step = (state: ParserState, event: AnthropicEvent) => {
if (event.type === "content_block_delta") return onContentBlockDelta(state, event)
if (event.type === "content_block_stop") return onContentBlockStop(state, event)
if (event.type === "message_delta") return Effect.succeed(onMessageDelta(state, event))
if (event.type === "message_stop") return Effect.succeed(onMessageStop(state))
if (event.type === "error") return onError(event)
return Effect.succeed<StepResult>([state, NO_EVENTS])
}
@@ -958,7 +1013,11 @@ export const protocol = Protocol.make({
},
stream: {
event: Protocol.jsonEvent(AnthropicEvent),
initial: () => ({ tools: ToolStream.empty<number>(), lifecycle: Lifecycle.initial() }),
initial: () => ({
tools: ToolStream.empty<number>(),
reasoningSignatures: {},
lifecycle: Lifecycle.initial(),
}),
step,
},
})
+2 -2
View File
@@ -1,4 +1,5 @@
import { Effect, Schema } from "effect"
import { Tool } from "@opencode-ai/schema/tool"
import { Route } from "../route/client"
import { Auth } from "../route/auth"
import { Endpoint } from "../route/endpoint"
@@ -16,7 +17,6 @@ import {
type TextPart,
type ToolCallPart,
type ToolDefinition,
type ToolContent,
} from "../schema"
import { JsonObject, optionalArray, ProviderShared } from "./shared"
import { GeminiToolSchema } from "./utils/gemini-tool-schema"
@@ -289,7 +289,7 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
})
continue
}
const content: ReadonlyArray<ToolContent> = part.result.value
const content: ReadonlyArray<Tool.Content> = part.result.value
const text = content.filter((item) => item.type === "text").map((item) => item.text)
const media: GeminiInlineDataPart[] = []
for (const item of content) {
+154 -33
View File
@@ -1,4 +1,5 @@
import { Effect, Schema } from "effect"
import type { Content } from "@opencode-ai/schema/tool"
import { HttpTransport } from "../route/transport"
import { Protocol } from "../route/protocol"
import {
@@ -14,7 +15,6 @@ import {
type TextPart,
type ToolCallPart,
type ToolDefinition,
type ToolContent,
type ToolResultPart,
} from "../schema"
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared"
@@ -55,6 +55,9 @@ const OpenResponsesOutputText = Schema.Struct({
text: Schema.String,
})
export const MessagePhase = Schema.Literals(["commentary", "final_answer"])
type MessagePhase = Schema.Schema.Type<typeof MessagePhase>
const OpenResponsesReasoningSummaryText = Schema.Struct({
type: Schema.tag("summary_text"),
text: Schema.String,
@@ -86,10 +89,14 @@ const OpenResponsesFunctionCallOutput = Schema.Union([
Schema.Array(OpenResponsesFunctionCallOutputContent),
])
const OpenResponsesInputItem = Schema.Union([
export const InputItem = Schema.Union([
Schema.Struct({ role: Schema.tag("system"), content: Schema.String }),
Schema.Struct({ role: Schema.tag("user"), content: Schema.Array(OpenResponsesInputContent) }),
Schema.Struct({ role: Schema.tag("assistant"), content: Schema.Array(OpenResponsesOutputText) }),
Schema.Struct({
role: Schema.tag("assistant"),
content: Schema.Array(OpenResponsesOutputText),
phase: Schema.optionalKey(MessagePhase),
}),
OpenResponsesReasoningItem,
OpenResponsesItemReference,
Schema.Struct({
@@ -104,7 +111,14 @@ const OpenResponsesInputItem = Schema.Union([
output: OpenResponsesFunctionCallOutput,
}),
])
type OpenResponsesInputItem = Schema.Schema.Type<typeof OpenResponsesInputItem>
type OpenResponsesInputItem = Schema.Schema.Type<typeof InputItem>
type LoweredInputItem =
| OpenResponsesInputItem
| {
readonly role: "assistant"
readonly content: ReadonlyArray<{ readonly type: "output_text"; readonly text: string }>
readonly phase?: MessagePhase | null
}
// Mutable counterpart of the schema reasoning item so `lowerMessages` can fold
// multiple streamed summary parts into the same item before flushing.
@@ -135,7 +149,7 @@ export const ToolChoice = Schema.Union([
// transports in sync without a destructure-and-strip dance.
export const coreFields = {
model: Schema.String,
input: Schema.Array(OpenResponsesInputItem),
input: Schema.Array(InputItem),
instructions: Schema.optional(Schema.String),
tools: optionalArray(Tool),
tool_choice: Schema.optional(ToolChoice),
@@ -167,7 +181,12 @@ export type OpenResponsesBody = Schema.Schema.Type<typeof OpenResponsesBody>
const OpenResponsesUsage = Schema.Struct({
input_tokens: Schema.optional(Schema.Number),
input_tokens_details: optionalNull(Schema.Struct({ cached_tokens: Schema.optional(Schema.Number) })),
input_tokens_details: optionalNull(
Schema.Struct({
cached_tokens: Schema.optional(Schema.Number),
cache_write_tokens: Schema.optional(Schema.Number),
}),
),
output_tokens: Schema.optional(Schema.Number),
output_tokens_details: optionalNull(Schema.Struct({ reasoning_tokens: Schema.optional(Schema.Number) })),
total_tokens: Schema.optional(Schema.Number),
@@ -201,6 +220,7 @@ export const Event = Schema.StructWithRest(
Schema.Struct({
type: Schema.String,
delta: Schema.optional(Schema.String),
text: Schema.optional(Schema.String),
item_id: Schema.optional(Schema.String),
summary_index: Schema.optional(Schema.Number),
item: Schema.optional(StreamItem),
@@ -233,6 +253,7 @@ export interface Extension {
readonly media: ProviderShared.ValidatedMedia
readonly request: LLMRequest
}) => MediaInput | undefined
readonly messagePhase?: (value: unknown) => MessagePhase | null | undefined
}
const BASE: Extension = { id: ADAPTER, name: NAME }
@@ -244,6 +265,9 @@ export interface ParserState {
readonly tools: ToolStream.State<string>
readonly hasFunctionCall: boolean
readonly lifecycle: Lifecycle.State
readonly messageItems: ReadonlySet<string>
readonly messagePhase: (value: unknown) => MessagePhase | null | undefined
readonly messagePhases: Readonly<Record<string, MessagePhase | null>>
readonly reasoningItems: Readonly<Record<string, ReasoningStreamItem>>
readonly store: boolean | undefined
}
@@ -347,7 +371,7 @@ const lowerUserContent = Effect.fn("OpenResponses.lowerUserContent")(function* (
// Tool results may carry structured text, images, and files. Keep media as provider-native
// content instead of JSON-stringifying base64 into a prompt string.
const lowerToolResultContentItem = Effect.fn("OpenResponses.lowerToolResultContentItem")(function* (
item: ToolContent,
item: Content,
request: LLMRequest,
extension: Extension,
) {
@@ -368,14 +392,14 @@ const lowerToolResultOutput = Effect.fn("OpenResponses.lowerToolResultOutput")(f
// compatibility with existing cassettes and provider expectations.
if (part.result.type !== "content") return ProviderShared.toolResultText(part)
// Preserve the narrowed array element type when compiled through a consumer package.
const content: ReadonlyArray<ToolContent> = part.result.value
const content: ReadonlyArray<Content> = part.result.value
return yield* Effect.forEach(content, (item) => lowerToolResultContentItem(item, request, extension))
})
const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (request: LLMRequest, extension: Extension) {
const system: OpenResponsesInputItem[] =
const system: LoweredInputItem[] =
request.system.length === 0 ? [] : [{ role: "system", content: ProviderShared.joinText(request.system) }]
const input: OpenResponsesInputItem[] = [...system]
const input: LoweredInputItem[] = [...system]
const store = OpenResponsesOptions.resolve(request).store
const providerMetadataKey = request.model.route.providerMetadataKey ?? "openresponses"
@@ -407,7 +431,27 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
const hostedToolReferences = new Set<string>()
const flushText = () => {
if (content.length === 0) return
input.push({ role: "assistant", content: content.map((part) => ({ type: "output_text", text: part.text })) })
const groups = content.reduce<Array<{ phase: MessagePhase | null | undefined; parts: TextPart[] }>>(
(groups, part) => {
const metadata = part.providerMetadata?.[providerMetadataKey]
const phase =
ProviderShared.isRecord(metadata)
? messagePhase(metadata.phase, extension)
: undefined
const group = groups.at(-1)
if (group && group.phase === phase) group.parts.push(part)
else groups.push({ phase, parts: [part] })
return groups
},
[],
)
input.push(
...groups.map((group) => ({
role: "assistant" as const,
content: group.parts.map((part) => ({ type: "output_text" as const, text: part.text })),
...(group.phase === undefined ? {} : { phase: group.phase }),
})),
)
content.splice(0, content.length)
}
for (const part of message.content) {
@@ -452,7 +496,7 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
if (store !== false && itemID && !hostedToolReferences.has(itemID))
input.push({ type: "item_reference", id: itemID })
if (store === false && part.result.type === "content") {
const content: ReadonlyArray<ToolContent> = part.result.value
const content: ReadonlyArray<Content> = part.result.value
input.push({
role: "user",
content: yield* Effect.forEach(content, (item) => lowerToolResultContentItem(item, request, extension)),
@@ -508,9 +552,9 @@ const lowerOptions = (request: LLMRequest) => {
}
}
export const fromRequest = Effect.fn("OpenResponses.fromRequest")(function* (
export const fromRequestWithExtension = Effect.fn("OpenResponses.fromRequestWithExtension")(function* (
request: LLMRequest,
extension: Extension = BASE,
extension: Extension,
) {
const generation = request.generation
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
@@ -536,23 +580,31 @@ export const fromRequest = Effect.fn("OpenResponses.fromRequest")(function* (
}
})
const decodeBody = ProviderShared.validateWith(Schema.decodeUnknownEffect(OpenResponsesBody))
export const fromRequest = Effect.fn("OpenResponses.fromRequest")(function* (request: LLMRequest) {
return yield* decodeBody(yield* fromRequestWithExtension(request, BASE))
})
// =============================================================================
// Stream Parsing
// =============================================================================
// Responses APIs report `input_tokens` (inclusive total) with a
// `cached_tokens` subset, and `output_tokens` (inclusive total) with a
// `reasoning_tokens` subset. Pass the totals through and derive the
// cached-read and cache-write subsets, and `output_tokens` (inclusive total)
// with a `reasoning_tokens` subset. Pass the totals through and derive the
// non-cached breakdown.
const mapUsage = (usage: OpenResponsesUsage | null | undefined, providerMetadataKey: string) => {
if (!usage) return undefined
const cached = usage.input_tokens_details?.cached_tokens
const cacheWrite = usage.input_tokens_details?.cache_write_tokens
const reasoning = usage.output_tokens_details?.reasoning_tokens
const nonCached = ProviderShared.subtractTokens(usage.input_tokens, cached)
const nonCached = ProviderShared.subtractTokens(usage.input_tokens, ProviderShared.sumTokens(cached, cacheWrite))
return new Usage({
inputTokens: usage.input_tokens,
outputTokens: usage.output_tokens,
nonCachedInputTokens: nonCached,
cacheReadInputTokens: cached,
cacheWriteInputTokens: cacheWrite,
reasoningTokens: reasoning,
totalTokens: ProviderShared.totalTokens(usage.input_tokens, usage.output_tokens, usage.total_tokens),
providerMetadata: { [providerMetadataKey]: usage },
@@ -588,24 +640,30 @@ const NO_EVENTS: StepResult["1"] = []
const TERMINAL_TYPES = new Set(["response.completed", "response.incomplete", "response.failed"])
export const terminal = (event: Event) => TERMINAL_TYPES.has(event.type)
const onOutputTextDelta = (state: ParserState, event: Event): StepResult => {
const onOutputTextDelta = (state: ParserState, event: Event, id: string): StepResult => {
if (!event.delta) return [state, NO_EVENTS]
const events: LLMEvent[] = []
const phase = state.messagePhases[id]
const metadata = phase === undefined ? undefined : providerMetadata(state, { phase })
const lifecycle = Lifecycle.textStart(state.lifecycle, events, id, metadata)
return [
{ ...state, lifecycle: Lifecycle.textDelta(state.lifecycle, events, event.item_id ?? "text-0", event.delta) },
{ ...state, lifecycle: Lifecycle.textDelta(lifecycle, events, id, event.delta) },
events,
]
}
const onOutputTextDone = (state: ParserState, event: Event): StepResult => {
const onOutputTextDone = (state: ParserState, event: Event, id: string): StepResult => {
if (state.messageItems.has(id)) {
if (state.lifecycle.text.has(id) || event.text === undefined) return [state, NO_EVENTS]
return onOutputTextDelta(state, { ...event, delta: event.text }, id)
}
const events: LLMEvent[] = []
return [{ ...state, lifecycle: Lifecycle.textEnd(state.lifecycle, events, event.item_id ?? "text-0") }, events]
return [{ ...state, lifecycle: Lifecycle.textEnd(state.lifecycle, events, id) }, events]
}
export const onReasoningDelta = (state: ParserState, event: Event): StepResult => {
export const onReasoningDelta = (state: ParserState, event: Event, itemID: string): 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 [
@@ -636,6 +694,18 @@ const reasoningMetadata = (state: ParserState, item: StreamItem & { id: string }
// best-effort, not guaranteed.
const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
const item = event.item
if (item?.type === "message" && item.id)
return [
{
...state,
messageItems: new Set([...state.messageItems, item.id]),
messagePhases: (() => {
const phase = state.messagePhase(item.phase)
return phase === undefined ? state.messagePhases : { ...state.messagePhases, [item.id]: phase }
})(),
},
NO_EVENTS,
]
if (item && isReasoningItem(item)) {
const events: LLMEvent[] = []
return [
@@ -792,7 +862,28 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
const item = event.item
if (!item) return [state, NO_EVENTS] satisfies StepResult
if (item.type === "message" && item.id) return onOutputTextDone(state, { ...event, item_id: item.id })
if (item.type === "message" && item.id) {
const itemPhase = state.messagePhase(item.phase)
const phase = itemPhase === undefined ? state.messagePhases[item.id] : itemPhase
const events: LLMEvent[] = []
const messageItems = new Set(state.messageItems)
messageItems.delete(item.id)
const { [item.id]: _phase, ...messagePhases } = state.messagePhases
return [
{
...state,
lifecycle: Lifecycle.textEnd(
state.lifecycle,
events,
item.id,
phase === undefined ? undefined : providerMetadata(state, { phase }),
),
messageItems,
messagePhases,
},
events,
] satisfies StepResult
}
if (item.type === "function_call") {
if (!item.id || !item.call_id || !item.name) return [state, NO_EVENTS] satisfies StepResult
@@ -892,19 +983,41 @@ const providerError = (state: ParserState, event: Event, fallback: string) => {
}
export const step = (state: ParserState, event: Event) => {
if (event.type === "response.output_text.delta") return Effect.succeed(onOutputTextDelta(state, event))
if (event.type === "response.output_text.done") return Effect.succeed(onOutputTextDone(state, event))
if (event.type === "response.reasoning.delta" || event.type === "response.reasoning_summary_text.delta")
return Effect.succeed(onReasoningDelta(state, event))
if (event.type === "response.reasoning.done" || event.type === "response.reasoning_summary_text.done")
if (event.type === "response.output_text.delta" || event.type === "response.output_text.done") {
if (!event.item_id) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
return Effect.succeed(
event.type === "response.output_text.delta"
? onOutputTextDelta(state, event, event.item_id)
: onOutputTextDone(state, event, event.item_id),
)
}
if (event.type === "response.reasoning.delta" || event.type === "response.reasoning_summary_text.delta") {
if (!event.item_id) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
return Effect.succeed(onReasoningDelta(state, event, event.item_id))
}
if (event.type === "response.reasoning.done" || event.type === "response.reasoning_summary_text.done") {
if (!event.item_id) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
return Effect.succeed(onReasoningDone(state, event))
}
if (event.type === "response.reasoning_summary_part.added")
return Effect.succeed(onReasoningSummaryPartAdded(state, event))
return event.item_id
? Effect.succeed(onReasoningSummaryPartAdded(state, event))
: ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
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))
return event.item_id
? Effect.succeed(onReasoningSummaryPartDone(state, event))
: ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
if (event.type === "response.output_item.added") {
if (event.item?.type === "message" && !event.item.id)
return ProviderShared.eventError(state.id, `${event.type} message is missing id`)
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)
if (event.type === "response.output_item.done") {
if (event.item?.type === "message" && !event.item.id)
return ProviderShared.eventError(state.id, `${event.type} message is missing id`)
return onOutputItemDone(state, event)
}
if (event.type === "response.completed" || event.type === "response.incomplete")
return Effect.succeed(onResponseFinish(state, event))
if (event.type === "response.failed") return providerError(state, event, `${state.name} response failed`)
@@ -926,10 +1039,18 @@ export const initial = (request: LLMRequest, extension: Extension = BASE): Parse
hasFunctionCall: false,
tools: ToolStream.empty<string>(),
lifecycle: Lifecycle.initial(),
messageItems: new Set<string>(),
messagePhase: (value) => messagePhase(value, extension),
messagePhases: {},
reasoningItems: {},
store: OpenResponsesOptions.resolve(request).store,
})
const messagePhase = (value: unknown, extension: Extension): MessagePhase | null | undefined => {
if (value === "commentary" || value === "final_answer") return value
return extension.messagePhase?.(value)
}
export const protocol = Protocol.make({
id: ADAPTER,
body: {
+9 -6
View File
@@ -1,4 +1,5 @@
import { Effect, Schema } from "effect"
import { Tool } from "@opencode-ai/schema/tool"
import { Route } from "../route/client"
import { Auth } from "../route/auth"
import { Endpoint } from "../route/endpoint"
@@ -17,7 +18,6 @@ import {
type TextPart,
type ToolCallPart,
type ToolDefinition,
type ToolContent,
} from "../schema"
import { classifyProviderFailure } from "../provider-error"
import { isRecord, JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared"
@@ -131,6 +131,7 @@ const OpenAIChatUsage = Schema.Struct({
prompt_tokens_details: optionalNull(
Schema.Struct({
cached_tokens: Schema.optional(Schema.Number),
cache_write_tokens: Schema.optional(Schema.Number),
}),
),
completion_tokens_details: optionalNull(
@@ -334,7 +335,7 @@ const lowerToolMessages = Effect.fn("OpenAIChat.lowerToolMessages")(function* (m
messages.push({ role: "tool", tool_call_id: part.id, content: ProviderShared.toolResultText(part) })
continue
}
const content: ReadonlyArray<ToolContent> = part.result.value
const content: ReadonlyArray<Tool.Content> = part.result.value
const text = content.filter((item) => item.type === "text").map((item) => item.text)
messages.push({ role: "tool", tool_call_id: part.id, content: text.join("\n") })
const files = content.filter((item) => item.type === "file")
@@ -453,20 +454,22 @@ const mapFinishReason = (reason: string | null | undefined): FinishReason => {
}
// OpenAI Chat reports `prompt_tokens` (inclusive total) with a
// `cached_tokens` subset, and `completion_tokens` (inclusive total) with
// a `reasoning_tokens` subset. We pass the inclusive totals through and
// derive the non-cached breakdown so the `LLM.Usage` contract is
// cached-read and cache-write subsets, and `completion_tokens` (inclusive
// total) with a `reasoning_tokens` subset. We pass the inclusive totals
// through and derive the non-cached breakdown so the `LLM.Usage` contract is
// satisfied on both sides.
const mapUsage = (usage: OpenAIChatEvent["usage"]): Usage | undefined => {
if (!usage) return undefined
const cached = usage.prompt_tokens_details?.cached_tokens
const cacheWrite = usage.prompt_tokens_details?.cache_write_tokens
const reasoning = usage.completion_tokens_details?.reasoning_tokens
const nonCached = ProviderShared.subtractTokens(usage.prompt_tokens, cached)
const nonCached = ProviderShared.subtractTokens(usage.prompt_tokens, ProviderShared.sumTokens(cached, cacheWrite))
return new Usage({
inputTokens: usage.prompt_tokens,
outputTokens: usage.completion_tokens,
nonCachedInputTokens: nonCached,
cacheReadInputTokens: cached,
cacheWriteInputTokens: cacheWrite,
reasoningTokens: reasoning,
totalTokens: ProviderShared.totalTokens(usage.prompt_tokens, usage.completion_tokens, usage.total_tokens),
providerMetadata: { openai: usage },
+18 -3
View File
@@ -35,8 +35,18 @@ const OpenAIResponsesToolChoice = Schema.Union([
Schema.Struct({ type: Schema.tag("image_generation") }),
])
const OpenAIResponsesInputItem = Schema.Union([
Schema.Struct({
role: Schema.tag("assistant"),
content: Schema.Array(Schema.Struct({ type: Schema.tag("output_text"), text: Schema.String })),
phase: Schema.optionalKey(Schema.NullOr(OpenResponses.MessagePhase)),
}),
OpenResponses.InputItem,
])
const OpenAIResponsesCoreFields = {
...OpenResponses.coreFields,
input: Schema.Array(OpenAIResponsesInputItem),
tools: optionalArray(OpenAIResponsesTools),
tool_choice: Schema.optional(OpenAIResponsesToolChoice),
}
@@ -60,6 +70,7 @@ const encodeWebSocketMessage = Schema.encodeSync(Schema.fromJsonString(OpenAIRes
const extension = {
id: ADAPTER,
name: NAME,
messagePhase: (value: unknown) => (value === null ? null : undefined),
lowerMedia: ({ part, media, request }) => {
if (request.model.provider !== "xai" || media.mime !== "application/pdf") return undefined
return {
@@ -102,7 +113,7 @@ const lowerToolChoice = (toolChoice: NonNullable<LLMRequest["toolChoice"]>, tool
})
const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request: LLMRequest) {
const body = yield* OpenResponses.fromRequest(
const body = yield* OpenResponses.fromRequestWithExtension(
LLMRequest.update(request, { tools: [], toolChoice: undefined }),
extension,
)
@@ -208,9 +219,13 @@ const onHostedToolDone = Effect.fn("OpenAIResponses.onHostedToolDone")(function*
const step = (state: OpenResponses.ParserState, event: OpenResponses.Event) => {
if (event.type === "response.reasoning_text.delta" || event.type === "response.reasoning_summary.delta")
return Effect.succeed(OpenResponses.onReasoningDelta(state, event))
return event.item_id
? Effect.succeed(OpenResponses.onReasoningDelta(state, event, event.item_id))
: ProviderShared.eventError(ADAPTER, `${event.type} is missing item_id`)
if (event.type === "response.reasoning_text.done" || event.type === "response.reasoning_summary.done")
return Effect.succeed(OpenResponses.onReasoningDone(state, event))
return event.item_id
? Effect.succeed(OpenResponses.onReasoningDone(state, event))
: ProviderShared.eventError(ADAPTER, `${event.type} is missing item_id`)
if (event.type === "response.output_item.done" && event.item && isHostedToolItem(event.item))
return onHostedToolDone(state, event.item)
return OpenResponses.step(state, event)
+2 -2
View File
@@ -1,4 +1,5 @@
import { Buffer } from "node:buffer"
import { Tool } from "@opencode-ai/schema/tool"
import { Effect, Schema, Stream } from "effect"
import * as Sse from "effect/unstable/encoding/Sse"
import { Headers, HttpClientRequest } from "effect/unstable/http"
@@ -9,7 +10,6 @@ import {
type ContentPart,
type LLMRequest,
type MediaPart,
type ToolFileContent,
type TextPart,
type ToolResultPart,
} from "../schema"
@@ -206,7 +206,7 @@ export const validateMedia = Effect.fn("ProviderShared.validateMedia")(function*
return { mime, base64, dataUrl: `data:${mime};base64,${base64}`, bytes } satisfies ValidatedMedia
})
export const validateToolFile = (route: string, part: ToolFileContent, supportedMimes: ReadonlySet<string>) =>
export const validateToolFile = (route: string, part: Tool.FileContent, supportedMimes: ReadonlySet<string>) =>
validateMedia(route, { type: "media", mediaType: part.mime, data: part.uri, filename: part.name }, supportedMimes)
export const trimBaseUrl = (value: string) => value.replace(/\/+$/, "")
+9 -6
View File
@@ -14,16 +14,19 @@ export const stepStart = (state: State, events: LLMEvent[]): State => {
return { ...state, stepStarted: true }
}
export const textDelta = (state: State, events: LLMEvent[], id: string, text: string): State => {
export const textStart = (state: State, events: LLMEvent[], id: string, providerMetadata?: ProviderMetadata): State => {
if (state.text.has(id)) return state
const stepped = stepStart(state, events)
if (stepped.text.has(id)) {
events.push(LLMEvent.textDelta({ id, text }))
return stepped
}
events.push(LLMEvent.textStart({ id }), LLMEvent.textDelta({ id, text }))
events.push(LLMEvent.textStart({ id, providerMetadata }))
return { ...stepped, text: new Set([...stepped.text, id]) }
}
export const textDelta = (state: State, events: LLMEvent[], id: string, text: string): State => {
const started = textStart(state, events, id)
events.push(LLMEvent.textDelta({ id, text }))
return started
}
export const reasoningStart = (
state: State,
events: LLMEvent[],
+2 -5
View File
@@ -1,4 +1,5 @@
import { Schema } from "effect"
import { Tool } from "@opencode-ai/schema/tool"
import { ModelID, ProviderID, ProviderMetadata, RouteID } from "./ids"
export const ProviderFailureClassification = Schema.Literal("context-overflow")
@@ -152,8 +153,4 @@ export class LLMError extends Schema.TaggedErrorClass<LLMError>()("LLM.Error", {
* Anything thrown or yielded by a handler that is not a `ToolFailure` is
* treated as a defect and fails the stream.
*/
export class ToolFailure extends Schema.TaggedErrorClass<ToolFailure>()("LLM.ToolFailure", {
message: Schema.String,
error: Schema.optional(Schema.Defect()),
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
}) {}
export class ToolFailure extends Tool.Error {}
+3 -3
View File
@@ -40,9 +40,9 @@ import { ProviderFailureClassification } from "./errors"
* - Anthropic and Bedrock report the input breakdown natively: Anthropic's
* `input_tokens` and Bedrock's `inputTokens` are non-cached only. Their
* mappers sum the breakdown to derive the inclusive `inputTokens`.
* Anthropic does *not* break extended-thinking out of `output_tokens`, so
* `reasoningTokens` is `undefined` and `outputTokens` carries the
* combined total — a documented limitation of the Anthropic API.
* Anthropic's `outputTokens` includes extended thinking. Newer responses
* expose that subset as `output_tokens_details.thinking_tokens`, which maps
* to `reasoningTokens`; older responses leave it undefined.
*
* `providerMetadata` always carries the provider's raw usage payload —
* keyed by provider name (`{ openai: ... }`, `{ anthropic: ... }`, etc.)
+5 -7
View File
@@ -1,5 +1,5 @@
import { Schema } from "effect"
import { ToolContent, ToolFileContent, ToolTextContent } from "@opencode-ai/schema/llm"
import { Tool } from "@opencode-ai/schema/tool"
import { JsonSchema, MessageRole, ProviderMetadata } from "./ids"
import { CacheHint, CachePolicy, GenerationOptions, HttpOptions, ModelSchema, ProviderOptions } from "./options"
import { isRecord } from "../utils/record"
@@ -40,8 +40,6 @@ export const MediaPart = Schema.Struct({
}).annotate({ identifier: "LLM.Content.Media" })
export type MediaPart = Schema.Schema.Type<typeof MediaPart>
export { ToolContent, ToolFileContent, ToolTextContent }
const isToolResultValue = (value: unknown): value is ToolResultValue =>
isRecord(value) &&
(value.type === "text" || value.type === "json" || value.type === "error" || value.type === "content") &&
@@ -63,7 +61,7 @@ export const ToolResultValue = Object.assign(
}),
Schema.Struct({
type: Schema.Literal("content"),
value: Schema.Array(ToolContent),
value: Schema.Array(Tool.Content),
}),
]).annotate({ identifier: "LLM.ToolResult" }),
{
@@ -79,16 +77,16 @@ export type ToolResultValue = Schema.Schema.Type<typeof ToolResultValue>
export interface ToolOutput {
readonly structured: unknown
readonly content: ReadonlyArray<ToolContent>
readonly content: ReadonlyArray<Tool.Content>
}
export const ToolOutput = Object.assign(
Schema.Struct({
structured: Schema.Unknown,
content: Schema.Array(ToolContent),
content: Schema.Array(Tool.Content),
}).annotate({ identifier: "LLM.ToolOutput" }),
{
make: (structured: unknown, content: ReadonlyArray<ToolContent> = []): ToolOutput => ({ structured, content }),
make: (structured: unknown, content: ReadonlyArray<Tool.Content> = []): ToolOutput => ({ structured, content }),
fromResultValue: (result: ToolResultValue): ToolOutput | undefined => {
switch (result.type) {
case "json":
+5 -5
View File
@@ -251,11 +251,11 @@ export class CacheHint extends Schema.Class<CacheHint>("LLM.CacheHint")({
// Auto-placement policy for prompt caching. The protocol-neutral lowering step
// reads this and injects `CacheHint`s at the configured boundaries; the
// per-protocol body builders then translate those hints into wire markers as
// usual. `"auto"` is the recommended default for agent loops — it places one
// breakpoint at the last tool definition, one at the last system part, and one
// at the latest user message. The combination of provider invalidation
// hierarchy (tools → system → messages) and Anthropic/Bedrock's 20-block
// lookback means three trailing breakpoints reliably cover the static prefix.
// usual. `"auto"` is the recommended default for agent loops — it places
// breakpoints at the last tool definition, the first and last distinct system
// parts, and the conversation tail. The rolling message breakpoint keeps a
// prior cache entry within Anthropic/Bedrock's 20-block lookback during long
// tool loops.
//
// Pass `"none"` to opt out entirely (the legacy behavior). Pass the granular
// object form to override individual choices.
+1 -1
View File
@@ -28,7 +28,7 @@ export const dispatch = (tools: Tools, call: ToolCallPart): Effect.Effect<Dispat
return decodeAndExecute(tool, call).pipe(
Effect.map((value) => result(call, value)),
Effect.catchTag("LLM.ToolFailure", (failure) =>
Effect.catchTag("Tool.Error", (failure) =>
Effect.succeed(result(call, { type: "error", value: failure.message }, failure.error)),
),
)
+6 -6
View File
@@ -1,7 +1,7 @@
import { Effect, JsonSchema, Schema } from "effect"
import { Tool } from "@opencode-ai/schema/tool"
import type {
ToolCallPart,
ToolContent,
ToolDefinition as ToolDefinitionClass,
ToolOutput as ToolOutputType,
} from "./schema"
@@ -31,7 +31,7 @@ export interface ToolModelOutputInput<Parameters, Output> {
export type ToolToModelOutput<Parameters extends ToolSchema<any>, Success extends ToolSchema<any>> = (
input: ToolModelOutputInput<Schema.Schema.Type<Parameters>, Success["Encoded"]>,
) => ReadonlyArray<ToolContent>
) => ReadonlyArray<Tool.Content>
/**
* A type-safe LLM tool. Each tool bundles its own description, parameter
@@ -95,7 +95,7 @@ type DynamicToolConfig = {
readonly jsonSchema: JsonSchema.JsonSchema
readonly outputSchema?: JsonSchema.JsonSchema
readonly execute?: (params: unknown, context?: ToolExecuteContext) => Effect.Effect<unknown, ToolFailure>
readonly toModelOutput?: (input: ToolModelOutputInput<unknown, unknown>) => ReadonlyArray<ToolContent>
readonly toModelOutput?: (input: ToolModelOutputInput<unknown, unknown>) => ReadonlyArray<Tool.Content>
readonly toStructuredOutput?: (output: unknown) => unknown
}
@@ -151,7 +151,7 @@ export function make(config: {
readonly jsonSchema: JsonSchema.JsonSchema
readonly outputSchema?: JsonSchema.JsonSchema
readonly execute: (params: unknown, context?: ToolExecuteContext) => Effect.Effect<unknown, ToolFailure>
readonly toModelOutput?: (input: ToolModelOutputInput<unknown, unknown>) => ReadonlyArray<ToolContent>
readonly toModelOutput?: (input: ToolModelOutputInput<unknown, unknown>) => ReadonlyArray<Tool.Content>
readonly toStructuredOutput?: (output: unknown) => unknown
}): AnyExecutableTool
export function make(config: {
@@ -159,7 +159,7 @@ export function make(config: {
readonly jsonSchema: JsonSchema.JsonSchema
readonly outputSchema?: JsonSchema.JsonSchema
readonly execute?: undefined
readonly toModelOutput?: (input: ToolModelOutputInput<unknown, unknown>) => ReadonlyArray<ToolContent>
readonly toModelOutput?: (input: ToolModelOutputInput<unknown, unknown>) => ReadonlyArray<Tool.Content>
readonly toStructuredOutput?: (output: unknown) => unknown
}): AnyTool
export function make(config: TypedToolConfig | DynamicToolConfig): AnyTool {
@@ -236,7 +236,7 @@ const toJsonSchema = (schema: Schema.Top): JsonSchema.JsonSchema => {
}
const project = (
toModelOutput: ((input: ToolModelOutputInput<any, any>) => ReadonlyArray<ToolContent>) | undefined,
toModelOutput: ((input: ToolModelOutputInput<any, any>) => ReadonlyArray<Tool.Content>) | undefined,
toStructuredOutput: ((output: unknown) => unknown) | undefined,
parameters: unknown,
callID: ToolCallPart["id"],
+68 -8
View File
@@ -39,8 +39,8 @@ describe("applyCachePolicy", () => {
}),
)
// No explicit cache field → auto policy fires → last system part + latest
// user message both get cache_control markers.
// A single system block is both the first and last boundary, so the auto
// policy deduplicates it and still marks the conversation tail.
expect(prepared.body).toMatchObject({
system: [{ type: "text", text: "You are concise.", cache_control: { type: "ephemeral" } }],
messages: [{ role: "user", content: [{ type: "text", text: "hi", cache_control: { type: "ephemeral" } }] }],
@@ -48,12 +48,15 @@ describe("applyCachePolicy", () => {
}),
)
it.effect("'auto' marks the last tool, last system part, and latest user message on Anthropic", () =>
it.effect("'auto' marks the last tool, first and last system parts, and final message boundary on Anthropic", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare(
LLM.request({
model: anthropicModel,
system: "Sys A",
system: [
{ type: "text", text: "Base agent" },
{ type: "text", text: "Project instructions" },
],
tools: [{ name: "t1", description: "t1", inputSchema: { type: "object", properties: {} } }],
messages: [
Message.user("first user"),
@@ -66,7 +69,10 @@ describe("applyCachePolicy", () => {
expect(prepared.body).toMatchObject({
tools: [{ name: "t1", cache_control: { type: "ephemeral" } }],
system: [{ type: "text", text: "Sys A", cache_control: { type: "ephemeral" } }],
system: [
{ type: "text", text: "Base agent", cache_control: { type: "ephemeral" } },
{ type: "text", text: "Project instructions", cache_control: { type: "ephemeral" } },
],
messages: [
{ role: "user", content: [{ type: "text", text: "first user" }] },
{ role: "assistant", content: [{ type: "text", text: "assistant reply" }] },
@@ -120,7 +126,10 @@ describe("applyCachePolicy", () => {
const prepared = yield* LLMClient.prepare(
LLM.request({
model: bedrockModel,
system: "Sys",
system: [
{ type: "text", text: "Base agent" },
{ type: "text", text: "Project instructions" },
],
tools: [{ name: "t1", description: "t1", inputSchema: { type: "object", properties: {} } }],
messages: [Message.user("first user"), Message.assistant("reply"), Message.user("latest user")],
cache: "auto",
@@ -131,7 +140,12 @@ describe("applyCachePolicy", () => {
toolConfig: {
tools: [{ toolSpec: { name: "t1" } }, { cachePoint: { type: "default" } }],
},
system: [{ text: "Sys" }, { cachePoint: { type: "default" } }],
system: [
{ text: "Base agent" },
{ cachePoint: { type: "default" } },
{ text: "Project instructions" },
{ cachePoint: { type: "default" } },
],
messages: [
{ role: "user", content: [{ text: "first user" }] },
{ role: "assistant", content: [{ text: "reply" }] },
@@ -193,9 +207,55 @@ describe("applyCachePolicy", () => {
}),
)
const body = prepared.body as { system: Array<{ text: string; cache_control?: unknown }> }
const body = prepared.body as {
system: Array<{ text: string; cache_control?: unknown }>
messages: Array<{ content: Array<{ cache_control?: unknown }> }>
}
expect(body.system[0]?.cache_control).toEqual({ type: "ephemeral", ttl: "1h" })
expect(body.system[1]?.cache_control).toEqual({ type: "ephemeral" })
expect(body.messages[0]?.content[0]?.cache_control).toEqual({ type: "ephemeral" })
}),
)
it.effect("auto policy stays within the four-breakpoint cap when preserving manual hints", () =>
Effect.gen(function* () {
const request = LLM.request({
model: anthropicModel,
system: [
{ type: "text", text: "Base agent" },
{
type: "text",
text: "Manual context",
cache: new CacheHint({ type: "ephemeral", ttlSeconds: 3600 }),
},
{ type: "text", text: "Project instructions" },
],
tools: [{ name: "t1", description: "t1", inputSchema: { type: "object", properties: {} } }],
prompt: "hi",
cache: "auto",
})
const applied = applyCachePolicy(request)
expect(applied.tools[0]?.cache).toBeDefined()
expect(applied.system.map((part) => part.cache !== undefined)).toEqual([true, true, true])
const tail = applied.messages[0]!.content[0]!
expect("cache" in tail ? tail.cache : undefined).toBeUndefined()
expect(applyCachePolicy(applied)).toBe(applied)
const prepared = yield* LLMClient.prepare(request)
const body = prepared.body as {
tools: Array<{ cache_control?: unknown }>
system: Array<{ cache_control?: unknown }>
messages: Array<{ content: Array<{ cache_control?: unknown }> }>
}
const marked = [
...body.tools.map((tool) => tool.cache_control),
...body.system.map((part) => part.cache_control),
...body.messages.flatMap((message) => message.content.map((part) => part.cache_control)),
].filter((cache) => cache !== undefined)
expect(marked).toHaveLength(4)
expect(body.system[1]?.cache_control).toEqual({ type: "ephemeral", ttl: "1h" })
expect(body.messages[0]?.content[0]?.cache_control).toBeUndefined()
}),
)
File diff suppressed because one or more lines are too long
@@ -1,6 +1,6 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { CacheHint, LLM } from "../../src"
import { CacheHint, LLM, LLMRequest, Message, ToolCallPart, ToolDefinition } from "../../src"
import { LLMClient } from "../../src/route"
import * as Anthropic from "../../src/providers/anthropic"
import { LARGE_CACHEABLE_SYSTEM } from "../recorded-scenarios"
@@ -24,6 +24,39 @@ const cacheRequest = LLM.request({
generation: { maxTokens: 16, temperature: 0 },
})
const lookup = ToolDefinition.make({
name: "lookup",
description: "Look up a fixture value.",
inputSchema: {
type: "object",
properties: { index: { type: "number" } },
required: ["index"],
additionalProperties: false,
},
})
const longToolTurn = [
Message.user("Run the fixture lookups."),
...Array.from({ length: 11 }, (_, index) => {
const id = `lookup_${index}`
return [
Message.assistant(ToolCallPart.make({ id, name: lookup.name, input: { index } })),
Message.tool({
id,
name: lookup.name,
result: `Fixture result ${index}. `.repeat(80),
}),
]
}).flat(),
]
const longToolTurnRequest = LLM.request({
id: "recorded_anthropic_cache_long_tool_turn",
model,
system: LARGE_CACHEABLE_SYSTEM,
messages: longToolTurn,
tools: [lookup],
generation: { maxTokens: 16, temperature: 0 },
})
const recorded = recordedTests({
prefix: "anthropic-messages-cache",
provider: "anthropic",
@@ -50,4 +83,28 @@ describe("Anthropic Messages cache recorded", () => {
expect(second.usage?.cacheReadInputTokens ?? 0).toBeGreaterThan(0)
}),
)
recorded.effect.with("keeps a long tool turn inside the cache lookback", { tags: ["cache", "tool"] }, () =>
Effect.gen(function* () {
const first = yield* LLMClient.generate(longToolTurnRequest)
const firstRead = first.usage?.cacheReadInputTokens ?? 0
const firstWrite = first.usage?.cacheWriteInputTokens ?? 0
const firstCached = firstRead + firstWrite
// The prefix may already be warm when recording, so either a read or a
// write establishes that Anthropic recognized the cache boundary.
expect(firstCached).toBeGreaterThan(0)
const second = yield* LLMClient.generate(
LLMRequest.update(longToolTurnRequest, {
messages: [
...longToolTurn,
Message.assistant("The fixture lookups are complete."),
Message.user("Reply exactly: OK"),
],
}),
)
expect(second.usage?.cacheReadInputTokens ?? 0).toBeGreaterThanOrEqual(firstCached)
expect(second.usage?.cacheWriteInputTokens ?? 0).toBeLessThan(firstCached)
}),
)
})
@@ -506,6 +506,7 @@ describe("Anthropic Messages route", () => {
expect(response.events.find((event) => event.type === "reasoning-end")).toMatchObject({
providerMetadata: { anthropic: { signature: "sig_1" } },
})
expect(response.events.find((event) => event.type === "reasoning-delta" && event.text === "")).toBeUndefined()
expect(response.message.content).toEqual([
{ type: "text", text: "Hello!" },
{ type: "reasoning", text: "thinking", providerMetadata: { anthropic: { signature: "sig_1" } } },
@@ -518,6 +519,199 @@ describe("Anthropic Messages route", () => {
}),
)
it.effect("requires message_stop before completing a streamed message", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "message_start", message: { usage: { input_tokens: 5 } } },
{ type: "content_block_start", index: 0, content_block: { type: "text", text: "" } },
{ type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "Hello" } },
{ type: "content_block_stop", index: 0 },
{ type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 1 } },
),
),
),
Effect.flip,
)
expect(error.reason).toMatchObject({
_tag: "InvalidProviderOutput",
message: "Provider stream ended without a terminal finish event",
})
}),
)
it.effect("maps thinking tokens and preserves unknown Anthropic usage fields", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{
type: "message_start",
message: {
usage: {
input_tokens: 5,
cache_read_input_tokens: 2,
service_tier: "standard",
cache_creation: { ephemeral_5m_input_tokens: 1 },
server_tool_use: { web_search_requests: 1, start_counter: 2 },
output_tokens_details: { thinking_tokens: 3, start_detail: "preserved" },
},
},
},
{
type: "message_delta",
delta: { stop_reason: "end_turn" },
usage: {
output_tokens: 8,
server_tool_use: { web_search_requests: 2, terminal_counter: 3 },
output_tokens_details: { terminal_detail: "preserved" },
future_terminal: { requests: 4 },
},
},
{ type: "message_stop" },
),
),
),
)
expect(response.usage).toMatchObject({
inputTokens: 7,
outputTokens: 8,
reasoningTokens: 3,
totalTokens: 15,
providerMetadata: {
anthropic: {
input_tokens: 5,
cache_read_input_tokens: 2,
service_tier: "standard",
cache_creation: { ephemeral_5m_input_tokens: 1 },
server_tool_use: { web_search_requests: 2, start_counter: 2, terminal_counter: 3 },
output_tokens: 8,
output_tokens_details: {
thinking_tokens: 3,
start_detail: "preserved",
terminal_detail: "preserved",
},
future_terminal: { requests: 4 },
},
},
})
}),
)
it.effect("round-trips omitted thinking carried only by a signature delta", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "message_start", message: { usage: { input_tokens: 5 } } },
{
type: "content_block_start",
index: 0,
content_block: { type: "thinking", thinking: "", signature: "" },
},
{ type: "content_block_delta", index: 0, delta: { type: "signature_delta", signature: "sig_1" } },
{ type: "content_block_stop", index: 0 },
{ type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 1 } },
{ type: "message_stop" },
),
),
),
)
expect(response.message.content).toEqual([
{ type: "reasoning", text: "", providerMetadata: { anthropic: { signature: "sig_1" } } },
])
const prepared = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
LLM.request({ model, messages: [response.message], cache: "none" }),
)
expect(prepared.body.messages).toEqual([
{ role: "assistant", content: [{ type: "thinking", thinking: "", signature: "sig_1" }] },
])
}),
)
it.effect("retains a thinking signature supplied in content_block_start", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "message_start", message: { usage: { input_tokens: 5 } } },
{
type: "content_block_start",
index: 0,
content_block: { type: "thinking", thinking: "", signature: "sig_1" },
},
{ type: "content_block_stop", index: 0 },
{ type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 1 } },
{ type: "message_stop" },
),
),
),
)
expect(response.message.content).toEqual([
{ type: "reasoning", text: "", providerMetadata: { anthropic: { signature: "sig_1" } } },
])
expect(response.events.find((event) => event.type === "reasoning-end")).toMatchObject({
providerMetadata: { anthropic: { signature: "sig_1" } },
})
}),
)
it.effect("retains complete tool input from content_block_start", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "message_start", message: { usage: { input_tokens: 5 } } },
{
type: "content_block_start",
index: 0,
content_block: { type: "tool_use", id: "call_1", name: "lookup", input: { query: "weather" } },
},
{ type: "content_block_stop", index: 0 },
{ type: "message_delta", delta: { stop_reason: "tool_use" }, usage: { output_tokens: 1 } },
{ type: "message_stop" },
),
),
),
)
expect(response.toolCalls).toMatchObject([
{ id: "call_1", name: "lookup", input: { query: "weather" } },
])
}),
)
it.effect("retains empty text blocks", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "message_start", message: { usage: { input_tokens: 5 } } },
{ type: "content_block_start", index: 0, content_block: { type: "text", text: "" } },
{ type: "content_block_stop", index: 0 },
{ type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 1 } },
{ type: "message_stop" },
),
),
),
)
expect(response.message.content).toEqual([{ type: "text", text: "" }])
}),
)
it.effect("parses redacted thinking into empty reasoning with redactedData metadata", () =>
Effect.gen(function* () {
const body = sseEvents(
@@ -629,6 +823,7 @@ describe("Anthropic Messages route", () => {
delta: { stop_reason: "model_context_window_exceeded" },
usage: { output_tokens: 1 },
},
{ type: "message_stop" },
),
),
),
@@ -646,6 +841,7 @@ describe("Anthropic Messages route", () => {
sseEvents(
{ type: "message_start", message: { usage: { input_tokens: 5 } } },
{ type: "message_delta", delta: { stop_reason: "pause_turn" }, usage: { output_tokens: 1 } },
{ type: "message_stop" },
),
),
),
@@ -664,6 +860,7 @@ describe("Anthropic Messages route", () => {
{ type: "content_block_delta", index: 0, delta: { type: "input_json_delta", partial_json: ':"weather"}' } },
{ type: "content_block_stop", index: 0 },
{ type: "message_delta", delta: { stop_reason: "tool_use" }, usage: { output_tokens: 1 } },
{ type: "message_stop" },
)
const response = yield* LLMClient.generate(
LLMRequest.update(request, {
@@ -849,6 +1046,7 @@ describe("Anthropic Messages route", () => {
{ type: "content_block_delta", index: 2, delta: { type: "text_delta", text: "Found it." } },
{ type: "content_block_stop", index: 2 },
{ type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 8 } },
{ type: "message_stop" },
)
const response = yield* LLMClient.generate(
LLMRequest.update(request, {
@@ -912,6 +1110,7 @@ describe("Anthropic Messages route", () => {
},
{ type: "content_block_stop", index: 1 },
{ type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 1 } },
{ type: "message_stop" },
)
const response = yield* LLMClient.generate(
LLMRequest.update(request, {
@@ -939,6 +939,7 @@ describe("Bedrock Converse route", () => {
const prepared = yield* LLMClient.prepare<BedrockConverse.BedrockConverseBody>(
LLM.request({
model,
cache: "none",
messages: [
Message.assistant([ToolCallPart.make({ id: "call_1", name: "read", input: { path: "report.pdf" } })]),
Message.tool({
@@ -550,7 +550,7 @@ describe("OpenAI Chat route", () => {
prompt_tokens: 5,
completion_tokens: 2,
total_tokens: 7,
prompt_tokens_details: { cached_tokens: 1 },
prompt_tokens_details: { cached_tokens: 1, cache_write_tokens: 2 },
completion_tokens_details: { reasoning_tokens: 0 },
}),
)
@@ -558,8 +558,9 @@ describe("OpenAI Chat route", () => {
const usage = new Usage({
inputTokens: 5,
outputTokens: 2,
nonCachedInputTokens: 4,
nonCachedInputTokens: 2,
cacheReadInputTokens: 1,
cacheWriteInputTokens: 2,
reasoningTokens: 0,
totalTokens: 7,
providerMetadata: {
@@ -567,7 +568,7 @@ describe("OpenAI Chat route", () => {
prompt_tokens: 5,
completion_tokens: 2,
total_tokens: 7,
prompt_tokens_details: { cached_tokens: 1 },
prompt_tokens_details: { cached_tokens: 1, cache_write_tokens: 2 },
completion_tokens_details: { reasoning_tokens: 0 },
},
},
@@ -1,6 +1,6 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { LLM, LLMEvent } from "../../src"
import { LLM, LLMEvent, Message } from "../../src"
import { configure } from "../../src/providers/openai-compatible-responses"
import { OpenAI } from "../../src/providers"
import { OpenResponses } from "../../src/protocols/open-responses"
@@ -70,6 +70,31 @@ describe("Open Responses-compatible route", () => {
}),
)
it.effect("omits OpenAI-only nullable phases from the Open Responses baseline", () =>
Effect.gen(function* () {
const model = configure({
apiKey: "test-key",
baseURL: "https://responses.example.test/v1",
}).model("example-model")
const prepared = yield* LLMClient.prepare(
LLM.request({
model,
messages: [
Message.assistant({
type: "text",
text: "Unclassified.",
providerMetadata: { openresponses: { phase: null } },
}),
],
}),
)
expect(prepared.body).toMatchObject({
input: [{ role: "assistant", content: [{ type: "output_text", text: "Unclassified." }] }],
})
}),
)
it.effect("reads standard options from the Open Responses namespace", () =>
Effect.gen(function* () {
const model = configure({
@@ -832,7 +832,7 @@ describe("OpenAI Responses route", () => {
input_tokens: 5,
output_tokens: 2,
total_tokens: 7,
input_tokens_details: { cached_tokens: 1 },
input_tokens_details: { cached_tokens: 1, cache_write_tokens: 2 },
output_tokens_details: { reasoning_tokens: 0 },
},
},
@@ -842,8 +842,9 @@ describe("OpenAI Responses route", () => {
const usage = new Usage({
inputTokens: 5,
outputTokens: 2,
nonCachedInputTokens: 4,
nonCachedInputTokens: 2,
cacheReadInputTokens: 1,
cacheWriteInputTokens: 2,
reasoningTokens: 0,
totalTokens: 7,
providerMetadata: {
@@ -851,7 +852,7 @@ describe("OpenAI Responses route", () => {
input_tokens: 5,
output_tokens: 2,
total_tokens: 7,
input_tokens_details: { cached_tokens: 1 },
input_tokens_details: { cached_tokens: 1, cache_write_tokens: 2 },
output_tokens_details: { reasoning_tokens: 0 },
},
},
@@ -881,6 +882,121 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("preserves and replays assistant message phases", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{
type: "response.output_item.added",
item: { type: "message", id: "msg_commentary" },
},
{ type: "response.output_text.delta", item_id: "msg_commentary", delta: "Checking." },
{ type: "response.output_text.done", item_id: "msg_commentary" },
{
type: "response.output_item.done",
item: { type: "message", id: "msg_commentary", phase: "commentary" },
},
{
type: "response.output_item.added",
item: { type: "message", id: "msg_final", phase: "final_answer" },
},
{ type: "response.output_text.done", item_id: "msg_final", text: "Finished." },
{
type: "response.output_item.done",
item: { type: "message", id: "msg_final", phase: "final_answer" },
},
{ type: "response.output_item.added", item: { type: "message", id: "msg_null", phase: null } },
{ type: "response.output_text.delta", item_id: "msg_null", delta: "Unclassified." },
{ type: "response.output_item.done", item: { type: "message", id: "msg_null", phase: null } },
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.message.content).toEqual([
{
type: "text",
text: "Checking.",
providerMetadata: { openai: { phase: "commentary" } },
},
{
type: "text",
text: "Finished.",
providerMetadata: { openai: { phase: "final_answer" } },
},
{
type: "text",
text: "Unclassified.",
providerMetadata: { openai: { phase: null } },
},
])
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
LLM.request({ model, messages: [response.message] }),
)
expect(prepared.body.input).toEqual([
{
role: "assistant",
content: [{ type: "output_text", text: "Checking." }],
phase: "commentary",
},
{
role: "assistant",
content: [{ type: "output_text", text: "Finished." }],
phase: "final_answer",
},
{
role: "assistant",
content: [{ type: "output_text", text: "Unclassified." }],
phase: null,
},
])
}),
)
it.effect("rejects output text events without the spec-required item id", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.output_text.delta", delta: "orphaned" },
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
Effect.flip,
)
expect(error.reason._tag).toBe("InvalidProviderOutput")
expect(error.message).toContain("response.output_text.delta is missing item_id")
}),
)
it.effect("rejects reasoning events without the spec-required item id", () =>
Effect.gen(function* () {
const events = [
{ type: "response.reasoning_summary_part.added", summary_index: 0 },
{ type: "response.reasoning_summary_part.done", summary_index: 0 },
{ type: "response.reasoning_text.done" },
]
for (const event of events) {
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(sseEvents(event, { type: "response.completed", response: { id: "resp_1" } })),
),
Effect.flip,
)
expect(error.reason._tag).toBe("InvalidProviderOutput")
expect(error.message).toContain(`${event.type} is missing item_id`)
}
}),
)
it.effect("maps incomplete response reasons", () =>
Effect.gen(function* () {
const generate = (incompleteDetails: object) =>
+5 -2
View File
@@ -1,4 +1,5 @@
import { describe, expect } from "bun:test"
import { Content } from "@opencode-ai/schema/tool"
import { Effect, Schema, Stream } from "effect"
import {
GenerationOptions,
@@ -7,7 +8,6 @@ import {
LLMRequest,
LLMResponse,
ToolChoice,
ToolContent,
ToolOutput,
toDefinitions,
} from "../src"
@@ -279,7 +279,7 @@ describe("LLMClient tools", () => {
it.effect("models canonical tool files with URIs", () =>
Effect.sync(() => {
const decode = Schema.decodeUnknownSync(ToolContent)
const decode = Schema.decodeUnknownSync(Content)
expect(decode({ type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png" })).toEqual({
type: "file",
@@ -539,6 +539,7 @@ describe("LLMClient tools", () => {
},
{ type: "content_block_stop", index: 1 },
{ type: "message_delta", delta: { stop_reason: "tool_use" }, usage: { output_tokens: 5 } },
{ type: "message_stop" },
)
: sseEvents(
{ type: "message_start", message: { usage: { input_tokens: 5 } } },
@@ -546,6 +547,7 @@ describe("LLMClient tools", () => {
{ type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "Done." } },
{ type: "content_block_stop", index: 0 },
{ type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 1 } },
{ type: "message_stop" },
),
{ headers: { "content-type": "text/event-stream" } },
)
@@ -801,6 +803,7 @@ describe("LLMClient tools", () => {
{ type: "content_block_delta", index: 2, delta: { type: "text_delta", text: "Done." } },
{ type: "content_block_stop", index: 2 },
{ type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 8 } },
{ type: "message_stop" },
),
{ headers: { "content-type": "text/event-stream" } },
)
+1 -1
View File
@@ -56,7 +56,7 @@
"@opencode-ai/client": "file:vendor/opencode-ai-client-1.17.13.tgz",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/sdk": "1.18.5",
"@opencode-ai/session-ui": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@pierre/trees": "1.0.0-beta.4",
@@ -8,8 +8,8 @@ import type {
QuestionRequest,
Session,
SessionStatus,
FileDiffInfo,
} from "@opencode-ai/sdk/v2/client"
import type { SessionDiff } from "@/utils/diffs"
import type { State, VcsCache } from "./types"
import { trimSessions } from "./session-trim"
import { dropSessionCaches } from "./session-cache"
@@ -171,7 +171,7 @@ export function applyDirectoryEvent(input: {
break
}
case "session.diff": {
const props = event.properties as { sessionID: string; diff: FileDiffInfo[] }
const props = event.properties as { sessionID: string; diff: SessionDiff[] }
input.setStore("session_diff", props.sessionID, reconcile(list(props.diff), { key: "file" }))
break
}
@@ -5,8 +5,8 @@ import type {
PermissionRequest,
QuestionRequest,
SessionStatus,
FileDiffInfo,
} from "@opencode-ai/sdk/v2/client"
import type { SessionDiff } from "@/utils/diffs"
import { dropSessionCaches, pickSessionCacheEvictions } from "./session-cache"
const msg = (id: string, sessionID: string) =>
@@ -32,7 +32,7 @@ describe("app session cache", () => {
test("dropSessionCaches clears orphaned parts without message rows", () => {
const store: {
session_status: Record<string, SessionStatus | undefined>
session_diff: Record<string, FileDiffInfo[] | undefined>
session_diff: Record<string, SessionDiff[] | undefined>
message: Record<string, Message[] | undefined>
part: Record<string, Part[] | undefined>
permission: Record<string, PermissionRequest[] | undefined>
@@ -63,7 +63,7 @@ describe("app session cache", () => {
const m = msg("msg_1", "ses_1")
const store: {
session_status: Record<string, SessionStatus | undefined>
session_diff: Record<string, FileDiffInfo[] | undefined>
session_diff: Record<string, SessionDiff[] | undefined>
message: Record<string, Message[] | undefined>
part: Record<string, Part[] | undefined>
permission: Record<string, PermissionRequest[] | undefined>
@@ -4,14 +4,14 @@ import type {
PermissionRequest,
QuestionRequest,
SessionStatus,
FileDiffInfo,
} from "@opencode-ai/sdk/v2/client"
import type { SessionDiff } from "@/utils/diffs"
export const SESSION_CACHE_LIMIT = 40
type SessionCache = {
session_status: Record<string, SessionStatus | undefined>
session_diff: Record<string, FileDiffInfo[] | undefined>
session_diff: Record<string, SessionDiff[] | undefined>
message: Record<string, Message[] | undefined>
part: Record<string, Part[] | undefined>
permission: Record<string, PermissionRequest[] | undefined>
@@ -13,9 +13,9 @@ import type {
ReferenceInfo,
Session,
SessionStatus,
FileDiffInfo,
VcsInfo,
} from "@opencode-ai/sdk/v2/client"
import type { SessionDiff } from "@/utils/diffs"
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
import type { Accessor } from "solid-js"
import type { SetStoreFunction, Store } from "solid-js/store"
@@ -50,7 +50,7 @@ export type State = {
}
session_working(id: string): boolean
session_diff: {
[sessionID: string]: FileDiffInfo[]
[sessionID: string]: SessionDiff[]
}
permission: {
[sessionID: string]: PermissionRequest[]
+1 -1
View File
@@ -25,7 +25,7 @@ describe("adaptServerEvent", () => {
} as OpenCodeEvent
expect(adaptServerEvent(current)).toMatchObject({
type: "permission.asked",
type: "permission.v2.asked",
properties: { id: "perm_1", sessionID: "ses_1", permission: "read", patterns: ["src/**"] },
current,
})
+16 -8
View File
@@ -1,5 +1,5 @@
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
import type { Event } from "@opencode-ai/sdk/v2/client"
import type { Event, PermissionRequest } from "@opencode-ai/sdk/v2/client"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { makeEventListener } from "@solid-primitives/event-listener"
@@ -18,7 +18,15 @@ const isAbortError = (error: unknown) =>
error !== null && typeof error === "object" && "name" in error && error.name === "AbortError"
const isStreamClosed = (error: unknown, signal?: AbortSignal) => isAbortError(error) || signal?.aborted === true
export type ServerEvent = Event & { current?: OpenCodeEvent }
type PermissionEvent = {
id: string
type: "permission.v2.asked"
properties: PermissionRequest
current?: OpenCodeEvent
}
export type ServerEvent = (Event | PermissionEvent) & {
current?: OpenCodeEvent
}
type QueuedServerEvent = { directory: string; payload: ServerEvent }
type CurrentDelta = Extract<
OpenCodeEvent,
@@ -29,7 +37,7 @@ export function adaptServerEvent(event: OpenCodeEvent): ServerEvent {
if (event.type === "permission.v2.asked") {
return {
id: event.id,
type: "permission.asked",
type: "permission.v2.asked",
properties: {
id: event.data.id,
sessionID: event.data.sessionID,
@@ -43,16 +51,16 @@ export function adaptServerEvent(event: OpenCodeEvent): ServerEvent {
: undefined,
},
current: event,
} as ServerEvent
}
}
if (event.type === "permission.v2.replied")
return { id: event.id, type: "permission.replied", properties: event.data, current: event } as ServerEvent
return { id: event.id, type: "permission.v2.replied", properties: event.data, current: event } as ServerEvent
if (event.type === "question.v2.asked")
return { id: event.id, type: "question.asked", properties: event.data, current: event } as ServerEvent
return { id: event.id, type: "question.v2.asked", properties: event.data, current: event } as ServerEvent
if (event.type === "question.v2.replied")
return { id: event.id, type: "question.replied", properties: event.data, current: event } as ServerEvent
return { id: event.id, type: "question.v2.replied", properties: event.data, current: event } as ServerEvent
if (event.type === "question.v2.rejected")
return { id: event.id, type: "question.rejected", properties: event.data, current: event } as ServerEvent
return { id: event.id, type: "question.v2.rejected", properties: event.data, current: event } as ServerEvent
return { id: event.id, type: event.type, properties: event.data, current: event } as ServerEvent
}
+3 -3
View File
@@ -8,8 +8,8 @@ import type {
QuestionRequest,
Session,
SessionStatus,
FileDiffInfo,
} from "@opencode-ai/sdk/v2/client"
import type { SessionDiff } from "@/utils/diffs"
import { batch } from "solid-js"
import { createStore, produce, reconcile } from "solid-js/store"
import { diffs as cleanDiffs, message as cleanMessage } from "@/utils/diffs"
@@ -140,7 +140,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
const [data, setData] = createStore({
info: {} as Record<string, Session | undefined>,
session_status: {} as Record<string, SessionStatus>,
session_diff: {} as Record<string, FileDiffInfo[]>,
session_diff: {} as Record<string, SessionDiff[]>,
permission: {} as Record<string, PermissionRequest[]>,
question: {} as Record<string, QuestionRequest[]>,
message: {} as Record<string, Message[]>,
@@ -773,7 +773,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
return
}
case "session.diff": {
const props = event.properties as { sessionID: string; diff: FileDiffInfo[] }
const props = event.properties as { sessionID: string; diff: SessionDiff[] }
setData("session_diff", props.sessionID, reconcile(cleanDiffs(props.diff), { key: "file" }))
return
}
+2 -7
View File
@@ -909,13 +909,8 @@ export default function Page() {
)
const stopVcs = sdk().event.listen((evt) => {
if (evt.details.type !== "filesystem.changed") return
const props =
typeof evt.details.properties === "object" && evt.details.properties
? (evt.details.properties as Record<string, unknown>)
: undefined
const file = typeof props?.file === "string" ? props.file : undefined
if (!file || file.startsWith(".git/")) return
if (evt.details.type !== "file.watcher.updated") return
if (evt.details.properties.file.startsWith(".git/")) return
refreshVcs()
})
onCleanup(stopVcs)
@@ -1,6 +1,7 @@
import { createEffect, onCleanup, type JSX } from "solid-js"
import { makeEventListener } from "@solid-primitives/event-listener"
import type { FileDiffInfo, VcsFileDiff } from "@opencode-ai/sdk/v2"
import type { VcsFileDiff } from "@opencode-ai/sdk/v2"
import type { SessionDiff } from "@/utils/diffs"
import { SessionReview } from "@opencode-ai/session-ui/session-review"
import type {
SessionReviewCommentActions,
@@ -14,7 +15,7 @@ import type { LineComment } from "@/context/comments"
export type DiffStyle = "unified" | "split"
type ReviewDiff = FileDiffInfo | VcsFileDiff
type ReviewDiff = SessionDiff | VcsFileDiff
export interface SessionReviewTabProps {
title?: JSX.Element
@@ -23,7 +23,8 @@ import { Mark } from "@opencode-ai/ui/logo"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import type { FileDiffInfo, VcsFileDiff } from "@opencode-ai/sdk/v2"
import type { VcsFileDiff } from "@opencode-ai/sdk/v2"
import type { SessionDiff } from "@/utils/diffs"
import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd"
import { useDialog } from "@opencode-ai/ui/context/dialog"
@@ -59,7 +60,7 @@ import type { RenderDiff } from "@/pages/session/v2/review-diff-kinds"
export function SessionSidePanel(props: {
canReview: () => boolean
diffs: () => (FileDiffInfo | VcsFileDiff)[]
diffs: () => (SessionDiff | VcsFileDiff)[]
diffsReady: () => boolean
empty: () => string
hasReview: () => boolean
@@ -1,8 +1,9 @@
import type { FileDiffInfo, VcsFileDiff } from "@opencode-ai/sdk/v2"
import type { VcsFileDiff } from "@opencode-ai/sdk/v2"
import type { SessionDiff } from "@/utils/diffs"
import type { Kind } from "@/components/file-tree-v2"
import { normalizeFileTreeV2Path } from "@/components/file-tree-v2-model"
export type RenderDiff = FileDiffInfo | VcsFileDiff
export type RenderDiff = SessionDiff | VcsFileDiff
export function normalizePath(p: string) {
return normalizeFileTreeV2Path(p)
@@ -1,5 +1,6 @@
import { createMemo, createResource, createSignal, Show, type JSX } from "solid-js"
import type { FileDiffInfo, VcsFileDiff } from "@opencode-ai/sdk/v2"
import type { VcsFileDiff } from "@opencode-ai/sdk/v2"
import type { SessionDiff } from "@/utils/diffs"
import {
SESSION_REVIEW_V2_SIDEBAR_WIDTH_MAX,
SESSION_REVIEW_V2_SIDEBAR_WIDTH_MIN,
@@ -29,7 +30,7 @@ import {
import type { ReviewPanelV2State } from "@/pages/session/v2/review-panel-v2-state"
import { applyFileListKeyDown, SessionFileListV2 } from "@/pages/session/v2/session-file-list-v2"
type ReviewDiff = FileDiffInfo | VcsFileDiff
type ReviewDiff = SessionDiff | VcsFileDiff
export type ReviewPanelV2Props = {
title?: JSX.Element
+2 -2
View File
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import type { FileDiffInfo } from "@opencode-ai/sdk/v2"
import type { SnapshotFileDiff } from "@opencode-ai/sdk/v2"
import type { Message } from "@opencode-ai/sdk/v2/client"
import { diffs, message } from "./diffs"
@@ -9,7 +9,7 @@ const item = {
additions: 1,
deletions: 1,
status: "modified",
} satisfies FileDiffInfo
} satisfies SnapshotFileDiff
describe("diffs", () => {
test("keeps valid arrays", () => {
+4 -4
View File
@@ -1,9 +1,9 @@
import type { FileDiffInfo } from "@opencode-ai/sdk/v2"
import type { SnapshotFileDiff } from "@opencode-ai/sdk/v2"
import type { Message } from "@opencode-ai/sdk/v2/client"
type Diff = FileDiffInfo
export type SessionDiff = SnapshotFileDiff & { file: string; patch: string }
function diff(value: unknown): value is Diff {
function diff(value: unknown): value is SessionDiff {
if (!value || typeof value !== "object" || Array.isArray(value)) return false
if (!("file" in value) || typeof value.file !== "string") return false
if (!("patch" in value) || typeof value.patch !== "string") return false
@@ -17,7 +17,7 @@ function object(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value)
}
export function diffs(value: unknown): Diff[] {
export function diffs(value: unknown): SessionDiff[] {
if (Array.isArray(value) && value.every(diff)) return value
if (Array.isArray(value)) return value.filter(diff)
if (diff(value)) return [value]
+1 -1
View File
@@ -71,7 +71,7 @@ export async function streamTurn(input: {
const next = await stream.next()
if (next.done) throw new Error("event stream disconnected during prompt execution")
const event = next.value
if (event.type === "permission.v2.asked" && event.data.sessionID === input.sessionID) {
if (event.type === "permission.asked" && event.data.sessionID === input.sessionID) {
const tool = event.data.source?.callID ? tools.get(event.data.source.callID) : undefined
await replyPermission({
client: input.client,
+1 -1
View File
@@ -5,7 +5,7 @@ import { Result } from "effect"
import { isAbsolute, resolve } from "node:path"
import { pendingToolCall, stringValue, toLocations, toToolKind, type ToolInput } from "./tool"
type PermissionEvent = Extract<EventSubscribeOutput, { type: "permission.v2.asked" }>
type PermissionEvent = Extract<EventSubscribeOutput, { type: "permission.asked" }>
type Connection = Pick<AgentSideConnection, "requestPermission"> & Partial<Pick<AgentSideConnection, "writeTextFile">>
type Tool = { readonly name: string; readonly input: ToolInput }
+4
View File
@@ -201,6 +201,10 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO
Flag.withDefault(false),
),
yolo: Flag.boolean("yolo").pipe(Flag.withDefault(false), Flag.withHidden),
dangerouslySkipPermissions: Flag.boolean("dangerously-skip-permissions").pipe(
Flag.withDefault(false),
Flag.withHidden,
),
},
}),
Spec.make("service", {
+1 -1
View File
@@ -24,7 +24,7 @@ export default Runtime.handler(Commands.commands.run, (input) =>
file: [...input.file],
title: Option.getOrUndefined(input.title),
thinking: input.thinking,
auto: input.auto || input.yolo,
auto: input.auto || input.yolo || input.dangerouslySkipPermissions,
}),
)
}),
@@ -9,8 +9,8 @@ import {
Provider,
Reference,
Skill,
} from "@opencode-ai/plugin/v2/effect"
import { Tool } from "@opencode-ai/plugin/v2/effect/tool"
} from "@opencode-ai/plugin/effect"
import { Tool } from "@opencode-ai/schema/tool"
const key = Symbol.for("opencode.plugin.v2.effect")
;(globalThis as typeof globalThis & { [key]?: unknown })[key] = {
@@ -24,5 +24,5 @@ const key = Symbol.for("opencode.plugin.v2.effect")
Provider,
Reference,
Skill,
Tool,
Tool: { Error: Tool.Error },
}
@@ -9,8 +9,7 @@ import {
Provider,
Reference,
Skill,
} from "@opencode-ai/plugin/v2"
import { Tool } from "@opencode-ai/plugin/v2/tool"
} from "@opencode-ai/plugin"
const key = Symbol.for("opencode.plugin.v2.promise")
;(globalThis as typeof globalThis & { [key]?: unknown })[key] = {
@@ -24,5 +23,4 @@ const key = Symbol.for("opencode.plugin.v2.promise")
Provider,
Reference,
Skill,
Tool,
}
+1 -1
View File
@@ -177,7 +177,7 @@ export async function runNonInteractivePrompt(input: Input) {
}
const event = next.value
if (event.type === "permission.v2.asked" && submitted && event.data.sessionID === input.sessionID) {
if (event.type === "permission.asked" && submitted && event.data.sessionID === input.sessionID) {
await replyPermission(event.data)
continue
}
+3 -1
View File
@@ -13,11 +13,13 @@ type Options = {
readonly command?: ReadonlyArray<string>
}
const startupDirectory = process.cwd()
function command(password: string, options: Options) {
const [executable, ...args] = options.command ?? [...selfCommand(), "serve"]
if (!executable) throw new Error("Failed to resolve standalone server command")
return ChildProcess.make(executable, [...args, "--stdio", "--port", "0"], {
cwd: process.cwd(),
cwd: startupDirectory,
// Explicit entry wins over anything inherited, so a user-exported
// OPENCODE_PASSWORD cannot shadow the child's lease credential.
env: { OPENCODE_PASSWORD: password },
+5 -3
View File
@@ -1,11 +1,13 @@
import path from "node:path"
const entrypoint = process.argv[1] ? path.resolve(process.argv[1]) : undefined
export function selfCommand() {
const runtime = path.basename(process.execPath, path.extname(process.execPath)).toLowerCase()
if (runtime !== "bun" && runtime !== "node" && runtime !== "nodejs") return [process.execPath]
if (!process.argv[1]) throw new Error("Failed to resolve CLI entrypoint")
if (runtime === "node" || runtime === "nodejs") return [process.execPath, ...nodeFlags(), process.argv[1]]
return [process.execPath, process.argv[1]]
if (!entrypoint) throw new Error("Failed to resolve CLI entrypoint")
if (runtime === "node" || runtime === "nodejs") return [process.execPath, ...nodeFlags(), entrypoint]
return [process.execPath, entrypoint]
}
function nodeFlags() {
@@ -502,7 +502,7 @@ function permissionAsked(
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly callID: string }
} = {},
) {
return ephemeralEvent("permission.v2.asked", {
return ephemeralEvent("permission.asked", {
id,
sessionID,
action: input.action ?? "shell",
@@ -3,12 +3,14 @@ import { Service } from "@opencode-ai/client/effect/service"
import path from "node:path"
import { Standalone } from "../../src/services/standalone"
process.argv[1] = path.join(import.meta.dir, "../../src/index.ts")
process.chdir(path.join(import.meta.dir, "../../../.."))
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const endpoint = yield* Standalone.start()
const endpoint = yield* Standalone.start({
command: [process.execPath, path.join(import.meta.dir, "../../src/index.ts"), "serve"],
})
const response = yield* Effect.promise(() =>
fetch(new URL("/api/health", endpoint.url), { headers: Service.headers(endpoint) }),
)
+11 -20
View File
@@ -75,20 +75,11 @@ export const define = sdk.Plugin.define`
const effectPluginModule = promisePluginModule
.replace("opencode.plugin.v2.promise", "opencode.plugin.v2.effect")
.replace("Promise plugin", "Effect plugin")
const promiseToolModule = `const sdk = globalThis[Symbol.for("opencode.plugin.v2.promise")]
if (!sdk) throw new Error("OpenCode Promise plugin SDK is unavailable")
export const Tool = sdk.Tool
export const make = sdk.Tool.make`
const promiseToolModule = `export {}`
const effectToolModule = `const sdk = globalThis[Symbol.for("opencode.plugin.v2.effect")]
if (!sdk) throw new Error("OpenCode Effect plugin SDK is unavailable")
export const Tool = sdk.Tool
export const Failure = sdk.Tool.Failure
export const RegistrationError = sdk.Tool.RegistrationError
export const make = sdk.Tool.make
export const validateName = sdk.Tool.validateName
export const registrationEntries = sdk.Tool.registrationEntries
export const validateNamespace = sdk.Tool.validateNamespace
export const toLLMDefinition = sdk.Tool.toLLMDefinition`
export const Error = sdk.Tool.Error
`
return `#!/usr/bin/env -S node ${nodeExecArgv.join(" ")}
import __cjs_mod__ from "node:module"
import { chmodSync as __ocChmod, existsSync as __ocExists, lstatSync as __ocLstat, mkdirSync as __ocMkdir, renameSync as __ocRename, rmSync as __ocRm, writeFileSync as __ocWrite } from "node:fs"
@@ -100,17 +91,17 @@ const __filename = import.meta.filename
const __dirname = import.meta.dirname
const require = __cjs_mod__.createRequire(import.meta.url)
const __ocPluginModules = ${JSON.stringify({
"@opencode-ai/plugin/v2": "opencode:plugin-v2",
"@opencode-ai/plugin/v2/plugin": "opencode:plugin-v2-plugin",
"@opencode-ai/plugin/v2/tool": "opencode:plugin-v2-tool",
"@opencode-ai/plugin/v2/effect": "opencode:plugin-v2-effect",
"@opencode-ai/plugin/v2/effect/plugin": "opencode:plugin-v2-effect-plugin",
"@opencode-ai/plugin/v2/effect/tool": "opencode:plugin-v2-effect-tool",
"@opencode-ai/plugin": "opencode:plugin-v2",
"@opencode-ai/plugin/promise/plugin": "opencode:plugin-promise-plugin",
"@opencode-ai/plugin/promise/tool": "opencode:plugin-promise-tool",
"@opencode-ai/plugin/effect": "opencode:plugin-v2-effect",
"@opencode-ai/plugin/effect/plugin": "opencode:plugin-v2-effect-plugin",
"@opencode-ai/plugin/effect/tool": "opencode:plugin-v2-effect-tool",
})}
const __ocPluginSources = ${JSON.stringify({
"opencode:plugin-v2": promiseModule,
"opencode:plugin-v2-plugin": promisePluginModule,
"opencode:plugin-v2-tool": promiseToolModule,
"opencode:plugin-promise-plugin": promisePluginModule,
"opencode:plugin-promise-tool": promiseToolModule,
"opencode:plugin-v2-effect": effectModule,
"opencode:plugin-v2-effect-plugin": effectPluginModule,
"opencode:plugin-v2-effect-tool": effectToolModule,
+1 -1
View File
@@ -36,7 +36,7 @@
"@opencode-ai/protocol": "workspace:*"
},
"peerDependencies": {
"effect": "4.0.0-beta.98"
"effect": "4.0.0-beta.101"
},
"peerDependenciesMeta": {
"effect": {
+104 -3
View File
@@ -6,11 +6,86 @@ import {
groupNames,
promiseOmitEndpoints,
} from "@opencode-ai/protocol/client"
import { Effect } from "effect"
import { Agent } from "@opencode-ai/schema/agent"
import { Command } from "@opencode-ai/schema/command"
import { Credential } from "@opencode-ai/schema/credential"
import { Event } from "@opencode-ai/schema/event"
import { EventLog } from "@opencode-ai/schema/event-log"
import { FileDiff } from "@opencode-ai/schema/file-diff"
import { FileSystem } from "@opencode-ai/schema/filesystem"
import { Form } from "@opencode-ai/schema/form"
import { InstructionEntry } from "@opencode-ai/schema/instruction-entry"
import { Integration } from "@opencode-ai/schema/integration"
import { Location } from "@opencode-ai/schema/location"
import { Mcp } from "@opencode-ai/schema/mcp"
import { Model } from "@opencode-ai/schema/model"
import { Permission } from "@opencode-ai/schema/permission"
import { PermissionSaved } from "@opencode-ai/schema/permission-saved"
import { Plugin } from "@opencode-ai/schema/plugin"
import { Project } from "@opencode-ai/schema/project"
import { ProjectCopy } from "@opencode-ai/schema/project-copy"
import { AgentAttachment, FileAttachment, Prompt, PromptMention } from "@opencode-ai/schema/prompt"
import { PromptInput } from "@opencode-ai/schema/prompt-input"
import { Provider } from "@opencode-ai/schema/provider"
import { Pty } from "@opencode-ai/schema/pty"
import { PtyTicket } from "@opencode-ai/schema/pty-ticket"
import { Question } from "@opencode-ai/schema/question"
import { Reference } from "@opencode-ai/schema/reference"
import { AbsolutePath, PositiveInt, RelativePath } from "@opencode-ai/schema/schema"
import { Session } from "@opencode-ai/schema/session"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import { SessionPending } from "@opencode-ai/schema/session-pending"
import { Shell } from "@opencode-ai/schema/shell"
import { Skill } from "@opencode-ai/schema/skill"
import { Vcs } from "@opencode-ai/schema/vcs"
import { WebSearch } from "@opencode-ai/schema/websearch"
import { Workspace } from "@opencode-ai/schema/workspace"
import { Effect, Schema } from "effect"
import { fileURLToPath } from "url"
const promiseContract = compile(ClientApi, { groupNames, omitEndpoints: promiseOmitEndpoints })
const effectContract = compile(ClientApi, { groupNames, omitEndpoints: effectOmitEndpoints })
const effectTypeReferences = [
...namespaceTypes("Agent", "@opencode-ai/schema/agent", Agent),
...namespaceTypes("Command", "@opencode-ai/schema/command", Command),
...namespaceTypes("Credential", "@opencode-ai/schema/credential", Credential),
...namespaceTypes("Event", "@opencode-ai/schema/event", Event),
...namespaceTypes("EventLog", "@opencode-ai/schema/event-log", EventLog),
...namespaceTypes("FileDiff", "@opencode-ai/schema/file-diff", FileDiff),
...namespaceTypes("FileSystem", "@opencode-ai/schema/filesystem", FileSystem),
...namespaceTypes("Form", "@opencode-ai/schema/form", Form),
...namespaceTypes("InstructionEntry", "@opencode-ai/schema/instruction-entry", InstructionEntry),
...namespaceTypes("Integration", "@opencode-ai/schema/integration", Integration),
...namespaceTypes("Location", "@opencode-ai/schema/location", Location),
...namespaceTypes("Mcp", "@opencode-ai/schema/mcp", Mcp),
...namespaceTypes("Model", "@opencode-ai/schema/model", Model),
...namespaceTypes("Permission", "@opencode-ai/schema/permission", Permission),
...namespaceTypes("PermissionSaved", "@opencode-ai/schema/permission-saved", PermissionSaved),
...namespaceTypes("Plugin", "@opencode-ai/schema/plugin", Plugin),
...namespaceTypes("Project", "@opencode-ai/schema/project", Project),
...namespaceTypes("ProjectCopy", "@opencode-ai/schema/project-copy", ProjectCopy),
...namespaceTypes("PromptInput", "@opencode-ai/schema/prompt-input", PromptInput),
...namespaceTypes("Provider", "@opencode-ai/schema/provider", Provider),
...namespaceTypes("Pty", "@opencode-ai/schema/pty", Pty),
...namespaceTypes("PtyTicket", "@opencode-ai/schema/pty-ticket", PtyTicket),
...namespaceTypes("Question", "@opencode-ai/schema/question", Question),
...namespaceTypes("Reference", "@opencode-ai/schema/reference", Reference),
...namespaceTypes("Session", "@opencode-ai/schema/session", Session),
...namespaceTypes("SessionMessage", "@opencode-ai/schema/session-message", SessionMessage),
...namespaceTypes("SessionPending", "@opencode-ai/schema/session-pending", SessionPending),
...namespaceTypes("Shell", "@opencode-ai/schema/shell", Shell),
...namespaceTypes("Skill", "@opencode-ai/schema/skill", Skill),
...namespaceTypes("Vcs", "@opencode-ai/schema/vcs", Vcs),
...namespaceTypes("WebSearch", "@opencode-ai/schema/websearch", WebSearch),
...namespaceTypes("Workspace", "@opencode-ai/schema/workspace", Workspace),
typeReference("Prompt", "@opencode-ai/schema/prompt", Prompt),
typeReference("PromptMention", "@opencode-ai/schema/prompt", PromptMention),
typeReference("FileAttachment", "@opencode-ai/schema/prompt", FileAttachment),
typeReference("AgentAttachment", "@opencode-ai/schema/prompt", AgentAttachment),
typeReference("AbsolutePath", "@opencode-ai/schema/schema", AbsolutePath),
typeReference("PositiveInt", "@opencode-ai/schema/schema", PositiveInt),
typeReference("RelativePath", "@opencode-ai/schema/schema", RelativePath),
]
await Effect.runPromise(
Effect.all(
@@ -22,14 +97,40 @@ await Effect.runPromise(
fileURLToPath(new URL("../src/promise/generated", import.meta.url)),
),
write(
emitEffectImported(effectContract, { module: "../../contract", api: "ClientApi" }),
emitEffectImported(effectContract, {
module: "../../contract",
api: "ClientApi",
shapeModule: "../api/api.js",
}),
fileURLToPath(new URL("../src/effect/generated", import.meta.url)),
),
write(
emitEffectShape(effectContract, { module: "../../contract", api: "ClientApi" }),
emitEffectShape(effectContract, {
typeReferences: effectTypeReferences,
outputTypes: {
"event.subscribe": {
name: "OpenCodeEvent",
import: 'import type { OpenCodeEvent } from "@opencode-ai/protocol/groups/event"',
},
},
}),
fileURLToPath(new URL("../src/effect/api", import.meta.url)),
),
],
{ concurrency: 3, discard: true },
).pipe(Effect.provide(NodeFileSystem.layer)),
)
function namespaceTypes(namespace: string, module: string, values: object) {
return Object.entries(values).flatMap(([name, schema]) =>
Schema.isSchema(schema) ? [typeReference(`${namespace}.${name}`, module, schema)] : [],
)
}
function typeReference(name: string, module: string, schema: Schema.Top) {
return {
schema,
name,
import: `import type { ${name.split(".")[0]} } from ${JSON.stringify(module)}`,
}
}
+3 -1
View File
@@ -1,7 +1,9 @@
import type { ModelApi, ProviderApi } from "./api/api.js"
import type { ModelApi, ProviderApi, WebsearchApi } from "./api/api.js"
export type * from "./api/api.js"
export type WebSearchApi<E = never> = WebsearchApi<E>
export interface CatalogApi<E = never> {
readonly provider: ProviderApi<E>
readonly model: ModelApi<E>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+2
View File
@@ -14,6 +14,7 @@ export type {
PluginApi,
ProviderApi,
ReferenceApi,
WebSearchApi,
SessionApi,
SkillApi,
} from "./api.js"
@@ -35,6 +36,7 @@ export { Provider } from "@opencode-ai/schema/provider"
export { Pty } from "@opencode-ai/schema/pty"
export { Question } from "@opencode-ai/schema/question"
export { Reference } from "@opencode-ai/schema/reference"
export { WebSearch } from "@opencode-ai/schema/websearch"
export { AbsolutePath, RelativePath } from "@opencode-ai/schema/schema"
export { Session } from "@opencode-ai/schema/session"
export { SessionPending } from "@opencode-ai/schema/session-pending"
+1
View File
@@ -8,6 +8,7 @@ export type ModelApi = Client["model"]
export type PluginApi = Client["plugin"]
export type ProviderApi = Client["provider"]
export type ReferenceApi = Client["reference"]
export type WebSearchApi = Client["websearch"]
export type SessionApi = Client["session"]
export type SkillApi = Client["skill"]
@@ -7,6 +7,8 @@ import type {
LocationGetOutput,
AgentListInput,
AgentListOutput,
AgentGetInput,
AgentGetOutput,
PluginListInput,
PluginListOutput,
SessionListInput,
@@ -207,6 +209,10 @@ import type {
DebugLocationListOutput,
DebugLocationEvictInput,
DebugLocationEvictOutput,
WebsearchProvidersInput,
WebsearchProvidersOutput,
WebsearchQueryInput,
WebsearchQueryOutput,
} from "./types"
import { ClientError } from "./client-error"
@@ -399,6 +405,18 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
get: (input: AgentGetInput, requestOptions?: RequestOptions) =>
request<AgentGetOutput>(
{
method: "GET",
path: `/api/agent/${encodeURIComponent(input.agentID)}`,
query: { location: input["location"] },
successStatus: 200,
declaredStatuses: [404, 401, 400],
empty: false,
},
requestOptions,
),
},
plugin: {
list: (input?: PluginListInput, requestOptions?: RequestOptions) =>
@@ -1735,6 +1753,33 @@ export function make(options: ClientOptions) {
),
},
},
websearch: {
providers: (input?: WebsearchProvidersInput, requestOptions?: RequestOptions) =>
request<WebsearchProvidersOutput>(
{
method: "GET",
path: `/api/websearch/provider`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [503, 401, 400],
empty: false,
},
requestOptions,
),
query: (input: WebsearchQueryInput, requestOptions?: RequestOptions) =>
request<WebsearchQueryOutput>(
{
method: "POST",
path: `/api/websearch`,
query: { location: input["location"] },
body: { query: input["query"], providerID: input["providerID"] },
successStatus: 200,
declaredStatuses: [400, 503, 401],
empty: false,
},
requestOptions,
),
},
}
}
+204 -200
View File
@@ -6,11 +6,11 @@ export type ServiceStopResponse = { accepted: boolean }
export type ModelRef = { id: string; providerID: string; variant?: string }
export type ProviderSettings = { [x: string]: JsonValue }
export type ProviderSettings = { [x: string]: any }
export type AgentColor = string
export type PermissionV2Effect = "allow" | "deny" | "ask"
export type PermissionEffect = "allow" | "deny" | "ask"
export type PluginInfo = { id: string }
@@ -98,8 +98,6 @@ export type SessionMessageShell = {
output?: { output: string; cursor: number; size: number; truncated: boolean }
}
export type SessionMessageAssistantText = { type: "text"; text: string }
export type SessionMessageProviderState = { [x: string]: JsonValue }
export type SessionMessageToolStateStreaming = { status: "streaming"; input: string }
@@ -157,8 +155,6 @@ export type ShellInfo = {
time: { started: number; completed?: number }
}
export type SessionMessageProviderState3 = { [x: string]: any }
export type SessionMessageProviderState4 = { [x: string]: any }
export type SessionMessageProviderState5 = { [x: string]: any }
@@ -167,6 +163,10 @@ export type SessionMessageProviderState6 = { [x: string]: any }
export type SessionMessageProviderState7 = { [x: string]: any }
export type SessionMessageProviderState8 = { [x: string]: any }
export type SessionMessageProviderState9 = { [x: string]: any }
export type EventLogSynced = { type: "log.synced"; aggregateID: string; seq?: number }
export type ModelReasoningField = "reasoning" | "reasoning_content" | "reasoning_text" | (string & {})
@@ -175,24 +175,24 @@ export type ModelCapabilities = { tools: boolean; input: Array<string>; output:
export type ModelVariant = {
id: string
settings?: { [x: string]: JsonValue }
settings?: { [x: string]: any }
headers?: { [x: string]: string }
body?: { [x: string]: JsonValue }
body?: { [x: string]: any }
}
export type MoneyUSDPerMillionTokens = number
export type GenerateTextResponse = { data: { text: string } }
export type ProviderV2Info = {
export type ProviderInfo = {
id: string
integrationID?: string
name: string
disabled?: boolean
package: string
settings?: { [x: string]: JsonValue }
settings?: { [x: string]: any }
headers?: { [x: string]: string }
body?: { [x: string]: JsonValue }
body?: { [x: string]: any }
}
export type IntegrationWhen = { key: string; op: "eq" | "neq"; value: string }
@@ -299,7 +299,7 @@ export type FormExternalField = { key: string; type: "external"; url: string; ti
export type FormValue = string | number | boolean | Array<string>
export type PermissionV2Source = { type: "tool"; messageID: string; callID: string }
export type PermissionSource = { type: "tool"; messageID: string; callID: string }
export type PermissionSavedInfo = { id: string; projectID: string; action: string; resource: string }
@@ -323,9 +323,9 @@ export type FileDiffLegacyInfo = {
status?: "added" | "deleted" | "modified"
}
export type PermissionAction = "allow" | "deny" | "ask"
export type PermissionV1Action = "allow" | "deny" | "ask"
export type JSONSchema = { [x: string]: any }
export type SessionV1JSONSchema = { [x: string]: any }
export type ProviderAuthError = { name: "ProviderAuthError"; data: { providerID: string; message: string } }
@@ -356,7 +356,7 @@ export type APIError = {
}
}
export type TextPart = {
export type SessionV1TextPart = {
id: string
sessionID: string
messageID: string
@@ -368,7 +368,7 @@ export type TextPart = {
metadata?: { [x: string]: any } | undefined
}
export type SubtaskPart = {
export type SessionV1SubtaskPart = {
id: string
sessionID: string
messageID: string
@@ -380,7 +380,7 @@ export type SubtaskPart = {
command?: string | undefined
}
export type ReasoningPart = {
export type SessionV1ReasoningPart = {
id: string
sessionID: string
messageID: string
@@ -390,13 +390,13 @@ export type ReasoningPart = {
time: { start: number; end?: number | undefined }
}
export type FilePartSourceText = { value: string; start: number; end: number }
export type SessionV1FilePartSourceText = { value: string; start: number; end: number }
export type Range = { start: { line: number; character: number }; end: { line: number; character: number } }
export type SessionV1Range = { start: { line: number; character: number }; end: { line: number; character: number } }
export type ToolStatePending = { status: "pending"; input: { [x: string]: any }; raw: string }
export type SessionV1ToolStatePending = { status: "pending"; input: { [x: string]: any }; raw: string }
export type ToolStateRunning = {
export type SessionV1ToolStateRunning = {
status: "running"
input: { [x: string]: any }
title?: string | undefined
@@ -404,7 +404,7 @@ export type ToolStateRunning = {
time: { start: number }
}
export type ToolStateError = {
export type SessionV1ToolStateError = {
status: "error"
input: { [x: string]: any }
error: string
@@ -412,7 +412,7 @@ export type ToolStateError = {
time: { start: number; end: number }
}
export type StepStartPart = {
export type SessionV1StepStartPart = {
id: string
sessionID: string
messageID: string
@@ -420,7 +420,7 @@ export type StepStartPart = {
snapshot?: string | undefined
}
export type StepFinishPart = {
export type SessionV1StepFinishPart = {
id: string
sessionID: string
messageID: string
@@ -437,9 +437,15 @@ export type StepFinishPart = {
}
}
export type SnapshotPart = { id: string; sessionID: string; messageID: string; type: "snapshot"; snapshot: string }
export type SessionV1SnapshotPart = {
id: string
sessionID: string
messageID: string
type: "snapshot"
snapshot: string
}
export type PatchPart = {
export type SessionV1PatchPart = {
id: string
sessionID: string
messageID: string
@@ -448,7 +454,7 @@ export type PatchPart = {
files: Array<string>
}
export type AgentPart = {
export type SessionV1AgentPart = {
id: string
sessionID: string
messageID: string
@@ -457,7 +463,7 @@ export type AgentPart = {
source?: { value: string; start: number; end: number } | undefined
}
export type CompactionPart = {
export type SessionV1CompactionPart = {
id: string
sessionID: string
messageID: string
@@ -467,7 +473,7 @@ export type CompactionPart = {
tail_start_id?: string | undefined
}
export type PermissionV2Reply = "once" | "always" | "reject"
export type PermissionReply = "once" | "always" | "reject"
export type Pty = {
id: string
@@ -480,11 +486,11 @@ export type Pty = {
exitCode?: number
}
export type QuestionV2Option = { label: string; description: string }
export type QuestionOption = { label: string; description: string }
export type QuestionV2Tool = { messageID: string; callID: string }
export type QuestionTool = { messageID: string; callID: string }
export type QuestionV2Answer = Array<string>
export type QuestionAnswer = Array<string>
export type FormMetadata1 = { [x: string]: any }
@@ -501,12 +507,6 @@ export type SessionStatus =
}
| { type: "busy" }
export type QuestionOption = { label: string; description: string }
export type QuestionTool = { messageID: string; callID: string }
export type QuestionAnswer = Array<string>
export type ShellInfo1 = {
id: string
status: "running" | "exited" | "timeout" | "killed"
@@ -539,6 +539,10 @@ export type VcsFileStatus = {
status: "added" | "deleted" | "modified"
}
export type WebSearchProvider = { id: string; name: string }
export type WebSearchResult = { url: string; title?: string; content?: string; time: { published?: number } }
export type SessionMessageModelSelected = {
id: string
metadata?: { [x: string]: JsonValue }
@@ -560,10 +564,10 @@ export type CommandInfo = {
export type ProviderRequest = {
settings: ProviderSettings
headers: { [x: string]: string }
body: { [x: string]: JsonValue }
body: { [x: string]: any }
}
export type PermissionV2Rule = { action: string; resource: string; effect: PermissionV2Effect }
export type PermissionRule = { action: string; resource: string; effect: PermissionEffect }
export type SessionAgentSelected = {
id: string
@@ -733,16 +737,6 @@ export type SessionTextStarted = {
data: { sessionID: string; assistantMessageID: string; ordinal: number }
}
export type SessionTextEnded = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.text.ended"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; assistantMessageID: string; ordinal: number; text: string }
}
export type SessionToolInputStarted = {
id: string
created: number
@@ -1050,11 +1044,11 @@ export type ShellDeleted = {
data: { id: string }
}
export type QuestionV2Rejected = {
export type QuestionRejected = {
id: string
created: number
metadata?: { [x: string]: any }
type: "question.v2.rejected"
type: "question.rejected"
location?: LocationRef
data: { sessionID: string; requestID: string }
}
@@ -1068,6 +1062,15 @@ export type FormCancelled = {
data: { id: string; sessionID: string }
}
export type WebsearchUpdated = {
id: string
created: number
metadata?: { [x: string]: any }
type: "websearch.updated"
location?: LocationRef
data: {}
}
export type SessionIdle = {
id: string
created: number
@@ -1183,41 +1186,6 @@ export type McpResourcesChanged = {
data: { server: string }
}
export type PermissionAsked = {
id: string
created: number
metadata?: { [x: string]: any }
type: "permission.asked"
location?: LocationRef
data: {
id: string
sessionID: string
permission: string
patterns: Array<string>
metadata: { [x: string]: any }
always: Array<string>
tool?: { messageID: string; callID: string } | undefined
}
}
export type PermissionReplied = {
id: string
created: number
metadata?: { [x: string]: any }
type: "permission.replied"
location?: LocationRef
data: { sessionID: string; requestID: string; reply: "once" | "always" | "reject" }
}
export type QuestionRejected = {
id: string
created: number
metadata?: { [x: string]: any }
type: "question.rejected"
location?: LocationRef
data: { sessionID: string; requestID: string }
}
export type V2EventServerConnected = {
id: string
metadata?: { [x: string]: any } | undefined
@@ -1249,6 +1217,8 @@ export type SessionPendingSynthetic = {
delivery: "steer" | "queue"
}
export type SessionMessageAssistantText = { type: "text"; text: string; state?: SessionMessageProviderState }
export type SessionMessageAssistantReasoning = {
type: "reasoning"
text: string
@@ -1359,6 +1329,22 @@ export type ShellCreated = {
data: { info: ShellInfo }
}
export type SessionTextEnded = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.text.ended"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: {
sessionID: string
assistantMessageID: string
ordinal: number
text: string
state?: SessionMessageProviderState4
}
}
export type SessionReasoningStarted = {
id: string
created: number
@@ -1366,7 +1352,7 @@ export type SessionReasoningStarted = {
type: "session.reasoning.started"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; assistantMessageID: string; ordinal: number; state?: SessionMessageProviderState3 }
data: { sessionID: string; assistantMessageID: string; ordinal: number; state?: SessionMessageProviderState5 }
}
export type SessionReasoningEnded = {
@@ -1381,7 +1367,7 @@ export type SessionReasoningEnded = {
assistantMessageID: string
ordinal: number
text: string
state?: SessionMessageProviderState4
state?: SessionMessageProviderState6
}
}
@@ -1398,7 +1384,7 @@ export type SessionToolCalled = {
callID: string
input: { [x: string]: any }
executed: boolean
state?: SessionMessageProviderState5
state?: SessionMessageProviderState7
}
}
@@ -1523,21 +1509,21 @@ export type FormMultiselectField = {
export type FormAnswer = { [x: string]: FormValue }
export type PermissionV2Request = {
export type PermissionRequest = {
id: string
sessionID: string
action: string
resources: Array<string>
save?: Array<string>
metadata?: { [x: string]: JsonValue }
source?: PermissionV2Source
source?: PermissionSource
}
export type PermissionV2Asked = {
export type PermissionAsked = {
id: string
created: number
metadata?: { [x: string]: any }
type: "permission.v2.asked"
type: "permission.asked"
location?: LocationRef
data: {
id: string
@@ -1546,17 +1532,17 @@ export type PermissionV2Asked = {
resources: Array<string>
save?: Array<string>
metadata?: { [x: string]: any }
source?: PermissionV2Source
source?: PermissionSource
}
}
export type PermissionRule = { permission: string; pattern: string; action: PermissionAction }
export type PermissionV1Rule = { permission: string; pattern: string; action: PermissionV1Action }
export type OutputFormat =
export type SessionV1OutputFormat =
| { type: "text" }
| { type: "json_schema"; schema: JSONSchema; retryCount?: number | undefined | undefined }
| { type: "json_schema"; schema: SessionV1JSONSchema; retryCount?: number | undefined | undefined }
export type AssistantMessage = {
export type SessionV1AssistantMessage = {
id: string
sessionID: string
role: "assistant"
@@ -1591,7 +1577,7 @@ export type AssistantMessage = {
finish?: string | undefined
}
export type RetryPart = {
export type SessionV1RetryPart = {
id: string
sessionID: string
messageID: string
@@ -1622,26 +1608,31 @@ export type SessionError = {
}
}
export type FileSource = { text: FilePartSourceText; type: "file"; path: string }
export type SessionV1FileSource = { text: SessionV1FilePartSourceText; type: "file"; path: string }
export type ResourceSource = { text: FilePartSourceText; type: "resource"; clientName: string; uri: string }
export type SessionV1ResourceSource = {
text: SessionV1FilePartSourceText
type: "resource"
clientName: string
uri: string
}
export type SymbolSource = {
text: FilePartSourceText
export type SessionV1SymbolSource = {
text: SessionV1FilePartSourceText
type: "symbol"
path: string
range: Range
range: SessionV1Range
name: string
kind: number
}
export type PermissionV2Replied = {
export type PermissionReplied = {
id: string
created: number
metadata?: { [x: string]: any }
type: "permission.v2.replied"
type: "permission.replied"
location?: LocationRef
data: { sessionID: string; requestID: string; reply: PermissionV2Reply }
data: { sessionID: string; requestID: string; reply: PermissionReply }
}
export type PtyCreated = {
@@ -1662,21 +1653,21 @@ export type PtyUpdated = {
data: { info: Pty }
}
export type QuestionV2Info = {
export type QuestionInfo = {
question: string
header: string
options: Array<QuestionV2Option>
options: Array<QuestionOption>
multiple?: boolean
custom?: boolean
}
export type QuestionV2Replied = {
export type QuestionReplied = {
id: string
created: number
metadata?: { [x: string]: any }
type: "question.v2.replied"
type: "question.replied"
location?: LocationRef
data: { sessionID: string; requestID: string; answers: Array<QuestionV2Answer> }
data: { sessionID: string; requestID: string; answers: Array<QuestionAnswer> }
}
export type FormStringField1 = {
@@ -1753,26 +1744,9 @@ export type SessionStatus2 = {
data: { sessionID: string; status: SessionStatus }
}
export type QuestionInfo = {
question: string
header: string
options: Array<QuestionOption>
multiple?: boolean | undefined
custom?: boolean | undefined
}
export type QuestionReplied = {
id: string
created: number
metadata?: { [x: string]: any }
type: "question.replied"
location?: LocationRef
data: { sessionID: string; requestID: string; answers: Array<QuestionAnswer> }
}
export type ReferenceSource = ReferenceLocalSource | ReferenceGitSource
export type PermissionV2Ruleset = Array<PermissionV2Rule>
export type PermissionRuleset = Array<PermissionRule>
export type SessionInfo = {
id: string
@@ -1853,7 +1827,7 @@ export type SessionToolSuccess = {
content: [LLMToolContent, ...Array<LLMToolContent>]
metadata?: { [x: string]: JsonValue }
executed: boolean
resultState?: SessionMessageProviderState6
resultState?: SessionMessageProviderState8
}
}
@@ -1872,7 +1846,7 @@ export type SessionToolFailed = {
content?: [LLMToolContent, ...Array<LLMToolContent>]
metadata?: { [x: string]: JsonValue }
executed: boolean
resultState?: SessionMessageProviderState7
resultState?: SessionMessageProviderState9
}
}
@@ -1889,9 +1863,9 @@ export type ModelInfo = {
name: string
compatibility?: ModelCompatibility
package?: string
settings?: { [x: string]: JsonValue }
settings?: { [x: string]: any }
headers?: { [x: string]: string }
body?: { [x: string]: JsonValue }
body?: { [x: string]: any }
capabilities: ModelCapabilities
variants: Array<ModelVariant>
time: { released: number }
@@ -1927,14 +1901,14 @@ export type FormReplied = {
data: { id: string; sessionID: string; answer: FormAnswer }
}
export type PermissionRuleset = Array<PermissionRule>
export type PermissionV1Ruleset = Array<PermissionV1Rule>
export type UserMessage = {
export type SessionV1UserMessage = {
id: string
sessionID: string
role: "user"
time: { created: number }
format?: OutputFormat | undefined
format?: SessionV1OutputFormat | undefined
summary?: { title?: string | undefined; body?: string | undefined; diffs: Array<FileDiffLegacyInfo> } | undefined
agent: string
model: { providerID: string; modelID: string; variant?: string | undefined }
@@ -1942,23 +1916,18 @@ export type UserMessage = {
tools?: { [x: string]: boolean } | undefined
}
export type FilePartSource = FileSource | SymbolSource | ResourceSource
export type SessionV1FilePartSource = SessionV1FileSource | SessionV1SymbolSource | SessionV1ResourceSource
export type QuestionV2Asked = {
export type QuestionAsked = {
id: string
created: number
metadata?: { [x: string]: any }
type: "question.v2.asked"
type: "question.asked"
location?: LocationRef
data: { id: string; sessionID: string; questions: Array<QuestionV2Info>; tool?: QuestionV2Tool }
data: { id: string; sessionID: string; questions: Array<QuestionInfo>; tool?: QuestionTool }
}
export type QuestionV2Request = {
id: string
sessionID: string
questions: Array<QuestionV2Info>
tool?: QuestionV2Tool
}
export type QuestionRequest = { id: string; sessionID: string; questions: Array<QuestionInfo>; tool?: QuestionTool }
export type FormField1 =
| FormStringField1
@@ -1968,15 +1937,6 @@ export type FormField1 =
| FormMultiselectField1
| FormExternalField
export type QuestionAsked = {
id: string
created: number
metadata?: { [x: string]: any }
type: "question.asked"
location?: LocationRef
data: { id: string; sessionID: string; questions: Array<QuestionInfo>; tool?: QuestionTool | undefined }
}
export type ReferenceInfo = {
name: string
path: string
@@ -1996,7 +1956,7 @@ export type AgentInfo = {
hidden: boolean
color?: AgentColor
steps?: number
permissions: PermissionV2Ruleset
permissions: PermissionRuleset
}
export type SessionsResponse = { data: Array<SessionInfo>; cursor: { previous?: string | null; next?: string | null } }
@@ -2054,13 +2014,13 @@ export type SessionV1Info = {
version: string
metadata?: { [x: string]: any }
time: { created: number; updated: number; compacting?: number; archived?: number }
permission?: PermissionRuleset
permission?: PermissionV1Ruleset
revert?: { messageID: string; partID?: string; snapshot?: string; diff?: string }
}
export type Message = UserMessage | AssistantMessage
export type SessionV1Message = SessionV1UserMessage | SessionV1AssistantMessage
export type FilePart = {
export type SessionV1FilePart = {
id: string
sessionID: string
messageID: string
@@ -2068,7 +2028,7 @@ export type FilePart = {
mime: string
filename?: string | undefined
url: string
source?: FilePartSource | undefined
source?: SessionV1FilePartSource | undefined
}
export type FormFields1 = [FormField1, ...Array<FormField1>]
@@ -2139,17 +2099,17 @@ export type MessageUpdated = {
type: "message.updated"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; info: Message }
data: { sessionID: string; info: SessionV1Message }
}
export type ToolStateCompleted = {
export type SessionV1ToolStateCompleted = {
status: "completed"
input: { [x: string]: any }
output: string
title: string
metadata: { [x: string]: any }
time: { start: number; end: number; compacted?: number | undefined }
attachments?: Array<FilePart> | undefined
attachments?: Array<SessionV1FilePart> | undefined
}
export type FormInfo1 = { id: string; sessionID: string; title: string; metadata?: FormMetadata1; fields: FormFields1 }
@@ -2175,7 +2135,11 @@ export type SessionMessageInfo =
| SessionMessageAssistant
| SessionMessageCompaction
export type ToolState = ToolStatePending | ToolStateRunning | ToolStateCompleted | ToolStateError
export type SessionV1ToolState =
| SessionV1ToolStatePending
| SessionV1ToolStateRunning
| SessionV1ToolStateCompleted
| SessionV1ToolStateError
export type FormCreated = {
id: string
@@ -2231,32 +2195,32 @@ export type SessionMessagesResponse = {
cursor: { previous?: string | null; next?: string | null }
}
export type ToolPart = {
export type SessionV1ToolPart = {
id: string
sessionID: string
messageID: string
type: "tool"
callID: string
tool: string
state: ToolState
state: SessionV1ToolState
metadata?: { [x: string]: any } | undefined
}
export type SessionLogItem = SessionEventDurable | EventLogSynced
export type Part =
| TextPart
| SubtaskPart
| ReasoningPart
| FilePart
| ToolPart
| StepStartPart
| StepFinishPart
| SnapshotPart
| PatchPart
| AgentPart
| RetryPart
| CompactionPart
export type SessionV1Part =
| SessionV1TextPart
| SessionV1SubtaskPart
| SessionV1ReasoningPart
| SessionV1FilePart
| SessionV1ToolPart
| SessionV1StepStartPart
| SessionV1StepFinishPart
| SessionV1SnapshotPart
| SessionV1PatchPart
| SessionV1AgentPart
| SessionV1RetryPart
| SessionV1CompactionPart
export type MessagePartUpdated = {
id: string
@@ -2265,7 +2229,7 @@ export type MessagePartUpdated = {
type: "message.part.updated"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; part: Part; time: number }
data: { sessionID: string; part: SessionV1Part; time: number }
}
export type V2Event =
@@ -2326,8 +2290,8 @@ export type V2Event =
| SessionRevertCommitted
| FilesystemChanged
| ReferenceUpdated
| PermissionV2Asked
| PermissionV2Replied
| PermissionAsked
| PermissionReplied
| PluginAdded
| PluginUpdated
| ProjectDirectoriesUpdated
@@ -2341,12 +2305,13 @@ export type V2Event =
| ShellCreated
| ShellExited
| ShellDeleted
| QuestionV2Asked
| QuestionV2Replied
| QuestionV2Rejected
| QuestionAsked
| QuestionReplied
| QuestionRejected
| FormCreated
| FormReplied
| FormCancelled
| WebsearchUpdated
| SessionStatus2
| SessionIdle
| TuiPromptAppend
@@ -2358,11 +2323,6 @@ export type V2Event =
| VcsBranchUpdated
| McpStatusChanged
| McpResourcesChanged
| PermissionAsked
| PermissionReplied
| QuestionAsked
| QuestionReplied
| QuestionRejected
| SessionError
| V2EventServerConnected
@@ -2379,6 +2339,14 @@ export type InvalidRequestError = {
export const isInvalidRequestError = (value: unknown): value is InvalidRequestError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "InvalidRequestError"
export type AgentNotFoundError = {
readonly _tag: "AgentNotFoundError"
readonly agentID: string
readonly message: string
}
export const isAgentNotFoundError = (value: unknown): value is AgentNotFoundError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "AgentNotFoundError"
export type InvalidCursorError = { readonly _tag: "InvalidCursorError"; readonly message: string }
export const isInvalidCursorError = (value: unknown): value is InvalidCursorError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "InvalidCursorError"
@@ -2562,6 +2530,18 @@ export type AgentListOutput = {
data: Array<AgentInfo>
}
export type AgentGetInput = {
readonly agentID: { readonly agentID: string }["agentID"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type AgentGetOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
data: AgentInfo
}
export type PluginListInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
@@ -3290,7 +3270,7 @@ export type ProviderListInput = {
export type ProviderListOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
data: Array<ProviderV2Info>
data: Array<ProviderInfo>
}
export type ProviderGetInput = {
@@ -3302,7 +3282,7 @@ export type ProviderGetInput = {
export type ProviderGetOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
data: ProviderV2Info
data: ProviderInfo
}
export type IntegrationListInput = {
@@ -4454,7 +4434,7 @@ export type PermissionRequestListInput = {
export type PermissionRequestListOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
data: Array<PermissionV2Request>
data: Array<PermissionRequest>
}
export type PermissionSavedListInput = { readonly projectID?: { readonly projectID?: string | undefined }["projectID"] }
@@ -4532,18 +4512,18 @@ export type PermissionCreateInput = {
}["agent"]
}
export type PermissionCreateOutput = { data: { id: string; effect: PermissionV2Effect } }["data"]
export type PermissionCreateOutput = { data: { id: string; effect: PermissionEffect } }["data"]
export type PermissionListInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
export type PermissionListOutput = { data: Array<PermissionV2Request> }["data"]
export type PermissionListOutput = { data: Array<PermissionRequest> }["data"]
export type PermissionGetInput = {
readonly sessionID: { readonly sessionID: string; readonly requestID: string }["sessionID"]
readonly requestID: { readonly sessionID: string; readonly requestID: string }["requestID"]
}
export type PermissionGetOutput = { data: PermissionV2Request }["data"]
export type PermissionGetOutput = { data: PermissionRequest }["data"]
export type PermissionReplyInput = {
readonly sessionID: { readonly sessionID: string; readonly requestID: string }["sessionID"]
@@ -4842,12 +4822,12 @@ export type QuestionRequestListInput = {
export type QuestionRequestListOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
data: Array<QuestionV2Request>
data: Array<QuestionRequest>
}
export type QuestionListInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
export type QuestionListOutput = { data: Array<QuestionV2Request> }["data"]
export type QuestionListOutput = { data: Array<QuestionRequest> }["data"]
export type QuestionReplyInput = {
readonly sessionID: { readonly sessionID: string; readonly requestID: string }["sessionID"]
@@ -4950,3 +4930,27 @@ export type DebugLocationEvictInput = {
}
export type DebugLocationEvictOutput = void
export type WebsearchProvidersInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type WebsearchProvidersOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
data: Array<WebSearchProvider>
}
export type WebsearchQueryInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
readonly query: { readonly query: string; readonly providerID?: string }["query"]
readonly providerID?: { readonly query: string; readonly providerID?: string }["providerID"]
}
export type WebsearchQueryOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
data: { providerID: string; results: Array<WebSearchResult> }
}
+1
View File
@@ -9,6 +9,7 @@ export type {
PluginApi,
ProviderApi,
ReferenceApi,
WebSearchApi,
SessionApi,
SkillApi,
} from "./api.js"
+6 -1
View File
@@ -1,5 +1,6 @@
import { Effect } from "effect"
import { OpenCode as EffectOpenCode, type AppApi as EffectApi } from "../src/effect"
import type { Session } from "@opencode-ai/schema/session"
type EffectClient = Effect.Success<ReturnType<typeof EffectOpenCode.make>>
type PromiseClient = ReturnType<typeof import("../src/promise").OpenCode.make>
@@ -9,6 +10,10 @@ declare const promiseClient: PromiseClient
const effectApi: EffectApi<unknown> = effectClient
const effectSession: Effect.Effect<Session.Info, unknown> = effectClient.session.get({
sessionID: "ses_test" as Session.ID,
})
declare const sessionID: Parameters<typeof effectApi.session.instructions.entry.list>[0]["sessionID"]
const effectList: Effect.Effect<
@@ -37,4 +42,4 @@ const promiseRemove: Promise<void> = promiseClient.session.instructions.entry.re
key: "review-notes",
})
void [effectList, effectPut, effectRemove, promiseList, promisePut, promiseRemove]
void [effectSession, effectList, effectPut, effectRemove, promiseList, promisePut, promiseRemove]
@@ -14,6 +14,14 @@ test("effect entrypoint exposes canonical Schema contracts", () => {
expect(Client.Session).toBe(Session)
})
test("generated Effect API names canonical and composed outputs", async () => {
const source = await Bun.file(new URL("../src/effect/api/api.ts", import.meta.url)).text()
expect(source).toContain("export type Endpoint5_3Output = Session.Info")
expect(source).toContain("export type Endpoint19_0Output = OpenCodeEvent")
expect(source).not.toContain("HttpApiClient.ForApi")
})
test("shared DTO schemas construct and decode plain objects", () => {
const made = Prompt.make({ text: "hello" })
const decoded = Schema.decodeUnknownSync(Prompt)({ text: "hello" })
+33
View File
@@ -32,6 +32,7 @@ test("exposes every standard HTTP API group", () => {
"projectCopy",
"vcs",
"debug",
"websearch",
])
expect(Object.keys(client.debug)).toEqual(["location"])
expect(Object.keys(client.debug.location)).toEqual(["list", "evict"])
@@ -41,6 +42,7 @@ test("exposes every standard HTTP API group", () => {
expect(Object.keys(client.integration.connect)).toEqual(["key"])
expect(Object.keys(client.integration.oauth)).toEqual(["connect", "status", "complete", "cancel"])
expect(Object.keys(client.integration.command)).toEqual(["connect", "status", "cancel"])
expect(Object.keys(client.websearch)).toEqual(["providers", "query"])
expect(Object.keys(client.file)).toEqual(["read", "list", "find"])
expect(Object.keys(client.vcs)).toEqual(["status", "diff"])
expect(Object.keys(client.pty)).toEqual(["list", "create", "get", "update", "remove"])
@@ -48,6 +50,37 @@ test("exposes every standard HTTP API group", () => {
expect(Object.keys(client.project)).toEqual(["list", "current", "directories"])
})
test("websearch.query uses the public HTTP contract", async () => {
let request: Request | undefined
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: async (input, init) => {
request = input instanceof Request ? input : new Request(input, init)
return Response.json({
location: { directory: "/tmp/project", project: { id: "proj_test", directory: "/tmp/project" } },
data: {
providerID: "exa",
results: [{ url: "https://example.com", title: "Result", content: "result", time: {} }],
},
})
},
})
const result = await client.websearch.query({
query: "opencode",
providerID: "exa",
location: { directory: "/tmp/project" },
})
expect(result.data).toEqual({
providerID: "exa",
results: [{ url: "https://example.com", title: "Result", content: "result", time: {} }],
})
expect(request?.method).toBe("POST")
expect(request?.url).toBe("http://localhost:3000/api/websearch?location%5Bdirectory%5D=%2Ftmp%2Fproject")
expect(await request?.json()).toEqual({ query: "opencode", providerID: "exa" })
})
test("server.get uses the public HTTP contract", async () => {
let request: Request | undefined
const client = OpenCode.make({
+1 -1
View File
@@ -40,7 +40,7 @@ export type ExecuteOptions<Provided extends Record<string, unknown> = {}> = {
limits?: ExecutionLimits
/** Observes decoded tool input immediately before tool execution. */
onToolCallStart?: (call: ToolRuntime.ToolCallStarted) => Effect.Effect<void, never, Services<Provided>>
/** Observes each admitted tool call as it settles, with outcome and duration. */
/** Observes each admitted tool call as it succeeds, fails, or is interrupted. */
onToolCallEnd?: (call: ToolRuntime.ToolCallEnded) => Effect.Effect<void, never, Services<Provided>>
}
+20 -22
View File
@@ -1,4 +1,4 @@
import { Cause, Effect, Schema } from "effect"
import { Cause, Effect, Exit, Schema } from "effect"
import { ToolError, toolError } from "./tool-error.js"
import {
decodeInput as decodeToolInput,
@@ -52,7 +52,7 @@ export type ToolCallEnded = {
readonly name: string
readonly input: unknown
readonly durationMs: number
readonly outcome: "success" | "failure"
readonly outcome: "success" | "failure" | "interrupted"
readonly message?: string
}
@@ -342,7 +342,6 @@ export type DiscoveryPlan = {
export type SearchEntry = {
readonly description: ToolDescription
readonly namespace: string
readonly searchText: string
}
@@ -373,7 +372,11 @@ const makeSearchTool = (searchIndex: ReadonlyArray<SearchEntry>): Tool => ({
const scoped =
request.namespace === undefined
? searchIndex
: searchIndex.filter((entry) => entry.namespace === request.namespace)
: searchIndex.filter(
(entry) =>
entry.description.path === request.namespace ||
entry.description.path.startsWith(`${request.namespace}.`),
)
const trimmed = query.trim()
const pathQuery = trimmed.startsWith("tools.") ? trimmed.slice("tools.".length) : trimmed
const exact =
@@ -428,7 +431,6 @@ export const searchSignature = (() => {
const toSearchEntry = <R>(path: string, tool: Tool<R>, description: ToolDescription): SearchEntry => ({
description,
namespace: path.split(".", 1)[0]!,
searchText: [
path,
tool.description,
@@ -495,22 +497,19 @@ export const make = <R>(
const root = toolTrie(tools)
const searchTool = makeSearchTool(searchIndex)
// End hooks observe settled success or failure; interruption emits neither outcome.
const observeEnd = <A, E>(effect: Effect.Effect<A, E, R>, call: ToolCallStarted): Effect.Effect<A, E, R> => {
const onEnd = hooks?.onToolCallEnd
if (onEnd === undefined) return effect
const startedAt = Date.now()
return effect.pipe(
Effect.tap(() => onEnd({ ...call, durationMs: Date.now() - startedAt, outcome: "success" })),
Effect.tapError((error) => {
Effect.onExit((exit) => {
const durationMs = Date.now() - startedAt
if (Exit.isSuccess(exit)) return onEnd({ ...call, durationMs, outcome: "success" })
if (Cause.hasInterruptsOnly(exit.cause)) return onEnd({ ...call, durationMs, outcome: "interrupted" })
const error = Cause.squash(exit.cause)
const message =
error instanceof ToolError || error instanceof ToolRuntimeError ? error.message : "Tool execution failed"
return onEnd({
...call,
durationMs: Date.now() - startedAt,
outcome: "failure",
message,
})
return onEnd({ ...call, durationMs, outcome: "failure", message })
}),
)
}
@@ -528,12 +527,6 @@ export const make = <R>(
calls.push(call)
}
const recordAndObserve = (name: string, input: unknown) =>
Effect.sync(() => {
recordCall({ name })
return calls.length - 1
}).pipe(Effect.tap((index) => hooks?.onToolCallStart?.({ index, name, input }) ?? Effect.void))
const executeTool = (name: string, tool: Tool<R>, externalArgs: Array<unknown>) =>
Effect.gen(function* () {
if (externalArgs.length !== 1)
@@ -547,9 +540,14 @@ export const make = <R>(
name === "search" ? [] : ["The signature may have changed. Use search to get the current signature."],
),
})
const index = yield* recordAndObserve(name, input)
const index = yield* Effect.sync(() => {
recordCall({ name })
return calls.length - 1
})
const call = { index, name, input }
return yield* observeEnd(
Effect.gen(function* () {
if (hooks?.onToolCallStart !== undefined) yield* hooks.onToolCallStart(call)
const raw = yield* runHost(Effect.suspend(() => tool.execute(input)))
const result = yield* Effect.try({
try: () => decodeToolOutput(tool, raw),
@@ -557,7 +555,7 @@ export const make = <R>(
})
return yield* decodeOutput(result, name)
}),
{ index, name, input },
call,
)
})
+90 -1
View File
@@ -189,7 +189,12 @@ describe("CodeMode tool-call observation", () => {
description: "Look up a value",
input: Schema.Struct({ query: Schema.String }),
output: Schema.String,
execute: ({ query }) => (query === "boom" ? Effect.fail(toolError("Lookup refused")) : Effect.succeed(query)),
execute: ({ query }) =>
query === "boom"
? Effect.fail(toolError("Lookup refused"))
: query === "defect"
? Effect.die("broken")
: Effect.succeed(query),
})
const runtime = CodeMode.make({
@@ -215,14 +220,98 @@ describe("CodeMode tool-call observation", () => {
expect(success.ok).toBe(true)
const failure = await Effect.runPromise(runtime.execute(`return await tools.context.lookup({ query: "boom" })`))
expect(failure.ok).toBe(false)
const defect = await Effect.runPromise(runtime.execute(`return await tools.context.lookup({ query: "defect" })`))
expect(defect.ok).toBe(false)
expect(events).toStrictEqual([
{ phase: "start", index: 0, name: "context.lookup" },
{ phase: "end", index: 0, name: "context.lookup", outcome: "success" },
{ phase: "start", index: 0, name: "context.lookup" },
{ phase: "end", index: 0, name: "context.lookup", outcome: "failure", message: "Lookup refused" },
{ phase: "start", index: 0, name: "context.lookup" },
{ phase: "end", index: 0, name: "context.lookup", outcome: "failure", message: "Tool execution failed" },
])
})
test("observes interrupted calls", async () => {
const events: Array<string> = []
const call = Tool.make({
description: "Interrupt",
input: Schema.Struct({}),
output: Schema.String,
execute: () => Effect.interrupt,
})
const exit = await Effect.runPromiseExit(
CodeMode.make({
tools: { host: { call } },
onToolCallStart: () => Effect.sync(() => events.push("start")),
onToolCallEnd: (call) => Effect.sync(() => events.push(`end:${call.outcome}`)),
}).execute("return await tools.host.call({})"),
)
expect(exit._tag).toBe("Failure")
expect(events).toEqual(["start", "end:interrupted"])
})
test("observes running calls interrupted during completion", async () => {
const events: Array<string> = []
const call = Tool.make({
description: "Pending",
input: Schema.Struct({}),
output: Schema.String,
execute: () => Effect.never,
})
const result = await Effect.runPromise(
CodeMode.make({
tools: { host: { call } },
onToolCallStart: () => Effect.sync(() => events.push("start")),
onToolCallEnd: (call) => Effect.sync(() => events.push(`end:${call.outcome}`)),
}).execute('tools.host.call({}); return "done"'),
)
expect(result).toMatchObject({ ok: true, value: "done" })
expect(events).toEqual(["start", "end:interrupted"])
})
test("ends calls interrupted during start observation", async () => {
const events: Array<string> = []
const call = Tool.make({
description: "Unused",
input: Schema.Struct({}),
output: Schema.String,
execute: () => Effect.succeed("unused"),
})
const exit = await Effect.runPromiseExit(
CodeMode.make({
tools: { host: { call } },
onToolCallStart: () => Effect.interrupt,
onToolCallEnd: (call) => Effect.sync(() => events.push(call.outcome)),
}).execute("return await tools.host.call({})"),
)
expect(exit._tag).toBe("Failure")
expect(events).toEqual(["interrupted"])
})
test("observes calls interrupted by the execution timeout", async () => {
const outcomes: Array<string> = []
const call = Tool.make({
description: "Pending",
input: Schema.Struct({}),
output: Schema.String,
execute: () => Effect.never,
})
const result = await Effect.runPromise(
CodeMode.make({
tools: { host: { call } },
limits: { timeoutMs: 10 },
onToolCallEnd: (call) => Effect.sync(() => outcomes.push(call.outcome)),
}).execute("return await tools.host.call({})"),
)
expect(result).toMatchObject({ ok: false, error: { kind: "TimeoutExceeded" } })
expect(outcomes).toEqual(["interrupted"])
})
})
describe("CodeMode console capture", () => {
+21
View File
@@ -54,6 +54,27 @@ describe("dotted tool names", () => {
expect(flat.catalog()[0]?.path).toBe("issues.list")
expect(await value(flat, `return await tools.issues.list({})`)).toBe("flat")
})
test("search scopes to a nested namespace subtree", async () => {
const nested = CodeMode.make({
tools: {
slack: {
admin: echo("Admin", "admin"),
"admin.invite": echo("Invite", "invite"),
"admin.users.list": echo("List users", "users"),
"administrator.list": echo("List administrators", "administrators"),
read: echo("Read Slack", "read"),
},
},
})
const result = await value(nested, `return search({ query: "", namespace: "slack.admin" })`)
expect((result as { items: Array<{ path: string }> }).items.map((item) => item.path)).toEqual([
"tools.slack.admin",
"tools.slack.admin.invite",
"tools.slack.admin.users.list",
])
})
})
describe("callable namespaces", () => {
@@ -1,6 +1,6 @@
export async function GET() {
const response = await fetch(
"https://raw.githubusercontent.com/anomalyco/opencode/refs/heads/dev/packages/sdk/openapi.json",
"https://raw.githubusercontent.com/anomalyco/opencode/refs/heads/dev/packages/protocol/openapi.json",
)
const json = await response.json()
return json
+1
View File
@@ -99,6 +99,7 @@
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@standard-schema/spec": "catalog:",
"@parcel/watcher": "2.5.1",
"@silvia-odwyer/photon-node": "0.3.4",
"@openrouter/ai-sdk-provider": "2.9.0",
+1 -1
View File
@@ -1,4 +1,4 @@
export * as AccountV2 from "./account"
export * as Account from "./account"
import { Schema } from "effect"
import type { HttpClientError } from "effect/unstable/http"
+8 -8
View File
@@ -1,14 +1,14 @@
import { sqliteTable, text, integer, primaryKey } from "drizzle-orm/sqlite-core"
import { AccountV2 } from "../account"
import { Account } from "../account"
import { Timestamps } from "../database/schema.sql"
export const AccountTable = sqliteTable("account", {
id: text().$type<AccountV2.ID>().primaryKey(),
id: text().$type<Account.ID>().primaryKey(),
email: text().notNull(),
url: text().notNull(),
access_token: text().$type<AccountV2.AccessToken>().notNull(),
refresh_token: text().$type<AccountV2.RefreshToken>().notNull(),
access_token: text().$type<Account.AccessToken>().notNull(),
refresh_token: text().$type<Account.RefreshToken>().notNull(),
token_expiry: integer(),
...Timestamps,
})
@@ -16,9 +16,9 @@ export const AccountTable = sqliteTable("account", {
export const AccountStateTable = sqliteTable("account_state", {
id: integer().primaryKey(),
active_account_id: text()
.$type<AccountV2.ID>()
.$type<Account.ID>()
.references(() => AccountTable.id, { onDelete: "set null" }),
active_org_id: text().$type<AccountV2.OrgID>(),
active_org_id: text().$type<Account.OrgID>(),
})
// LEGACY
@@ -27,8 +27,8 @@ export const ControlAccountTable = sqliteTable(
{
email: text().notNull(),
url: text().notNull(),
access_token: text().$type<AccountV2.AccessToken>().notNull(),
refresh_token: text().$type<AccountV2.RefreshToken>().notNull(),
access_token: text().$type<Account.AccessToken>().notNull(),
refresh_token: text().$type<Account.RefreshToken>().notNull(),
token_expiry: integer(),
active: integer({ mode: "boolean" })
.notNull()
+12 -12
View File
@@ -1,9 +1,9 @@
export * as AgentV2 from "./agent"
export * as Agent from "./agent"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Array, Context, Effect, Layer, Types } from "effect"
import { Agent } from "@opencode-ai/schema/agent"
import { EventV2 } from "./event"
import { Bus } from "./bus"
import { State } from "./state"
export const ID = Agent.ID
@@ -17,7 +17,7 @@ export const Color = Agent.Color
export const Info = Agent.Info
export type Info = Agent.Info
export const Event = Agent.Event
export { Event } from "@opencode-ai/schema/agent"
export interface Selection {
readonly id: ID
@@ -45,12 +45,12 @@ export interface Interface extends State.Transformable<Draft> {
readonly list: () => Effect.Effect<Info[]>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Agent") {}
export class Service extends Context.Service<Service, Interface>()("@opencode/Agent") {}
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const events = yield* EventV2.Service
const bus = yield* Bus.Service
const state = State.create<Data, Draft>({
name: "agent",
initial: () => ({ agents: new Map() }),
@@ -70,7 +70,7 @@ const layer = Layer.effect(
draft.agents.delete(id)
},
}),
finalize: () => events.publish(Event.Updated, {}).pipe(Effect.asVoid),
finalize: () => bus.publish(Agent.Event.Updated, {}).pipe(Effect.asVoid),
})
const selectable = (agent: Info | undefined) =>
agent && agent.mode !== "subagent" && !agent.hidden ? agent : undefined
@@ -89,17 +89,17 @@ const layer = Layer.effect(
return Service.of({
transform: state.transform,
reload: state.reload,
get: Effect.fn("AgentV2.get")(function* (id) {
get: Effect.fn("Agent.get")(function* (id) {
return state.get().agents.get(id)
}),
default: Effect.fn("AgentV2.default")(function* () {
default: Effect.fn("Agent.default")(function* () {
return selectedDefault()
}),
resolve: Effect.fn("AgentV2.resolve")(function* (id) {
resolve: Effect.fn("Agent.resolve")(function* (id) {
if (id !== undefined) return state.get().agents.get(ID.make(id))
return selectedDefault()
}),
select: Effect.fn("AgentV2.select")(function* (id) {
select: Effect.fn("Agent.select")(function* (id) {
if (id !== undefined) {
const selected = ID.make(id)
return { id: selected, info: state.get().agents.get(selected) }
@@ -107,11 +107,11 @@ const layer = Layer.effect(
const info = selectedDefault()
return { id: info?.id ?? defaultID, info }
}),
list: Effect.fn("AgentV2.list")(function* () {
list: Effect.fn("Agent.list")(function* () {
return Array.fromIterable(state.get().agents.values())
}),
})
}),
)
export const node = makeLocationNode({ service: Service, layer, deps: [EventV2.node] })
export const node = makeLocationNode({ service: Service, layer, deps: [Bus.node] })
+20 -20
View File
@@ -32,8 +32,8 @@ import {
import { Auth, Endpoint, type AnyRoute } from "@opencode-ai/ai/route"
import { ProviderShared } from "@opencode-ai/ai/protocols/shared"
import { Cause, Context, Effect, Layer, Option, Schema, Scope, Stream } from "effect"
import { ModelV2 } from "./model"
import { ProviderV2 } from "./provider"
import type { ID, Info } from "./model"
import { Provider } from "./provider"
import { State } from "./state"
type SDK = any
@@ -42,14 +42,14 @@ type AssistantContent = Extract<LanguageModelV3Message, { role: "assistant" }>["
type ToolResultContent = Extract<AssistantContent[number], { type: "tool-result" }>
export interface SDKEvent {
readonly model: ModelV2.Info
readonly model: Info
readonly package: string
readonly options: Record<string, any>
sdk?: SDK
}
export interface LanguageEvent {
readonly model: ModelV2.Info
readonly model: Info
readonly sdk: SDK
readonly options: Record<string, any>
language?: LanguageModelV3
@@ -103,7 +103,7 @@ function wrapSSE(res: Response, ms: number, ctl: AbortController) {
})
}
function prepareOptions(model: ModelV2.Info, pkg: string) {
function prepareOptions(model: Info, pkg: string) {
const projected = mapBodyToProviderOptions(model, pkg)
const options: Record<string, any> = {
name: model.providerID,
@@ -146,7 +146,7 @@ function prepareOptions(model: ModelV2.Info, pkg: string) {
if (typeof opts.body === "string" && model.body !== undefined) {
const decoded = Option.getOrUndefined(Schema.decodeUnknownOption(Schema.UnknownFromJsonString)(opts.body))
if (Schema.is(Schema.Record(Schema.String, Schema.Json))(decoded)) {
opts.body = JSON.stringify(ProviderV2.mergeOverlay(decoded, model.body))
opts.body = JSON.stringify(Provider.mergeOverlay(decoded, model.body))
}
}
@@ -162,11 +162,11 @@ function prepareOptions(model: ModelV2.Info, pkg: string) {
}
export class InitError extends Schema.TaggedErrorClass<InitError>()("AISDK.InitError", {
providerID: ProviderV2.ID,
providerID: Provider.ID,
cause: Schema.Defect(),
}) {}
function initError(providerID: ProviderV2.ID) {
function initError(providerID: Provider.ID) {
return Effect.catchCause((cause) => Effect.fail(new InitError({ providerID, cause: Cause.squash(cause) })))
}
@@ -181,11 +181,11 @@ export interface Interface {
}
readonly runSDK: (event: SDKEvent) => Effect.Effect<SDKEvent>
readonly runLanguage: (event: LanguageEvent) => Effect.Effect<LanguageEvent>
readonly language: (model: ModelV2.Info) => Effect.Effect<LanguageModelV3, InitError>
readonly model: (model: ModelV2.Info) => Effect.Effect<Model, InitError>
readonly language: (model: Info) => Effect.Effect<LanguageModelV3, InitError>
readonly model: (model: Info) => Effect.Effect<Model, InitError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/AISDK") {}
export class Service extends Context.Service<Service, Interface>()("@opencode/AISDK") {}
export const locationLayer = Layer.effect(
Service,
@@ -260,13 +260,13 @@ export const locationLayer = Layer.effect(
})
const existing = languages.get(key)
if (existing) return existing
if (!ProviderV2.isAISDK(model.package))
if (!Provider.isAISDK(model.package))
return yield* new InitError({
providerID: model.providerID,
cause: new Error(`Unsupported package ${model.package}`),
})
const packageName = ProviderV2.packageName(model.package)
const packageName = Provider.packageName(model.package)
const options = prepareOptions(model, packageName)
const sdkKey = cacheKey({
providerID: model.providerID,
@@ -301,8 +301,8 @@ export const locationLayer = Layer.effect(
export const defaultLayer = locationLayer
function modelFromLanguage(info: ModelV2.Info, language: LanguageModelV3) {
const packageName = ProviderV2.packageName(info.package!)
function modelFromLanguage(info: Info, language: LanguageModelV3) {
const packageName = Provider.packageName(info.package!)
const projected = mapBodyToProviderOptions(info, packageName)
const optionKey = providerOptionKey(packageName, info.providerID)
const providerOptions = (() => {
@@ -352,7 +352,7 @@ function modelFromLanguage(info: ModelV2.Info, language: LanguageModelV3) {
})
}
function gatewayProviderOptions(modelID: ModelV2.ID, settings: Readonly<Record<string, unknown>>) {
function gatewayProviderOptions(modelID: ID, settings: Readonly<Record<string, unknown>>) {
const gateway =
typeof settings.gateway === "object" && settings.gateway !== null && !Array.isArray(settings.gateway)
? Object.fromEntries(Object.entries(settings.gateway))
@@ -369,7 +369,7 @@ function gatewayProviderOptions(modelID: ModelV2.ID, settings: Readonly<Record<s
return { gateway: model }
}
function providerOptionKey(packageName: string | undefined, providerID: ProviderV2.ID) {
function providerOptionKey(packageName: string | undefined, providerID: Provider.ID) {
if (packageName === "@ai-sdk/google") return "google"
if (packageName === "@ai-sdk/google-vertex") return "vertex"
if (packageName === "@ai-sdk/google-vertex/anthropic") return "anthropic"
@@ -395,18 +395,18 @@ function requestSettings(settings: Readonly<Record<string, unknown>> | undefined
return Object.keys(result).length === 0 ? undefined : result
}
function mapBodyToProviderOptions(model: ModelV2.Info, packageName: string) {
function mapBodyToProviderOptions(model: Info, packageName: string) {
const settings = requestSettings(model.settings)
const pro = Schema.is(Schema.Struct({ mode: Schema.Literal("pro") }))(model.body?.reasoning)
const forceReasoning =
["@ai-sdk/openai", "@ai-sdk/azure", "@ai-sdk/amazon-bedrock/mantle"].includes(packageName) &&
(pro || settings?.reasoningEffort !== undefined || settings?.reasoningSummary !== undefined)
const normalized = forceReasoning ? ProviderV2.mergeOverlay(settings, { forceReasoning: true }) : settings
const normalized = forceReasoning ? Provider.mergeOverlay(settings, { forceReasoning: true }) : settings
if (!pro) return { settings: normalized, body: model.body }
const body = { ...model.body }
delete body.reasoning
return {
settings: ProviderV2.mergeOverlay(normalized, { reasoningMode: "pro" }),
settings: Provider.mergeOverlay(normalized, { reasoningMode: "pro" }),
body: Object.keys(body).length === 0 ? undefined : body,
}
}
@@ -1,8 +1,7 @@
export * as EventV2 from "./event"
export * as Bus from "./bus"
import { Cause, Context, DateTime, Effect, Layer, Option, PubSub, Schema, Stream } from "effect"
import { Event } from "@opencode-ai/schema/event"
import type { Data, Definition, Payload } from "@opencode-ai/schema/event"
import type { EventLog } from "@opencode-ai/schema/event-log"
import { and, asc, eq, gt, inArray, lte, sql } from "drizzle-orm"
import { Database } from "./database/database"
@@ -12,18 +11,10 @@ import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { isDeepStrictEqual } from "node:util"
import { Durable } from "@opencode-ai/schema/durable-event-manifest"
export const ID = Event.ID
export type ID = import("@opencode-ai/schema/event").ID
export const Seq = Event.Seq
export type Seq = import("@opencode-ai/schema/event").Seq
export const Version = Event.Version
export type Version = import("@opencode-ai/schema/event").Version
export type { Data, Definition, Payload } from "@opencode-ai/schema/event"
export type Subscriber<D extends Definition = Definition> = (event: Payload<D>) => Effect.Effect<void>
export type Subscriber<D extends Event.Definition = Event.Definition> = (event: Event.Payload<D>) => Effect.Effect<void>
export type Unsubscribe = Effect.Effect<void>
export const latestSequence = Effect.fn("EventV2.latestSequence")(function* (
export const latestSequence = Effect.fn("Bus.latestSequence")(function* (
db: Database.Interface["db"],
aggregateID: string,
) {
@@ -36,7 +27,7 @@ export const latestSequence = Effect.fn("EventV2.latestSequence")(function* (
return row?.seq ?? -1
})
export const reserveSequence = Effect.fn("EventV2.reserveSequence")(function* (
export const reserveSequence = Effect.fn("Bus.reserveSequence")(function* (
db: Database.Interface["db"],
aggregateID: string,
seq: number,
@@ -53,7 +44,7 @@ export const reserveSequence = Effect.fn("EventV2.reserveSequence")(function* (
})
export type SerializedEvent = {
readonly id: ID
readonly id: Event.ID
readonly type: string
readonly created?: DateTime.Utc
readonly seq: number
@@ -62,7 +53,7 @@ export type SerializedEvent = {
}
export class InvalidDurableEventError extends Schema.TaggedErrorClass<InvalidDurableEventError>()(
"EventV2.InvalidDurableEvent",
"Bus.InvalidDurableEvent",
{
type: Schema.String,
message: Schema.String,
@@ -71,11 +62,11 @@ export class InvalidDurableEventError extends Schema.TaggedErrorClass<InvalidDur
const envelope = (aggregateID: string, seq: number, version: number) => ({
aggregateID,
seq: Seq.make(seq),
version: Version.make(version),
seq: Event.Seq.make(seq),
version: Event.Version.make(version),
})
const decodeSerializedEvent = (event: SerializedEvent): Payload => {
const decodeSerializedEvent = (event: SerializedEvent): Event.Payload => {
const definition = Durable.get(event.type)
if (!definition?.durable) {
throw new InvalidDurableEventError({ type: event.type, message: `Unknown durable event type ${event.type}` })
@@ -94,7 +85,7 @@ export const durable = Event.durable
export const ephemeral = Event.ephemeral
export interface PublishOptions {
readonly id?: ID
readonly id?: Event.ID
readonly metadata?: Record<string, unknown>
readonly location?: Location.Ref
/** Local operational projection committed atomically with a new durable event. Not replayed or serialized. */
@@ -102,13 +93,13 @@ export interface PublishOptions {
}
/** Marker/event union emitted by `log`. */
export type LogItem = Payload | EventLog.Synced
export type LogItem = Event.Payload | EventLog.Synced
export const isSynced = (item: LogItem): item is EventLog.Synced => item.type === "log.synced"
export type SubscribePayload<D extends readonly Definition[]> = D[number] extends infer Item
? Item extends Definition
? Payload<Item>
export type SubscribePayload<D extends readonly Event.Definition[]> = D[number] extends infer Item
? Item extends Event.Definition
? Event.Payload<Item>
: never
: never
@@ -117,19 +108,19 @@ export interface Subscribe {
* Volatile live channel: every event published from now on, nothing before or
* across a disconnect. Consumers that need reliability combine it with `log`.
*/
(): Stream.Stream<Payload>
<D extends Definition>(definition: D): Stream.Stream<Payload<D>>
<const D extends readonly [Definition, ...Definition[]]>(definitions: D): Stream.Stream<SubscribePayload<D>>
(): Stream.Stream<Event.Payload>
<D extends Event.Definition>(definition: D): Stream.Stream<Event.Payload<D>>
<const D extends readonly [Event.Definition, ...Event.Definition[]]>(definitions: D): Stream.Stream<SubscribePayload<D>>
}
const isDefinition = (input: Definition | readonly Definition[]): input is Definition => !Array.isArray(input)
const isDefinition = (input: Event.Definition | readonly Event.Definition[]): input is Event.Definition => !Array.isArray(input)
export interface Interface {
readonly publish: <D extends Definition>(
readonly publish: <D extends Event.Definition>(
definition: D,
data: Data<D>,
data: Event.Data<D>,
options?: PublishOptions,
) => Effect.Effect<Payload<D>>
) => Effect.Effect<Event.Payload<D>>
readonly subscribe: Subscribe
/**
* Durable, ordered per-aggregate log read. Forked aggregates may reserve an
@@ -144,10 +135,10 @@ export interface Interface {
readonly follow?: boolean
}) => Stream.Stream<LogItem>
/** Latest committed seq per aggregate. Aggregates without events are absent. */
readonly sequences: (aggregateIDs: ReadonlyArray<string>) => Effect.Effect<ReadonlyMap<string, Seq>>
readonly sequences: (aggregateIDs: ReadonlyArray<string>) => Effect.Effect<ReadonlyMap<string, Event.Seq>>
/** @deprecated Use `subscribe()` and consume the returned stream. */
readonly listen: (listener: Subscriber) => Effect.Effect<Unsubscribe>
readonly project: <D extends Definition>(definition: D, projector: Subscriber<D>) => Effect.Effect<void>
readonly project: <D extends Event.Definition>(definition: D, projector: Subscriber<D>) => Effect.Effect<void>
readonly replay: (
event: SerializedEvent,
options?: { readonly publish?: boolean; readonly ownerID?: string; readonly strictOwner?: boolean },
@@ -160,7 +151,8 @@ export interface Interface {
readonly claim: (aggregateID: string, ownerID: string) => Effect.Effect<void>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Event") {}
export class Service extends Context.Service<Service, Interface>()("@opencode/Bus") {}
export interface LayerOptions {
readonly beforeAggregateRead?: (aggregateID: string) => Effect.Effect<void>
@@ -173,20 +165,20 @@ export const layerWith = (options?: LayerOptions) =>
Service,
Effect.gen(function* () {
const pubsub = {
live: yield* PubSub.unbounded<Payload>(),
live: yield* PubSub.unbounded<Event.Payload>(),
durable: new Map<string, Set<PubSub.PubSub<void>>>(),
typed: new Map<string, PubSub.PubSub<Payload>>(),
typed: new Map<string, PubSub.PubSub<Event.Payload>>(),
}
const projectors = new Map<string, Subscriber[]>()
const listeners = new Array<Subscriber>()
const { db } = yield* Database.Service
const logReadPageSize = options?.logReadPageSize ?? 512
const getOrCreate = (definition: Definition) =>
const getOrCreate = (definition: Event.Definition) =>
Effect.gen(function* () {
const existing = pubsub.typed.get(definition.type)
if (existing) return existing
const created = yield* PubSub.unbounded<Payload>()
const created = yield* PubSub.unbounded<Event.Payload>()
pubsub.typed.set(definition.type, created)
return created
})
@@ -204,8 +196,8 @@ export const layerWith = (options?: LayerOptions) =>
)
function commitDurableEvent(
definition: Definition,
event: Payload,
definition: Event.Definition,
event: Event.Payload,
input?: {
readonly seq: number
readonly aggregateID: string
@@ -318,7 +310,7 @@ export const layerWith = (options?: LayerOptions) =>
const committed = {
...event,
durable: { aggregateID, seq, version: durable.version },
} as Payload
} as Event.Payload
for (const projector of list) {
yield* projector(committed)
}
@@ -369,7 +361,7 @@ export const layerWith = (options?: LayerOptions) =>
})
}
function publishEvent<D extends Definition>(definition: D, event: Payload<D>, commit?: PublishOptions["commit"]) {
function publishEvent<D extends Event.Definition>(definition: D, event: Event.Payload<D>, commit?: PublishOptions["commit"]) {
return Effect.gen(function* () {
if (!definition?.durable && commit)
return yield* Effect.die(
@@ -379,22 +371,22 @@ export const layerWith = (options?: LayerOptions) =>
}),
)
if (definition?.durable) {
const committed = yield* commitDurableEvent(definition, event as Payload, undefined, commit)
const committed = yield* commitDurableEvent(definition, event as Event.Payload, undefined, commit)
if (committed) {
event = {
...event,
durable: envelope(committed.aggregateID, committed.seq, definition.durable.version),
}
yield* notify(event as Payload, true)
yield* notify(event as Event.Payload, true)
return event
}
}
yield* notify(event as Payload, false)
yield* notify(event as Event.Payload, false)
return event
})
}
const observe = (event: Payload, observer: (event: Payload) => Effect.Effect<void>) =>
const observe = (event: Event.Payload, observer: (event: Event.Payload) => Effect.Effect<void>) =>
Effect.suspend(() => observer(event)).pipe(
Effect.catchCauseIf(
(cause) => !Cause.hasInterrupts(cause),
@@ -402,7 +394,7 @@ export const layerWith = (options?: LayerOptions) =>
),
)
function notify(event: Payload, isolateListeners: boolean) {
function notify(event: Event.Payload, isolateListeners: boolean) {
return Effect.gen(function* () {
yield* Effect.forEach(
listeners,
@@ -415,7 +407,7 @@ export const layerWith = (options?: LayerOptions) =>
})
}
function publish<D extends Definition>(definition: D, data: Data<D>, options?: PublishOptions) {
function publish<D extends Event.Definition>(definition: D, data: Event.Data<D>, options?: PublishOptions) {
return Effect.gen(function* () {
const serviceLocation = Option.getOrUndefined(yield* Effect.serviceOption(Location.Service))
const location =
@@ -426,13 +418,13 @@ export const layerWith = (options?: LayerOptions) =>
return yield* publishEvent(
definition,
{
id: options?.id ?? ID.create(),
id: options?.id ?? Event.ID.create(),
created: yield* DateTime.now,
...(options?.metadata ? { metadata: options.metadata } : {}),
type: definition.type,
...(location ? { location } : {}),
data,
} as Payload<D>,
} as Event.Payload<D>,
options?.commit,
)
})
@@ -454,7 +446,7 @@ export const layerWith = (options?: LayerOptions) =>
created: event.created ?? DateTime.makeUnsafe(0),
type: definition.type,
data: Schema.decodeUnknownSync(definition.data)(event.data),
} as Payload
} as Event.Payload
const committed = yield* commitDurableEvent(definition, payload, {
seq: event.seq,
aggregateID: event.aggregateID,
@@ -516,7 +508,7 @@ export const layerWith = (options?: LayerOptions) =>
.pipe(Effect.orDie)
}
const local = <A extends Payload>(stream: Stream.Stream<A>) =>
const local = <A extends Event.Payload>(stream: Stream.Stream<A>) =>
Stream.unwrap(
Effect.serviceOption(Location.Service).pipe(
Effect.map((location) =>
@@ -536,12 +528,12 @@ export const layerWith = (options?: LayerOptions) =>
),
)
function subscribe(): Stream.Stream<Payload>
function subscribe<D extends Definition>(definition: D): Stream.Stream<Payload<D>>
function subscribe<const D extends readonly [Definition, ...Definition[]]>(
function subscribe(): Stream.Stream<Event.Payload>
function subscribe<D extends Event.Definition>(definition: D): Stream.Stream<Event.Payload<D>>
function subscribe<const D extends readonly [Event.Definition, ...Event.Definition[]]>(
definitions: D,
): Stream.Stream<SubscribePayload<D>>
function subscribe(input?: Definition | readonly Definition[]): Stream.Stream<Payload> {
function subscribe(input?: Event.Definition | readonly Event.Definition[]): Stream.Stream<Event.Payload> {
if (input === undefined) return streamLive()
if (isDefinition(input)) {
return local(Stream.unwrap(getOrCreate(input).pipe(Effect.map((pubsub) => Stream.fromPubSub(pubsub)))))
@@ -550,7 +542,7 @@ export const layerWith = (options?: LayerOptions) =>
return streamLive().pipe(Stream.filter((event) => types.has(event.type)))
}
const streamLive = (): Stream.Stream<Payload> => local(Stream.fromPubSub(pubsub.live))
const streamLive = (): Stream.Stream<Event.Payload> => local(Stream.fromPubSub(pubsub.live))
const readAfter = (
aggregateID: string,
@@ -624,7 +616,7 @@ export const layerWith = (options?: LayerOptions) =>
Stream.unwrap(
Effect.gen(function* () {
let sequence = input.after ?? -1
const readThrough = (through: number): Stream.Stream<Payload> =>
const readThrough = (through: number): Stream.Stream<Event.Payload> =>
Stream.paginate(sequence, (cursor) =>
readAfter(input.aggregateID, cursor, { through, limit: logReadPageSize }).pipe(
Effect.tap((page) =>
@@ -648,7 +640,7 @@ export const layerWith = (options?: LayerOptions) =>
const marker: EventLog.Synced = {
type: "log.synced",
aggregateID: input.aggregateID,
...(target >= 0 ? { seq: Seq.make(target) } : {}),
...(target >= 0 ? { seq: Event.Seq.make(target) } : {}),
}
const replay: Stream.Stream<LogItem> = readThrough(target).pipe(
Stream.map((event): LogItem => event),
@@ -665,7 +657,7 @@ export const layerWith = (options?: LayerOptions) =>
}),
)
const sequences = (aggregateIDs: ReadonlyArray<string>): Effect.Effect<ReadonlyMap<string, Seq>> => {
const sequences = (aggregateIDs: ReadonlyArray<string>): Effect.Effect<ReadonlyMap<string, Event.Seq>> => {
if (aggregateIDs.length === 0) return Effect.succeed(new Map())
return db
.select({ aggregateID: EventSequenceTable.aggregate_id, seq: EventSequenceTable.seq })
@@ -674,7 +666,7 @@ export const layerWith = (options?: LayerOptions) =>
.all()
.pipe(
Effect.orDie,
Effect.map((rows) => new Map(rows.map((row) => [row.aggregateID, Seq.make(row.seq)]))),
Effect.map((rows) => new Map(rows.map((row) => [row.aggregateID, Event.Seq.make(row.seq)]))),
)
}
@@ -687,11 +679,11 @@ export const layerWith = (options?: LayerOptions) =>
})
})
const project = <D extends Definition>(definition: D, projector: Subscriber<D>): Effect.Effect<void> =>
const project = <D extends Event.Definition>(definition: D, projector: Subscriber<D>): Effect.Effect<void> =>
Effect.sync(() => {
const key = definition.durable ? versionedType(definition.type, definition.durable.version) : definition.type
const list = projectors.get(key) ?? []
list.push((event) => projector(event as Payload<D>))
list.push((event) => projector(event as Event.Payload<D>))
projectors.set(key, list)
})
+51 -51
View File
@@ -3,82 +3,82 @@ export * as Catalog from "./catalog"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Array, Context, Effect, Layer, Order, pipe } from "effect"
import { Catalog } from "@opencode-ai/schema/catalog"
import { ModelV2 } from "./model"
import { ProviderV2 } from "./provider"
import { EventV2 } from "./event"
import { Model } from "./model"
import { Provider } from "./provider"
import { Bus } from "./bus"
import { State } from "./state"
import { Integration } from "./integration"
export type ProviderRecord = {
provider: ProviderV2.MutableInfo
models: Map<ModelV2.ID, ModelV2.MutableInfo>
provider: Provider.MutableInfo
models: Map<Model.ID, Model.MutableInfo>
}
export type DefaultModel = { providerID: ProviderV2.ID; modelID: ModelV2.ID }
export type DefaultModel = { providerID: Provider.ID; modelID: Model.ID }
export const Event = Catalog.Event
export { Event } from "@opencode-ai/schema/catalog"
type Data = {
providers: Map<ProviderV2.ID, ProviderRecord>
providers: Map<Provider.ID, ProviderRecord>
defaultModel?: DefaultModel
}
export type Draft = {
provider: {
list: () => readonly ProviderRecord[]
get: (providerID: ProviderV2.ID) => ProviderRecord | undefined
update: (providerID: ProviderV2.ID, fn: (provider: ProviderV2.MutableInfo) => void) => void
remove: (providerID: ProviderV2.ID) => void
get: (providerID: Provider.ID) => ProviderRecord | undefined
update: (providerID: Provider.ID, fn: (provider: Provider.MutableInfo) => void) => void
remove: (providerID: Provider.ID) => void
}
model: {
get: (providerID: ProviderV2.ID, modelID: ModelV2.ID) => ModelV2.Info | undefined
update: (providerID: ProviderV2.ID, modelID: ModelV2.ID, fn: (model: ModelV2.MutableInfo) => void) => void
remove: (providerID: ProviderV2.ID, modelID: ModelV2.ID) => void
get: (providerID: Provider.ID, modelID: Model.ID) => Model.Info | undefined
update: (providerID: Provider.ID, modelID: Model.ID, fn: (model: Model.MutableInfo) => void) => void
remove: (providerID: Provider.ID, modelID: Model.ID) => void
default: {
get: () => DefaultModel | undefined
set: (providerID: ProviderV2.ID, modelID: ModelV2.ID) => void
set: (providerID: Provider.ID, modelID: Model.ID) => void
}
}
}
export interface Interface extends State.Transformable<Draft> {
readonly provider: {
readonly get: (providerID: ProviderV2.ID) => Effect.Effect<ProviderV2.Info | undefined>
readonly all: () => Effect.Effect<ProviderV2.Info[]>
readonly available: () => Effect.Effect<ProviderV2.Info[]>
readonly get: (providerID: Provider.ID) => Effect.Effect<Provider.Info | undefined>
readonly all: () => Effect.Effect<Provider.Info[]>
readonly available: () => Effect.Effect<Provider.Info[]>
}
readonly model: {
readonly get: (providerID: ProviderV2.ID, modelID: ModelV2.ID) => Effect.Effect<ModelV2.Info | undefined>
readonly all: () => Effect.Effect<ModelV2.Info[]>
readonly available: () => Effect.Effect<ModelV2.Info[]>
readonly default: () => Effect.Effect<ModelV2.Info | undefined>
readonly small: (providerID: ProviderV2.ID) => Effect.Effect<ModelV2.Info | undefined>
readonly get: (providerID: Provider.ID, modelID: Model.ID) => Effect.Effect<Model.Info | undefined>
readonly all: () => Effect.Effect<Model.Info[]>
readonly available: () => Effect.Effect<Model.Info[]>
readonly default: () => Effect.Effect<Model.Info | undefined>
readonly small: (providerID: Provider.ID) => Effect.Effect<Model.Info | undefined>
}
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Catalog") {}
export class Service extends Context.Service<Service, Interface>()("@opencode/Catalog") {}
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const events = yield* EventV2.Service
const bus = yield* Bus.Service
const integrations = yield* Integration.Service
const available = (provider: ProviderV2.Info, integration: Integration.Info | undefined) => {
const available = (provider: Provider.Info, integration: Integration.Info | undefined) => {
if (provider.disabled) return false
if (typeof provider.settings?.apiKey === "string") return true
if (integration?.connections.length) return true
return provider.integrationID === undefined && !integration
}
const projectModel = (model: ModelV2.Info, provider: ProviderV2.Info) => {
const projectModel = (model: Model.Info, provider: Provider.Info) => {
return {
...model,
package: model.package ?? provider.package,
settings: ProviderV2.mergeOverlay(provider.settings, model.settings),
headers: ProviderV2.mergeHeaders(provider.headers, model.headers),
body: ProviderV2.mergeOverlay(provider.body, model.body),
} satisfies ModelV2.Info
settings: Provider.mergeOverlay(provider.settings, model.settings),
headers: Provider.mergeHeaders(provider.headers, model.headers),
body: Provider.mergeOverlay(provider.body, model.body),
} satisfies Model.Info
}
const state = State.create<Data, Draft>({
@@ -93,8 +93,8 @@ const layer = Layer.effect(
let current = draft.providers.get(providerID)
if (!current) {
current = {
provider: ProviderV2.Info.empty(providerID) as ProviderV2.MutableInfo,
models: new Map<ModelV2.ID, ModelV2.MutableInfo>(),
provider: Provider.Info.empty(providerID) as Provider.MutableInfo,
models: new Map<Model.ID, Model.MutableInfo>(),
}
draft.providers.set(providerID, current)
}
@@ -110,13 +110,13 @@ const layer = Layer.effect(
let record = draft.providers.get(providerID)
if (!record) {
record = {
provider: ProviderV2.Info.empty(providerID) as ProviderV2.MutableInfo,
models: new Map<ModelV2.ID, ModelV2.MutableInfo>(),
provider: Provider.Info.empty(providerID) as Provider.MutableInfo,
models: new Map<Model.ID, Model.MutableInfo>(),
}
draft.providers.set(providerID, record)
}
const model =
record.models.get(modelID) ?? (ModelV2.Info.default(providerID, modelID) as ModelV2.MutableInfo)
record.models.get(modelID) ?? (Model.Info.default(providerID, modelID) as Model.MutableInfo)
if (!record.models.has(modelID)) record.models.set(modelID, model)
fn(model)
model.id = modelID
@@ -135,8 +135,8 @@ const layer = Layer.effect(
}
return result
},
finalize: Effect.fn("CatalogV2.finalize")(function* (catalog) {
yield* events.publish(Event.Updated, {})
finalize: Effect.fn("Catalog.finalize")(function* (catalog) {
yield* bus.publish(Catalog.Event.Updated, {})
}),
})
const result: Interface = {
@@ -144,15 +144,15 @@ const layer = Layer.effect(
reload: state.reload,
provider: {
get: Effect.fn("CatalogV2.provider.get")(function* (providerID) {
get: Effect.fn("Catalog.provider.get")(function* (providerID) {
return state.get().providers.get(providerID)?.provider
}),
all: Effect.fn("CatalogV2.provider.all")(function* () {
all: Effect.fn("Catalog.provider.all")(function* () {
return Array.fromIterable(state.get().providers.values()).map((record) => record.provider)
}),
available: Effect.fn("CatalogV2.provider.available")(function* () {
available: Effect.fn("Catalog.provider.available")(function* () {
const active = new Map((yield* integrations.list()).map((integration) => [integration.id, integration]))
return (yield* result.provider.all()).filter((provider) =>
available(provider, active.get(provider.integrationID ?? Integration.ID.make(provider.id))),
@@ -161,14 +161,14 @@ const layer = Layer.effect(
},
model: {
get: Effect.fn("CatalogV2.model.get")(function* (providerID, modelID) {
get: Effect.fn("Catalog.model.get")(function* (providerID, modelID) {
const record = state.get().providers.get(providerID)
if (!record) return
const model = record.models.get(modelID)
return model && projectModel(model, record.provider)
}),
all: Effect.fn("CatalogV2.model.all")(function* () {
all: Effect.fn("Catalog.model.all")(function* () {
return pipe(
Array.fromIterable(state.get().providers.values()),
Array.flatMap((record) => {
@@ -178,9 +178,9 @@ const layer = Layer.effect(
)
}),
available: Effect.fn("CatalogV2.model.available")(function* () {
available: Effect.fn("Catalog.model.available")(function* () {
const providers = new Set((yield* result.provider.available()).map((provider) => provider.id))
const models: ModelV2.Info[] = []
const models: Model.Info[] = []
for (const record of state.get().providers.values()) {
if (!providers.has(record.provider.id)) continue
for (const model of record.models.values()) {
@@ -194,7 +194,7 @@ const layer = Layer.effect(
)
}),
default: Effect.fn("CatalogV2.model.default")(function* () {
default: Effect.fn("Catalog.model.default")(function* () {
const defaultModel = state.get().defaultModel
if (defaultModel) {
const provider = yield* result.provider.get(defaultModel.providerID)
@@ -207,18 +207,18 @@ const layer = Layer.effect(
return (yield* result.model.available())[0]
}),
small: Effect.fn("CatalogV2.model.small")(function* (providerID) {
small: Effect.fn("Catalog.model.small")(function* (providerID) {
const record = state.get().providers.get(providerID)
if (!record) return
const provider = record.provider
// TODO: Remove these provider-specific assumptions once model syncing reliably reports available deployments.
if (providerID === ProviderV2.ID.azure || providerID === ProviderV2.ID.make("azure-cognitive-services")) {
if (providerID === Provider.ID.azure || providerID === Provider.ID.make("azure-cognitive-services")) {
return
}
if (providerID === ProviderV2.ID.opencode) {
const gpt5Nano = record.models.get(ModelV2.ID.make("gpt-5-nano"))
if (providerID === Provider.ID.opencode) {
const gpt5Nano = record.models.get(Model.ID.make("gpt-5-nano"))
if (gpt5Nano?.enabled && gpt5Nano.status === "active") return projectModel(gpt5Nano, provider)
}
@@ -268,4 +268,4 @@ const layer = Layer.effect(
const SMALL_MODEL_RE = /\b(nano|flash|lite|mini|haiku|small|fast)\b/
export const node = makeLocationNode({ service: Service, layer, deps: [EventV2.node, Integration.node] })
export const node = makeLocationNode({ service: Service, layer, deps: [Bus.node, Integration.node] })
-78
View File
@@ -1,78 +0,0 @@
export * as CodeMode from "./codemode"
import { Context, Effect, Layer, Scope } from "effect"
import { CodeModeCatalog } from "./codemode/catalog"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { PermissionV2 } from "./permission"
import { ExecuteTool } from "./tool/execute"
import type { Any, Registration } from "./tool/tool"
import { Wildcard } from "./util/wildcard"
export interface Materialization {
readonly tool?: Any
readonly catalog?: ReadonlyArray<CodeModeCatalog.Entry>
}
export interface Interface {
readonly register: (
registrations: ReadonlyArray<Registration & { readonly key: string }>,
) => Effect.Effect<void, never, Scope.Scope>
readonly materialize: (permissions?: PermissionV2.Ruleset) => Effect.Effect<Materialization>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/CodeMode") {}
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const local = new Map<string, Array<{ readonly token: object; readonly registration: ExecuteTool.Registration }>>()
return Service.of({
register: Effect.fn("CodeMode.register")(function* (registrations) {
if (registrations.length === 0) return
yield* Effect.uninterruptible(
Effect.gen(function* () {
const token = {}
for (const registration of registrations)
local.set(registration.key, [
...(local.get(registration.key) ?? []),
{
token,
registration,
},
])
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
for (const registration of registrations) {
const remaining = local.get(registration.key)?.filter((item) => item.token !== token) ?? []
if (remaining.length > 0) local.set(registration.key, remaining)
else local.delete(registration.key)
}
}),
)
}),
)
}),
materialize: Effect.fn("CodeMode.materialize")(function* (permissions) {
const registrations = new Map<string, ExecuteTool.Registration>()
const rules = permissions ?? []
for (const [name, entries] of local) {
const registration = entries.at(-1)?.registration
if (!registration) continue
const rule = rules.findLast((rule) => Wildcard.match(registration.permission, rule.action))
if (rule?.resource === "*" && rule.effect === "deny") continue
registrations.set(name, registration)
}
if (registrations.size === 0) return {}
const executeRule = rules.findLast((rule) => Wildcard.match("execute", rule.action))
if (executeRule?.resource === "*" && executeRule.effect === "deny") return {}
return {
tool: ExecuteTool.create(registrations),
catalog: ExecuteTool.catalog(registrations),
}
}),
})
}),
)
export const node = makeLocationNode({ service: Service, layer, deps: [] })
+16 -18
View File
@@ -6,22 +6,19 @@ import { Instructions } from "../instructions/index"
import { CodeModeCatalog } from "./catalog"
// prettier-ignore
const prompt = (hasMoreTools: boolean) => `Run JavaScript to orchestrate tool calls and compose their results. Imports, direct filesystem access, and timers are unavailable. Do not use \`fetch\`; all external access goes through \`tools\`.
Prefer an explicit \`return\`; if omitted, the final top-level expression becomes the result. Await tool calls before returning; any calls still pending when execution ends are interrupted. Run independent calls concurrently with \`Promise.all\`.
Do not infer or normalize tool names; use only the exact signatures shown below${hasMoreTools ? " or returned by `search`" : ""}, preserving bracket notation such as \`tools.<namespace>["tool-name"](input)\`.${hasMoreTools ? `
const prompt = (hasMoreTools: boolean) => `The Code Mode tool catalog below is ${hasMoreTools ? "partial" : "complete"}.${hasMoreTools ? `
## Search
Only some tool signatures are shown. Use \`search\` to discover exact paths and signatures for additional tools:
Use \`search\` to discover exact paths and signatures for additional tools:
- ${searchSignature}` : ""}
## Available tools`
export function render(catalog: CodeModeCatalog.Summary) {
if (catalog.total === 0) return "No tools are currently available."
if (catalog.total === 0)
return "No Code Mode tools are currently available. Later Code Mode catalog updates may add or remove tools. Do not call `execute` unless there is at least one available Code Mode tool."
const tools = catalog.namespaces.flatMap((namespace) => {
const count = namespace.count === 1 ? "1 tool" : `${namespace.count} tools`
@@ -40,12 +37,13 @@ ${tools.join("\n")}`
}
export function update(previous: CodeModeCatalog.Summary, current: CodeModeCatalog.Summary) {
const full = `The Code Mode tool catalog has changed. This catalog supersedes the previous Code Mode tool catalog.
const replacement = `The Code Mode tool catalog has changed. This catalog supersedes the previous Code Mode tool catalog.
${render(current)}`
if (current.total === 0) return replacement
const previousComplete = previous.shown === previous.total
const currentComplete = current.shown === current.total
if (previousComplete !== currentComplete) return full
if (previousComplete !== currentComplete) return replacement
const diff = Instructions.diffByKey(
previous.namespaces.flatMap((namespace) => namespace.entries),
@@ -56,7 +54,7 @@ ${render(current)}`
const entriesChanged = diff.added.length > 0 || diff.removed.length > 0 || diff.changed.length > 0
if (!currentComplete) {
if (entriesChanged) return full
if (entriesChanged) return replacement
const namespaces = Instructions.diffByKey(
previous.namespaces,
current.namespaces,
@@ -64,7 +62,7 @@ ${render(current)}`
(before, after) => before.count !== after.count,
)
const changed = namespaces.added.length > 0 || namespaces.removed.length > 0 || namespaces.changed.length > 0
if (!changed) return full
if (!changed) return replacement
const parts = ["The Code Mode tool catalog has changed."]
if (namespaces.added.length > 0) {
@@ -89,11 +87,11 @@ ${render(current)}`
)
}
const delta = parts.join("\n\n")
if (delta.length < full.length) return delta
return full
if (delta.length < replacement.length) return delta
return replacement
}
if (!entriesChanged) return full
if (!entriesChanged) return replacement
const parts = ["The Code Mode tool catalog has changed."]
if (diff.added.length > 0) {
parts.push(
@@ -119,19 +117,19 @@ ${render(current)}`
)
}
const delta = parts.join("\n\n")
if (delta.length < full.length) return delta
return full
if (delta.length < replacement.length) return delta
return replacement
}
const key = Instructions.Key.make("core/codemode")
const codec = Schema.toCodecJson(CodeModeCatalog.Summary)
export const make = (entries?: ReadonlyArray<CodeModeCatalog.Entry>): Instructions.Instructions => {
const catalog = CodeModeCatalog.summarize(entries ?? [])
const catalog = entries === undefined ? Instructions.removed : CodeModeCatalog.summarize(entries)
return Instructions.make({
key,
codec,
read: Effect.succeed(catalog.total === 0 ? Instructions.removed : catalog),
read: Effect.succeed(catalog),
render: {
initial: render,
changed: update,
@@ -1,10 +1,9 @@
export * as ExecuteTool from "./execute"
export type { Registration } from "./tool"
export * as CodeModeTool from "./tool"
import { CodeMode, Tool, toolError } from "@opencode-ai/codemode"
import type { ToolContent } from "@opencode-ai/ai"
import { Effect, Ref, Schema } from "effect"
import { execute, make, toLLMDefinition, type Content, type Metadata, type Registration } from "./tool"
import type { Content, Context, Error, Info, Metadata, Result } from "@opencode-ai/schema/tool"
import { Effect, Ref, Schema, Semaphore } from "effect"
import { definition } from "../tool/runtime"
const ExecuteFile = Schema.Struct({
data: Schema.String,
@@ -34,14 +33,25 @@ type CollectedFiles = {
// Invariant model-facing guidance; the changing tool catalog is delivered through Instructions.
const description = [
"Run JavaScript in a confined Code Mode runtime through { code }.",
"Call Code Mode tools through `tools` using the exact paths and signatures from the instructions.",
"Use `search({ query })` to discover exact signatures when needed.",
"Await important calls and use `Promise.all` for independent calls.",
"Run JavaScript to orchestrate tool calls and compose their results through `{ code }` in a confined Code Mode runtime.",
"Imports, direct filesystem access, and timers are unavailable. Do not use `fetch`; all external access goes through `tools`.",
"Within `{ code }`, the only callable tools are those explicitly listed in the Code Mode catalog instructions or returned by `search`. Inside `{ code }`, ignore tools shown outside the Code Mode catalog. They are not available in the Code Mode runtime.",
'Call tools through `tools` using only exact paths and signatures from the catalog. Do not infer or normalize tool names; preserve bracket notation such as `tools.<namespace>["tool-name"](input)`.',
"Prefer an explicit `return`; if omitted, the final top-level expression becomes the result.",
"Await every call whose completion matters; pending calls are interrupted when execution ends. Run independent calls concurrently with `Promise.all`.",
].join("\n")
export const create = (registrations: ReadonlyMap<string, Registration>) => {
return make({
export const create = (
registrations: ReadonlyMap<string, Info>,
executeTool: (
name: string,
tool: Info,
input: unknown,
context: Context,
) => Effect.Effect<Result, Error>,
) => {
return ({
name: "execute",
description,
input: CodeMode.Input,
output: ExecuteOutput,
@@ -50,50 +60,51 @@ export const create = (registrations: ReadonlyMap<string, Registration>) => {
const callIndex = yield* Ref.make(0)
const files = yield* Ref.make<Array<CollectedFiles>>([])
const calls = yield* Ref.make<Array<ExecuteCall>>([])
// TODO: Publish live call-list updates once V2 has a generic tool progress API.
const finalCalls = Ref.get(calls).pipe(
Effect.map((items) =>
items.map((call) => (call.status === "running" ? { ...call, status: "error" as const } : call)),
),
)
const lock = Semaphore.makeUnsafe(1)
const updateCalls = (update: (items: Array<ExecuteCall>) => Array<ExecuteCall>) =>
lock.withPermit(
Ref.updateAndGet(calls, update).pipe(Effect.flatMap((toolCalls) => context.progress({ toolCalls }))),
)
const result = yield* runtime(
registrations,
(name, registration, input) =>
(name, tool, input) =>
Effect.gen(function* () {
const index = yield* Ref.getAndUpdate(callIndex, (index) => index + 1)
const executed = yield* execute(registration.tool, input, {
sessionID: context.sessionID,
agent: context.agent,
messageID: context.messageID,
callID: context.callID,
progress: context.progress,
}).pipe(Effect.mapError((failure) => toolError(failure.message, failure)))
const outputFileParts = outputFiles(executed.content)
const executed = yield* executeTool(name, tool, input, context).pipe(
Effect.mapError((failure) => toolError(failure.message, failure)),
)
const content =
typeof executed.content === "string"
? [{ type: "text" as const, text: executed.content }]
: executed.content ?? []
const outputFileParts = outputFiles(content)
if (outputFileParts.length > 0)
yield* Ref.update(files, (items) => [...items, { index, files: outputFileParts }])
return executed.output
}),
{
onToolCallStart: ({ index, name, input }) =>
Effect.gen(function* () {
const shown = displayInput(input)
yield* Ref.update(calls, (items) => {
const next = [...items]
next[index] = { tool: name, status: "running", ...(shown ? { input: shown } : {}) }
return next
})
}),
onToolCallEnd: ({ index, outcome }) =>
Ref.update(calls, (items) => {
const current = items[index]
if (!current) return items
onToolCallStart: ({ index, name, input }) => {
const shown = displayInput(input)
return updateCalls((items) => {
const next = [...items]
next[index] = { ...current, status: outcome === "success" ? "completed" : "error" }
next[index] = { tool: name, status: "running", ...(shown ? { input: shown } : {}) }
return next
}),
})
},
onToolCallEnd: ({ index, name, input, outcome }) => {
const shown = displayInput(input)
return updateCalls((items) => {
const next = [...items]
next[index] = {
...(items[index] ?? { tool: name, ...(shown ? { input: shown } : {}) }),
status: outcome === "success" ? "completed" : "error",
}
return next
})
},
},
).execute(code)
const toolCalls = yield* finalCalls
const toolCalls = yield* Ref.get(calls)
const collected = (yield* Ref.get(files))
.toSorted((left, right) => left.index - right.index)
.flatMap((item) => item.files)
@@ -104,11 +115,11 @@ export const create = (registrations: ReadonlyMap<string, Registration>) => {
files: collected,
...(result.ok ? {} : { error: true }),
}
const content: [Content, ...Content[]] = [{ type: "text", text: value.output }]
const content: Array<Content> = [{ type: "text", text: value.output }]
content.push(
...value.files.map((file) => ({
type: "file" as const,
data: file.data,
uri: `data:${file.mime};base64,${file.data}`,
mime: file.mime,
...(file.name === undefined ? {} : { name: file.name }),
})),
@@ -123,23 +134,26 @@ export const create = (registrations: ReadonlyMap<string, Registration>) => {
metadata,
}
}),
})
}) satisfies Info
}
export const catalog = (registrations: ReadonlyMap<string, Registration>) => {
export const catalog = (registrations: ReadonlyMap<string, Info>) => {
return runtime(registrations, () => Effect.fail(toolError("Execute context is unavailable"))).catalog()
}
function runtime(
registrations: ReadonlyMap<string, Registration>,
executeTool: (name: string, registration: Registration, input: unknown) => Effect.Effect<unknown, unknown>,
registrations: ReadonlyMap<string, Info>,
executeTool: (name: string, tool: Info, input: unknown) => Effect.Effect<unknown, unknown>,
hooks?: CodeMode.ToolCallHooks,
) {
const tools: Record<string, Tool.Tool<never>> = {}
for (const [name, registration] of registrations) {
const child = toLLMDefinition(name, registration.tool)
const child = definition(registration)
const normalized = registration.name.replace(/[^a-zA-Z0-9_-]/g, "_")
const path =
registration.namespace === undefined ? registration.name : `${registration.namespace}.${registration.name}`
registration.options?.namespace === undefined
? normalized
: `${registration.options.namespace}.${normalized}`
tools[path] = Tool.make({
description: child.description,
input: child.inputSchema,
@@ -177,7 +191,7 @@ function formatValue(value: CodeMode.DataValue) {
return JSON.stringify(value, null, 2) ?? String(value)
}
function outputFiles(content: ReadonlyArray<ToolContent>): Array<typeof ExecuteFile.Type> {
function outputFiles(content: ReadonlyArray<Content>): Array<typeof ExecuteFile.Type> {
return content.flatMap((part) => {
if (part.type !== "file") return []
const prefix = `data:${part.mime};base64,`
+10 -10
View File
@@ -1,11 +1,11 @@
export * as CommandV2 from "./command"
export * as Command from "./command"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Context, Effect, Layer, Schema, Types } from "effect"
import { Command } from "@opencode-ai/schema/command"
import { State } from "./state"
import { MCP } from "./mcp/index"
import { EventV2 } from "./event"
import { Bus } from "./bus"
import { AppProcess } from "@opencode-ai/util/process"
import { ChildProcess } from "effect/unstable/process"
import { Config } from "./config"
@@ -14,7 +14,7 @@ import { ShellSelect } from "./shell/select"
export const Info = Command.Info
export type Info = Command.Info
export const Event = Command.Event
export { Event } from "@opencode-ai/schema/command"
export type Evaluation = {
readonly text: string
@@ -50,13 +50,13 @@ export interface Interface extends State.Transformable<Draft> {
}) => Effect.Effect<Evaluation, NotFoundError | EvaluationError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Command") {}
export class Service extends Context.Service<Service, Interface>()("@opencode/Command") {}
export const layer = (options?: ShellSelect.Options) => Layer.effect(
Service,
Effect.gen(function* () {
const mcp = yield* MCP.Service
const events = yield* EventV2.Service
const bus = yield* Bus.Service
const processes = yield* AppProcess.Service
const config = yield* Config.Service
const location = yield* Location.Service
@@ -76,7 +76,7 @@ export const layer = (options?: ShellSelect.Options) => Layer.effect(
draft.commands.delete(name)
},
}),
finalize: () => events.publish(Event.Updated, {}).pipe(Effect.asVoid),
finalize: () => bus.publish(Command.Event.Updated, {}).pipe(Effect.asVoid),
})
const staticCommand = (name: string) => state.get().commands.get(name) as Info | undefined
const mcpCommands = Effect.fnUntraced(function* () {
@@ -92,12 +92,12 @@ export const layer = (options?: ShellSelect.Options) => Layer.effect(
return Service.of({
reload: state.reload,
transform: state.transform,
get: Effect.fn("CommandV2.get")(function* (name) {
get: Effect.fn("Command.get")(function* (name) {
const command = staticCommand(name)
if (command) return command
return (yield* mcpCommands()).find((command) => command.name === name)
}),
list: Effect.fn("CommandV2.list")(function* () {
list: Effect.fn("Command.list")(function* () {
const commands = Array.from(state.get().commands.values()) as Info[]
const names = new Set(commands.map((command) => command.name))
return [
@@ -105,7 +105,7 @@ export const layer = (options?: ShellSelect.Options) => Layer.effect(
...(yield* mcpCommands()).filter((command) => !names.has(command.name)),
]
}),
evaluate: Effect.fn("CommandV2.evaluate")(function* (input) {
evaluate: Effect.fn("Command.evaluate")(function* (input) {
const command = staticCommand(input.name)
if (command) return yield* evaluateTemplate(input.name, command.template, input.arguments ?? "", {
config,
@@ -247,7 +247,7 @@ export function configured(options?: ShellSelect.Options) {
return makeLocationNode({
service: Service,
layer: layer(options),
deps: [MCP.node, EventV2.node, AppProcess.node, Config.node, Location.node],
deps: [MCP.node, Bus.node, AppProcess.node, Config.node, Location.node],
})
}
+11 -7
View File
@@ -9,7 +9,7 @@ import { Permission } from "@opencode-ai/schema/permission"
import { Config as ConfigSchema } from "@opencode-ai/schema/config"
import { Integration } from "@opencode-ai/schema/integration"
import { Credential } from "./credential"
import { EventV2 } from "./event"
import { Bus } from "./bus"
import { Watcher } from "./filesystem/watcher"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Global } from "@opencode-ai/util/global"
@@ -27,6 +27,7 @@ import { ConfigModel } from "./config/model"
import { ConfigPlugin } from "./config/plugin"
import { ConfigProvider } from "./config/provider"
import { ConfigReference } from "./config/reference"
import { ConfigWebSearch } from "./config/websearch"
import { ConfigToolOutput } from "./config/tool-output"
import { ConfigVariable } from "./config/variable"
import { ConfigWatcher } from "./config/watcher"
@@ -108,6 +109,9 @@ export class Info extends Schema.Class<Info>("Config.Info")({
references: ConfigReference.Info.pipe(Schema.optional).annotate({
description: "Named local directories or Git repositories available as external context",
}),
websearch: ConfigWebSearch.Info.pipe(Schema.optional).annotate({
description: "Web search provider selection",
}),
plugins: ConfigPlugin.Plugins.pipe(Schema.optional).annotate({
description: "Ordered plugin enablement directives and external package declarations",
}),
@@ -164,7 +168,7 @@ export const Options = Schema.Struct({
})
export type Options = typeof Options.Type
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Config") {}
export class Service extends Context.Service<Service, Interface>()("@opencode/Config") {}
export const layer = (options?: Options) => Layer.effect(
Service,
@@ -173,7 +177,7 @@ export const layer = (options?: Options) => Layer.effect(
const global = yield* Global.Service
const location = yield* Location.Service
const watcher = yield* Watcher.Service
const events = yield* EventV2.Service
const bus = yield* Bus.Service
const credentials = yield* Credential.Service
const wellknown = yield* WellKnown.Service
const names = ["opencode.json", "opencode.jsonc"]
@@ -371,7 +375,7 @@ export const layer = (options?: Options) => Layer.effect(
if (isDeepStrictEqual(configs, next)) return
configs = next
yield* reconcile(next)
yield* events.publish(ConfigSchema.Event.Updated, {})
yield* bus.publish(ConfigSchema.Event.Updated, {})
}),
),
)
@@ -385,7 +389,7 @@ export const layer = (options?: Options) => Layer.effect(
),
Effect.forkScoped({ startImmediately: true }),
)
yield* events.subscribe(Integration.Event.ConnectionUpdated).pipe(
yield* bus.subscribe(Integration.Event.ConnectionUpdated).pipe(
Stream.filterEffect((event) =>
wellknown.entries().pipe(
Effect.map((entries) => entries.some((entry) => entry.integrationID === event.data.integrationID)),
@@ -397,7 +401,7 @@ export const layer = (options?: Options) => Layer.effect(
),
Effect.forkScoped({ startImmediately: true }),
)
yield* events.subscribe(WellKnown.Event.Updated).pipe(
yield* bus.subscribe(WellKnown.Event.Updated).pipe(
Stream.runForEach(() =>
reload().pipe(Effect.catchCause((cause) => Effect.logError("failed to reload wellknown sources", { cause }))),
),
@@ -434,7 +438,7 @@ export function configured(options?: Options) {
return makeLocationNode({
service: Service,
layer: layer(options),
deps: [Watcher.node, EventV2.node, FSUtil.node, Global.node, Location.node, Credential.node, WellKnown.node],
deps: [Watcher.node, Bus.node, FSUtil.node, Global.node, Location.node, Credential.node, WellKnown.node],
})
}
+1 -1
View File
@@ -8,7 +8,7 @@ import { PositiveInt } from "../schema"
export const Color = Schema.String.check(Schema.isPattern(/^#[0-9a-fA-F]{6}$/))
export class Info extends Schema.Class<Info>("ConfigV2.Agent")({
export class Info extends Schema.Class<Info>("Config.Agent")({
model: ConfigModel.Selection.pipe(Schema.optional),
request: ConfigProvider.Request.pipe(Schema.optional),
system: Schema.String.pipe(Schema.optional),
+2 -2
View File
@@ -3,13 +3,13 @@ export * as ConfigAttachments from "./attachments"
import { Schema } from "effect"
import { PositiveInt } from "../schema"
export class Image extends Schema.Class<Image>("ConfigV2.Attachments.Image")({
export class Image extends Schema.Class<Image>("Config.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<Info>("ConfigV2.Attachments")({
export class Info extends Schema.Class<Info>("Config.Attachments")({
image: Image.pipe(Schema.optional),
}) {}
+1 -1
View File
@@ -3,7 +3,7 @@ export * as ConfigCommand from "./command"
import { Schema } from "effect"
import { ConfigModel } from "./model"
export class Info extends Schema.Class<Info>("ConfigV2.Command")({
export class Info extends Schema.Class<Info>("Config.Command")({
template: Schema.String,
description: Schema.String.pipe(Schema.optional),
agent: Schema.String.pipe(Schema.optional),
+2 -2
View File
@@ -3,11 +3,11 @@ export * as ConfigCompaction from "./compaction"
import { Schema } from "effect"
import { NonNegativeInt } from "../schema"
export class Keep extends Schema.Class<Keep>("ConfigV2.Compaction.Keep")({
export class Keep extends Schema.Class<Keep>("Config.Compaction.Keep")({
tokens: NonNegativeInt.pipe(Schema.optional),
}) {}
export class Info extends Schema.Class<Info>("ConfigV2.Compaction")({
export class Info extends Schema.Class<Info>("Config.Compaction")({
auto: Schema.Boolean.pipe(Schema.optional),
keep: Keep.pipe(Schema.optional),
buffer: NonNegativeInt.pipe(Schema.optional),
+1 -1
View File
@@ -2,7 +2,7 @@ export * as ConfigFormatter from "./formatter"
import { Schema } from "effect"
export class Entry extends Schema.Class<Entry>("ConfigV2.Formatter.Entry")({
export class Entry extends Schema.Class<Entry>("Config.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),
+1 -1
View File
@@ -6,7 +6,7 @@ export const Disabled = Schema.Struct({
disabled: Schema.Literal(true),
})
export class Server extends Schema.Class<Server>("ConfigV2.LSP.Server")({
export class Server extends Schema.Class<Server>("Config.LSP.Server")({
command: Schema.String.pipe(Schema.Array),
extensions: Schema.String.pipe(Schema.Array, Schema.optional),
disabled: Schema.Boolean.pipe(Schema.optional),
+1 -1
View File
@@ -15,7 +15,7 @@ export const Remote = Mcp.RemoteConfig
export type Remote = Mcp.RemoteConfig
export const Server = Mcp.ServerConfig
export class Info extends Schema.Class<Info>("ConfigV2.MCP")({
export class Info extends Schema.Class<Info>("Config.MCP")({
timeout: Timeout.pipe(Schema.optional),
servers: Schema.Record(Schema.String, Server).pipe(Schema.optional),
}) {}

Some files were not shown because too many files have changed in this diff Show More