Compare commits

...

33 Commits

Author SHA1 Message Date
Brendan Allan 0e1bedda1b fix(desktop): restrict renderer navigation 2026-07-26 03:19:04 +00: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
164 changed files with 4107 additions and 7598 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@opencode-ai/ai": patch
---
Report OpenAI prompt cache write tokens in normalized usage.
+5
View File
@@ -0,0 +1,5 @@
---
"@opencode-ai/ai": patch
---
Improve Anthropic and Bedrock prompt reuse with layered cache breakpoints that roll through long tool loops.
+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:
+1585 -1561
View File
File diff suppressed because it is too large Load Diff
+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 })
+95 -36
View File
@@ -7,8 +7,10 @@ import { Protocol } from "../route/protocol"
import {
LLMError,
LLMEvent,
mergeJsonRecords,
Usage,
type CacheHint,
type FinishReasonDetails,
type FinishReason,
type JsonSchema,
type LLMRequest,
@@ -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
}
@@ -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,
},
})
+150 -29
View File
@@ -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
}
@@ -373,9 +397,9 @@ const lowerToolResultOutput = Effect.fn("OpenResponses.lowerToolResultOutput")(f
})
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) {
@@ -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: {
+7 -4
View File
@@ -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(
@@ -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)
+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[],
+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 -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.
+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) =>
+3
View 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" } },
)
+27 -19
View File
@@ -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 & {})
@@ -733,16 +733,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
@@ -1249,6 +1239,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 +1351,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 +1374,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 +1389,7 @@ export type SessionReasoningEnded = {
assistantMessageID: string
ordinal: number
text: string
state?: SessionMessageProviderState4
state?: SessionMessageProviderState6
}
}
@@ -1398,7 +1406,7 @@ export type SessionToolCalled = {
callID: string
input: { [x: string]: any }
executed: boolean
state?: SessionMessageProviderState5
state?: SessionMessageProviderState7
}
}
@@ -1853,7 +1861,7 @@ export type SessionToolSuccess = {
content: [LLMToolContent, ...Array<LLMToolContent>]
metadata?: { [x: string]: JsonValue }
executed: boolean
resultState?: SessionMessageProviderState6
resultState?: SessionMessageProviderState8
}
}
@@ -1872,7 +1880,7 @@ export type SessionToolFailed = {
content?: [LLMToolContent, ...Array<LLMToolContent>]
metadata?: { [x: string]: JsonValue }
executed: boolean
resultState?: SessionMessageProviderState7
resultState?: SessionMessageProviderState9
}
}
+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
View File
@@ -63,7 +63,6 @@ const layer = Layer.effect(
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 {
+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,
+26 -14
View File
@@ -11,16 +11,20 @@ import { Instructions } from "../instructions/index"
const Summary = Schema.Struct({
server: Schema.String,
instructions: Schema.String,
codemode: Schema.optionalKey(Schema.Literal(false)),
})
type Summary = typeof Summary.Type
const entries = (servers: ReadonlyArray<Summary>) =>
servers.flatMap((server) => [
` <server name="${server.server}">`,
` Use tools from this server through \`execute\` under \`tools[${JSON.stringify(McpTool.namespace(server.server))}]\`.`,
...server.instructions.split("\n").map((line) => ` ${line}`),
" </server>",
])
servers.flatMap((server) => {
const result = [` <server name="${server.server}">`]
if (server.codemode !== false)
result.push(
` Use tools from this server through \`execute\` under \`tools[${JSON.stringify(McpTool.namespace(server.server))}]\`.`,
)
result.push(...server.instructions.split("\n").map((line) => ` ${line}`), " </server>")
return result
})
const render = (servers: ReadonlyArray<Summary>) =>
["<mcp_instructions>", ...entries(servers), "</mcp_instructions>"].join("\n")
@@ -30,7 +34,7 @@ const update = (previous: ReadonlyArray<Summary>, current: ReadonlyArray<Summary
previous,
current,
(server) => server.server,
(before, after) => before.instructions !== after.instructions,
(before, after) => before.instructions !== after.instructions || before.codemode !== after.codemode,
)
// Additions and removals render as small deltas; anything else restates the full list.
if (diff.changed.length > 0 || (diff.added.length === 0 && diff.removed.length === 0))
@@ -76,21 +80,29 @@ export const layer = Layer.effect(
removed: () => "MCP server instructions are no longer available.",
},
})
if (PermissionV2.evaluate("execute", "*", agent.permissions).effect === "deny")
return source(Instructions.removed)
const [instructions, tools] = yield* Effect.all([mcp.instructions(), mcp.tools()], {
concurrency: "unbounded",
})
const canExecute = PermissionV2.evaluate("execute", "*", agent.permissions).effect !== "deny"
// Instructions are useful only when this agent can reach at least one server tool.
const visible = instructions
.filter((item) => {
.flatMap((item) => {
const owned = tools.filter((tool) => tool.server === item.server)
return owned.some(
(tool) =>
PermissionV2.evaluate(McpTool.name(tool.server, tool.name), "*", agent.permissions).effect !== "deny",
const codemode = owned[0]?.codemode !== false
if (codemode && !canExecute) return []
if (
!owned.some(
(tool) =>
PermissionV2.evaluate(McpTool.name(tool.server, tool.name), "*", agent.permissions).effect !== "deny",
)
)
return []
return [
codemode
? { server: item.server, instructions: item.instructions }
: { server: item.server, instructions: item.instructions, codemode: false as const },
]
})
.map((item) => ({ server: item.server, instructions: item.instructions }))
.toSorted((a, b) => a.server.localeCompare(b.server))
return source(visible.length === 0 ? Instructions.removed : visible)
}),
+5
View File
@@ -218,6 +218,11 @@ const layer = Layer.effect(
if (result.effect === "allow") return
const item = yield* create(request(input), input.agent)
return yield* restore(Deferred.await(item.deferred)).pipe(
// Deliberate defect tunnel: leaves wrap execution in blanket `mapError`, which
// must not convert a user's decline into model-facing tool output. The decline
// resurfaces as a typed failure at SessionModelRequest.executeTool. A decline
// WITH feedback (CorrectedError) intentionally stays typed so the leaf can turn
// it into ToolFailure and the model continues.
Effect.catchTag("PermissionV2.DeclinedError", (error) => Effect.die(error)),
Effect.ensuring(
Effect.sync(() => {
+17 -17
View File
@@ -4,7 +4,7 @@ Use this guide as the starting point for work involving OpenCode itself. It
covers the core concepts needed to configure and customize OpenCode, extend it
with plugins, and build integrations with the OpenCode SDK, clients, and API.
Full documentation is available at <https://v2.opencode.ai/>. This overview is
Full documentation is available at <https://v2.opencode.ai/docs>. This overview is
only an index of core concepts. Before answering a question about a topic below,
fetch the URL named in that section and use the full page as the source of
truth. Follow links from that page when the question needs more detail. Fetch
@@ -16,7 +16,7 @@ documentation page.
Always answer for OpenCode V2 unless the user explicitly asks about V1,
legacy OpenCode, or migrating from V1.
Use only <https://v2.opencode.ai/> documentation as the source of truth for V2.
Use only <https://v2.opencode.ai/docs> documentation as the source of truth for V2.
Do not use <https://opencode.ai/docs/>, which documents V1, and do not use
general web search to resolve a V2 documentation question when the V2 docs or
their `llms.txt` index cover it. The schema served from
@@ -29,7 +29,7 @@ V1 documentation and syntax may be consulted only when the user explicitly
asks about V1 or when needed as migration input. Outputs and recommendations
must still use V2 unless the user specifically requests a V1 result.
## [Configuration](https://v2.opencode.ai/config)
## [Configuration](https://v2.opencode.ai/docs/config)
OpenCode configuration uses JSON or JSONC. Include the published schema so the
user's editor can validate fields and provide autocomplete:
@@ -60,15 +60,15 @@ linked topic guide as the source of truth, and preserve unrelated settings when
editing an existing file. Keep the published `$schema` URL in configuration
examples, but do not fetch it to determine the V2 configuration shape.
See the [full configuration guide](https://v2.opencode.ai/config) for
See the [full configuration guide](https://v2.opencode.ai/docs/config) for
every field, examples, config locations, and links to dedicated feature guides.
## [V1 to V2 migration](https://v2.opencode.ai/migrate-v1)
## [V1 to V2 migration](https://v2.opencode.ai/docs/migrate-v1)
For any request to migrate OpenCode configuration, agents, commands, skills,
plugins, integrations, or other behavior from V1 to V2, read the full
[migration guide](https://v2.opencode.ai/migrate-v1) before acting. In
the repository, its source is `packages/docs/migrate-v1.mdx`.
[migration guide](https://v2.opencode.ai/docs/migrate-v1) before acting. In
the repository, its source is `packages/www/content/docs/(Get started)/migrate-v1.mdx`.
V1 config files and `.opencode/` definitions are intended to remain compatible.
The only intentional breaking changes are the server API and plugin API. Native
@@ -76,18 +76,18 @@ V2 config uses more ergonomic shapes, but conversion is optional. When the user
requests conversion, inspect the complete configuration, preserve behavior and
unrelated settings, and apply only the relevant migrations from the guide. For
plugin migrations, fetch and follow both the migration guide and the full
[plugins guide](https://v2.opencode.ai/build/plugins). If non-API V1
[plugins guide](https://v2.opencode.ai/docs/build/plugins). If non-API V1
functionality fails in V2, use the `report` skill to file it as a compatibility
bug.
## [Plugins](https://v2.opencode.ai/build/plugins)
## [Plugins](https://v2.opencode.ai/docs/build/plugins)
For questions about creating, configuring, loading, publishing, or migrating
plugins, fetch the full [plugins guide](https://v2.opencode.ai/build/plugins)
plugins, fetch the full [plugins guide](https://v2.opencode.ai/docs/build/plugins)
before answering. This includes questions about the Effect plugin API, hooks,
transforms, tools, plugin context capabilities, and package entrypoints.
## [Service](https://v2.opencode.ai/troubleshooting#check-the-background-service)
## [Service](https://v2.opencode.ai/docs/troubleshooting#check-the-background-service)
OpenCode uses a client-server architecture. Interfaces such as the TUI connect
to a background OpenCode service, which owns sessions, configuration, plugins,
@@ -106,7 +106,7 @@ Check its status after restarting:
opencode2 service status
```
## [API](https://v2.opencode.ai/api)
## [API](https://v2.opencode.ai/docs/api)
OpenCode exposes an HTTP API from its server. The API is described by an
OpenAPI document available from the running server at `/openapi.json`.
@@ -135,15 +135,15 @@ connected to an explicit server instead of its managed background service, use
the same configured server and authentication context rather than constructing
an unauthenticated request separately.
See the [full API reference](https://v2.opencode.ai/api) for available
See the [full API reference](https://v2.opencode.ai/docs/api) for available
endpoints, parameters, request bodies, and response schemas. The
raw [OpenAPI specification](https://v2.opencode.ai/openapi.json) is also
available for code generation and other tooling.
## [Client](https://v2.opencode.ai/build/client)
## [Client](https://v2.opencode.ai/docs/build/client)
For questions about connecting an application to OpenCode over the network,
fetch the full [client guide](https://v2.opencode.ai/build/client) before
fetch the full [client guide](https://v2.opencode.ai/docs/build/client) before
answering.
`@opencode-ai/client` is the generated TypeScript client for the OpenCode HTTP
@@ -154,7 +154,7 @@ exposes typed Effects, Streams, and decoded OpenCode schema values. Its
`Service` API can discover, start, stop, and authenticate with the local
background service from a Node application.
## [Troubleshooting](https://v2.opencode.ai/troubleshooting)
## [Troubleshooting](https://v2.opencode.ai/docs/troubleshooting)
OpenCode runs a client and a background server. Start by determining whether a
problem belongs to the client, the shared server, or one project.
@@ -174,6 +174,6 @@ problem belongs to the client, the shared server, or one project.
- Redact API keys, authorization headers, prompts, file contents, and other
sensitive data before sharing diagnostics.
See the [full troubleshooting guide](https://v2.opencode.ai/troubleshooting)
See the [full troubleshooting guide](https://v2.opencode.ai/docs/troubleshooting)
for service lifecycle commands, API inspection, log locations, explicit server
connections, issue-reporting details, and local development paths.
@@ -40,8 +40,7 @@ export const layer = Layer.effect(
// Drain failures are already logged and durably recorded by the execution layer.
yield* Effect.ignore(execution.resume(sessionID))
}),
// Each suspension is consumed atomically right before its drain; at most four drains run at once.
{ concurrency: 4, discard: true },
{ concurrency: "unbounded", discard: true },
)
}),
})
+4 -1
View File
@@ -319,7 +319,10 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
"session.text.ended": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
const match = latestText(draft)
if (match) match.text = event.data.text
if (match) {
match.text = event.data.text
match.state = castDraft(event.data.state)
}
})
},
"session.tool.input.started": (event) => {
+28 -4
View File
@@ -2,11 +2,14 @@ export * as SessionModelRequest from "./model-request"
import { LLM, Message, SystemPart, type LLMRequest, type ToolContent } from "@opencode-ai/ai"
import { SessionError } from "@opencode-ai/schema/session-error"
import { Context, Effect, Layer } from "effect"
import { Cause, Context, Effect, Layer, Result } from "effect"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { App } from "../app"
import { ModelV2 } from "../model"
import { PermissionV2 } from "../permission"
import { PluginHooks } from "../plugin/hooks"
import { QuestionTool } from "../tool/question"
import { ToolOutputStore } from "../tool-output-store"
import { ToolRegistry } from "../tool/registry"
import { SessionContext } from "./context"
import { SessionModelHeaders } from "./model-headers"
@@ -14,13 +17,32 @@ import { MAX_STEPS_PROMPT } from "./runner/max-steps"
import PROMPT_DEFAULT from "./runner/prompt/base.txt"
import { toLLMMessages } from "./runner/to-llm-message"
/** Failures a prepared execution can surface: infrastructure errors plus user declines resurfaced from the defect tunnel. */
export type ExecuteError = ToolOutputStore.Error | PermissionV2.DeclinedError | QuestionTool.CancelledError
// User declines dive under the leaves' blanket `mapError` as defects (the deliberate
// tunnel entered in PermissionV2.assert and the question tool), so a user's "no" can
// never become model-facing tool output. They resurface as typed failures exactly once,
// here at the seam the runner executes through.
const declineDefect = (cause: Cause.Cause<ToolOutputStore.Error>) => {
const decline = cause.reasons.flatMap((reason) =>
Cause.isDieReason(reason) &&
(reason.defect instanceof PermissionV2.DeclinedError || reason.defect instanceof QuestionTool.CancelledError)
? [reason.defect]
: [],
)[0]
return decline ? Result.succeed(decline) : Result.fail(cause)
}
interface Prepared {
readonly request: LLMRequest
/**
* One request-scoped execution operation. Unknown, hook-removed, and
* step-limit-violating calls fail individually through the same seam.
*/
readonly executeTool: ToolRegistry.ToolSet["execute"]
readonly executeTool: (
input: ToolRegistry.ExecuteInput,
) => Effect.Effect<ToolRegistry.ToolOutcome, ExecuteError>
/** True when this request is the final Step; violating calls are rejected and no continuation follows. */
readonly stepLimitReached: boolean
}
@@ -134,7 +156,7 @@ export const layer = Layer.effect(
tools: hookedTools,
toolChoice: stepLimitReached ? "none" : undefined,
})
const executeTool: ToolRegistry.ToolSet["execute"] = (executeInput) => {
const executeTool: Prepared["executeTool"] = (executeInput) => {
if (stepLimitReached)
return Effect.succeed({
status: "error",
@@ -145,7 +167,9 @@ export const layer = Layer.effect(
status: "error",
error: { type: "tool.unknown", message: `Tool is not available for this request: ${executeInput.call.name}` },
})
return toolSet.execute(executeInput)
return toolSet
.execute(executeInput)
.pipe(Effect.catchCauseFilter(declineDefect, (decline) => Effect.fail(decline)))
}
return {
request,
+153 -137
View File
@@ -1,7 +1,7 @@
export * as SessionRunnerLLM from "./llm"
import { LLMClient, LLMError, LLMEvent, isContextOverflowFailure, type ProviderErrorEvent } from "@opencode-ai/ai"
import { Cause, Data, Effect, Exit, Fiber, FiberSet, Layer, Option, Pull, Schedule, Semaphore, Stream } from "effect"
import { LLMClient, LLMError, LLMEvent, isContextOverflowFailure, type ProviderErrorEvent, type ToolCall } from "@opencode-ai/ai"
import { Cause, Data, Effect, Exit, Fiber, FiberSet, Layer, Option, Pull, Schedule, Stream } from "effect"
import { Database } from "../../database/database"
import { EventV2 } from "../../event"
import { PermissionV2 } from "../../permission"
@@ -18,7 +18,7 @@ import { SessionSchema } from "../schema"
import { SessionStore } from "../store"
import { SessionTitle } from "../title"
import { Service } from "./index"
import { createLLMEventPublisher } from "./publish-llm-event"
import { createLLMEventPublisher, type StepRecord } from "./publish-llm-event"
import { Snapshot } from "../../snapshot"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { llmClient } from "../../effect/app-node-platform"
@@ -36,32 +36,53 @@ type CallOutcome = Data.TaggedEnum<{
const CallOutcome = Data.taggedEnum<CallOutcome>()
// Declining an interactive prompt halts the drain instead of becoming model-facing tool output.
const isUserDeclined = (cause: Cause.Cause<unknown>) =>
cause.reasons.some(
(reason) =>
Cause.isDieReason(reason) &&
(reason.defect instanceof PermissionV2.DeclinedError || reason.defect instanceof QuestionTool.CancelledError),
)
const isDecline = (
error: SessionModelRequest.ExecuteError,
): error is PermissionV2.DeclinedError | QuestionTool.CancelledError =>
error._tag === "PermissionV2.DeclinedError" || error._tag === "QuestionTool.CancelledError"
/**
* Classifies how the owned tool fibers ended. Interrupts and interactive declines abort
* the step; a defect from a tool implementation becomes a failed tool call the model can
* read; a typed infrastructure failure must fail the assistant and then the drain.
* Classifies how the owned tool fibers ended. Interrupts abort the step; a user decline
* settles its own call and then aborts the step; a defect from a tool implementation
* becomes a failed tool call the model can read; a typed infrastructure failure must
* fail the assistant and then the drain.
*/
const classifyToolExits = (settled: Exit.Exit<Array<Exit.Exit<void, ToolOutputStore.Error>>, never>) => {
const classifyToolExits = (
settled: Exit.Exit<Array<Exit.Exit<void, SessionModelRequest.ExecuteError>>, never>,
calls: ReadonlyArray<ToolCall>,
) => {
// Exits align with calls by construction: one owned fiber per accepted local call.
const exits = settled._tag === "Success" ? settled.value : []
const declines = exits.flatMap((exit, index) =>
exit._tag === "Failure"
? exit.cause.reasons.flatMap((reason) =>
Cause.isFailReason(reason) && isDecline(reason.error) ? [{ call: calls[index], reason: reason.error }] : [],
)
: [],
)
const causes =
settled._tag === "Failure"
? [settled.cause]
: settled.value.flatMap((exit) => (exit._tag === "Failure" ? [exit.cause] : []))
const failure = causes.find((cause) => !Cause.hasInterrupts(cause) && !isUserDeclined(cause))
settled._tag === "Failure" ? [settled.cause] : exits.flatMap((exit) => (exit._tag === "Failure" ? [exit.cause] : []))
// The first non-interrupt, non-decline failure, rebuilt without decline reasons so the
// drain's error channel never carries a decline.
const failure = causes.flatMap((cause) => {
if (Cause.hasInterrupts(cause)) return []
const reasons = cause.reasons.flatMap((reason): Array<Cause.Reason<ToolOutputStore.Error>> =>
Cause.isFailReason(reason) ? (isDecline(reason.error) ? [] : [Cause.makeFailReason(reason.error)]) : [reason],
)
return reasons.length > 0 ? [Cause.fromReasons(reasons)] : []
}).at(0)
return {
interrupted: causes.some(Cause.hasInterrupts),
declined: causes.some(isUserDeclined),
declines,
failure,
infraError: failure === undefined ? undefined : Option.getOrUndefined(Cause.findErrorOption(failure)),
}
}
const TOOLS_INTERRUPTED = { type: "aborted", message: "Tool execution interrupted" } as const
const STEP_INTERRUPTED = { type: "aborted", message: "Step interrupted" } as const
const RESULT_MISSING = { type: "tool.result-missing", message: "Provider did not return a tool result" } as const
const layer = Layer.effect(
Service,
Effect.gen(function* () {
@@ -203,9 +224,12 @@ const layer = Layer.effect(
context: loaded,
step: currentStep,
})
const toolFibers = yield* FiberSet.make<void, ToolOutputStore.Error>()
const ownedToolFibers: Array<Fiber.Fiber<void, ToolOutputStore.Error>> = []
let needsContinuation = false
// Every local tool call forked here is owned until it reaches one durable settlement.
const toolRuns: Array<{
readonly call: ToolCall
readonly fiber: Fiber.Fiber<void, SessionModelRequest.ExecuteError>
}> = []
const interruptTools = Effect.suspend(() => Fiber.interruptAll(toolRuns.map((run) => run.fiber)))
const startSnapshot = yield* snapshots.capture()
const publisher = createLLMEventPublisher(events, {
sessionID: session.id,
@@ -217,15 +241,9 @@ const layer = Layer.effect(
snapshot: startSnapshot,
assistantMessageID,
})
const publication = Semaphore.makeUnsafe(1)
// Durable publishes are serialized so tool fibers and step settlement never interleave
// mid-event.
const serialized = <A, E, R>(effect: Effect.Effect<A, E, R>) => publication.withPermit(effect)
const publish = (event: LLMEvent) => serialized(publisher.publish(event))
const stepUsage = (settlement: NonNullable<ReturnType<typeof publisher.stepSettlement>>) => ({
cost: SessionUsage.calculateCost(resolved.cost, settlement.tokens),
tokens: settlement.tokens,
const stepUsage = (finish: NonNullable<StepRecord["finish"]>) => ({
cost: SessionUsage.calculateCost(resolved.cost, finish.tokens),
tokens: finish.tokens,
})
const captureStepEnd = Effect.fnUntraced(function* () {
@@ -239,20 +257,26 @@ const layer = Layer.effect(
return { snapshot, files }
})
const publishStepEnd = (settlement: NonNullable<ReturnType<typeof publisher.stepSettlement>>) =>
const publishStepEnd = (finish: NonNullable<StepRecord["finish"]>) =>
Effect.gen(function* () {
const end = yield* captureStepEnd()
yield* serialized(
events.publish(SessionEvent.Step.Ended, {
sessionID: session.id,
assistantMessageID: yield* publisher.startAssistant(),
finish: settlement.finish,
...stepUsage(settlement),
...end,
}),
)
yield* events.publish(SessionEvent.Step.Ended, {
sessionID: session.id,
assistantMessageID: yield* publisher.startAssistant(),
finish: finish.finish,
...stepUsage(finish),
...end,
})
})
// Concurrent writers, no lock: the provider loop and each tool fiber publish
// durable events unserialized. This is safe because every publisher method commits
// its state marks synchronously before its first await (see publish-llm-event.ts),
// every required event order is per-source (each source is one sequential fiber),
// and a fiber's events are causally after its own Tool.Called: the fork happens
// below that publish. Cross-source order is unconstrained; either interleaving is
// a truthful history of concurrent work.
//
// The stream is defined here but runs inside the settlement mask below: publish each
// event durably, fork one fiber per local tool call, and hold back a virgin
// context-overflow provider error so settlement may recover it via compaction.
@@ -262,43 +286,40 @@ const layer = Layer.effect(
Effect.gen(function* () {
if (overflowFailure || publisher.hasProviderError()) return
if (LLMEvent.is.providerError(event)) {
if (isContextOverflowFailure(event) && !publisher.hasRetryEvidence()) {
if (isContextOverflowFailure(event) && !publisher.record().outputStarted) {
overflowFailure = event
return
}
}
yield* publish(event)
if (LLMEvent.is.toolInputError(event)) {
if (!prepared.stepLimitReached) needsContinuation = true
return
}
yield* publisher.publish(event)
if (event.type !== "tool-call" || event.providerExecuted) return
// Unavailable calls fail individually through the same execution seam;
// continuation depends only on remaining Step allowance.
if (!prepared.stepLimitReached) needsContinuation = true
const assistantMessageID = yield* publisher.assistantMessageID(event.id)
ownedToolFibers.push(
yield* Effect.uninterruptibleMask((restore) =>
toolRuns.push({
call: event,
fiber: yield* Effect.uninterruptibleMask((restore) =>
restore(
prepared.executeTool({
sessionID: session.id,
agent: agent.id,
messageID: assistantMessageID,
call: event,
progress: (update) => serialized(publisher.progress(event.id, update)),
// Progress is ephemeral, not durable history: nothing to order.
progress: (update) => publisher.progress(event.id, update),
}),
).pipe(
Effect.flatMap((execution) => serialized(publisher.toolExecution(event.id, event.name, execution))),
// The fiber owns its call: it publishes its own completion, masked so a
// finished execution always reaches its durable settlement.
Effect.flatMap((outcome) => publisher.toolExecution(event.id, event.name, outcome)),
),
).pipe(FiberSet.run(toolFibers)),
)
).pipe(Effect.forkScoped),
})
}),
),
Effect.ensuring(serialized(publisher.flush())),
Effect.ensuring(publisher.flush()),
)
// Settle: only the stream itself is interruptible (restore); every line after it is
// protected so a started call always reaches one durable outcome.
// Settle: only the stream and the fiber joins are interruptible (restore); every
// other line is protected so a started call always reaches one durable outcome.
return yield* Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () {
const stream = yield* restore(providerStream).pipe(Effect.exit)
@@ -307,108 +328,103 @@ const layer = Layer.effect(
// away non-interrupt failures, so both interrupt checks stay Cause-based.
const streamInterrupted = stream._tag === "Failure" && Cause.hasInterrupts(stream.cause)
// Join every owned tool run first: await all exits, not just the first failure.
// Afterwards no fiber is alive, settlement is the only writer, and the record
// is final. A failed join means the waiting itself was interrupted, so the runs
// we abandoned are interrupted before settlement closes them out.
if (streamInterrupted) yield* interruptTools
const joined = yield* restore(
Effect.forEach(toolRuns, (run) => Fiber.await(run.fiber), { concurrency: "unbounded" }),
).pipe(Effect.exit)
if (joined._tag === "Failure") yield* interruptTools
const tools = classifyToolExits(
joined,
toolRuns.map((run) => run.call),
)
// A context overflow before any assistant output is recoverable: compact and
// restart the step instead of surfacing the provider error.
if (
recoverOverflow &&
!publisher.hasRetryEvidence() &&
!publisher.record().outputStarted &&
isContextOverflowFailure(overflowFailure ?? streamFailure) &&
(yield* restore(compaction.compact(compactionInput))).status === "completed"
)
return CallOutcome.Restart({ step: currentStep, recoveredOverflow: true })
// An unrecovered held-back overflow becomes the step's durable provider error. A
// thrown LLM failure records the assistant failure unless a provider error was
// already recorded from the stream. Terminal publication waits for owned tools.
if (overflowFailure) yield* publish(overflowFailure)
// An unrecovered held-back overflow becomes the step's durable provider error.
if (overflowFailure) yield* publisher.publish(overflowFailure)
// A thrown LLM failure not already recorded as the provider error either
// escapes as a scheduled retry or fails the assistant durably.
const llmFailure = streamFailure instanceof LLMError ? streamFailure : undefined
if (llmFailure && !publisher.hasProviderError()) {
const error = toSessionError(llmFailure)
if (SessionRunnerRetry.isRetryable(llmFailure) && !publisher.hasRetryEvidence()) {
// RetryScheduled and Step.Failed fold onto an existing assistant message, so
// Step.Started must be durable before the failure escapes.
yield* serialized(publisher.startAssistant())
return yield* new SessionRunnerRetry.RetryableFailure({
cause: llmFailure,
error,
step: currentStep,
})
}
yield* serialized(publisher.failAssistant(error))
const llmError = llmFailure && !publisher.record().providerFailed ? toSessionError(llmFailure) : undefined
if (llmFailure && llmError && SessionRunnerRetry.isRetryable(llmFailure) && !publisher.record().outputStarted) {
// RetryScheduled and Step.Failed fold onto an existing assistant message, so
// Step.Started must be durable before the failure escapes.
yield* publisher.startAssistant()
return yield* new SessionRunnerRetry.RetryableFailure({
cause: llmFailure,
error: llmError,
step: currentStep,
})
}
// Provider error events only arrive from the stream, so the flag is final here.
const providerFailed = publisher.hasProviderError()
if (llmError) yield* publisher.failAssistant(llmError)
// Settle every owned tool fiber. FiberSet.join returns on the first failure, so retain
// the individual fibers and await all exits before publishing the terminal step event.
if (streamInterrupted) yield* FiberSet.clear(toolFibers)
const settled = yield* restore(
Effect.forEach(ownedToolFibers, Fiber.await, { concurrency: "unbounded" }),
).pipe(Effect.exit)
if (settled._tag === "Failure") yield* FiberSet.clear(toolFibers)
const tools = classifyToolExits(settled)
if (tools.declined || streamInterrupted || tools.interrupted) {
yield* serialized(publisher.failUnsettledTools({ type: "aborted", message: "Tool execution interrupted" }))
yield* serialized(publisher.failAssistant({ type: "aborted", message: "Step interrupted" }))
// Close every unsettled call with the reason it could not settle truthfully,
// and fail the assistant when the step itself cannot complete. A declined call
// settles with its own reason before the generic sweeps.
for (const decline of tools.declines)
yield* publisher.failTool(decline.call.id, {
type: "aborted",
message:
decline.reason._tag === "QuestionTool.CancelledError"
? decline.reason.message
: "The user declined this tool call",
})
if (tools.declines.length > 0 || streamInterrupted || tools.interrupted) {
yield* publisher.failUnsettledTools(TOOLS_INTERRUPTED)
yield* publisher.failAssistant(STEP_INTERRUPTED)
}
if (tools.failure !== undefined) {
const error = toSessionError(tools.infraError ?? Cause.squash(tools.failure))
yield* serialized(publisher.failUnsettledTools(error))
if (tools.infraError !== undefined) yield* serialized(publisher.failAssistant(error))
yield* publisher.failUnsettledTools(error)
if (tools.infraError !== undefined) yield* publisher.failAssistant(error)
}
// Local calls have joined, so the remaining sweeps only close hosted calls the
// provider promised but never resolved.
if (publisher.record().providerFailed) yield* publisher.failUnsettledTools(TOOLS_INTERRUPTED)
if (llmError) yield* publisher.failUnsettledTools(RESULT_MISSING, "hosted")
// A clean stream that still left hosted calls unresolved fails the step itself.
if (stream._tag === "Success" && !publisher.record().providerFailed) {
const hostedResultMissing = yield* publisher.failUnsettledTools(RESULT_MISSING, "hosted")
if (hostedResultMissing && !publisher.record().finish) yield* publisher.failAssistant(RESULT_MISSING)
}
// Fail unresolved calls before the terminal step event. Local calls have joined, so
// these sweeps only close calls that could not produce a truthful settlement.
if (providerFailed)
yield* serialized(publisher.failUnsettledTools({ type: "aborted", message: "Tool execution interrupted" }))
if (llmFailure && !providerFailed)
yield* serialized(
publisher.failUnsettledTools(
{
type: "tool.result-missing",
message: "Provider did not return a tool result",
},
true,
),
)
const hostedResultMissing =
stream._tag === "Success" && !providerFailed
? yield* serialized(
publisher.failUnsettledTools(
{ type: "tool.result-missing", message: "Provider did not return a tool result" },
true,
),
)
: false
if (hostedResultMissing && !publisher.stepSettlement())
yield* serialized(
publisher.failAssistant({
type: "tool.result-missing",
message: "Provider did not return a tool result",
}),
)
const stepFailure = publisher.stepFailure()
const stepSettlement = publisher.stepSettlement()
if (stepSettlement && !stepFailure) yield* publishStepEnd(stepSettlement)
if (stepFailure) {
// One terminal event: Step.Ended on a clean finish, Step.Failed otherwise.
const record = publisher.record()
if (record.finish && !record.failure) yield* publishStepEnd(record.finish)
if (record.failure) {
const end = yield* captureStepEnd()
yield* serialized(
publisher.publishStepFailure({
...(stepSettlement ? stepUsage(stepSettlement) : {}),
...end,
}),
)
yield* publisher.publishStepFailure({
...(record.finish ? stepUsage(record.finish) : {}),
...end,
})
}
if (stream._tag === "Failure") return yield* Effect.failCause(stream.cause)
if (tools.declined) return yield* Effect.interrupt
if (tools.declines.length > 0) return yield* Effect.interrupt
if ((tools.interrupted || tools.infraError !== undefined) && tools.failure)
return yield* Effect.failCause(tools.failure)
if (tools.interrupted && settled._tag === "Failure") return yield* Effect.failCause(settled.cause)
if (stepFailure) return yield* new StepFailedError({ error: stepFailure })
return CallOutcome.Completed({ needsContinuation, step: currentStep })
if (tools.interrupted && joined._tag === "Failure") return yield* Effect.failCause(joined.cause)
if (record.failure) return yield* new StepFailedError({ error: record.failure })
return CallOutcome.Completed({
// A local call or malformed tool input requires another model step, unless
// this step already exhausted the agent's allowance.
needsContinuation:
!prepared.stepLimitReached &&
record.calls.some((call) => !call.providerExecuted && (call.called || call.settled)),
step: currentStep,
})
}),
)
}, Effect.scoped)
@@ -23,9 +23,30 @@ type Input = {
readonly assistantMessageID: SessionMessage.ID
}
const record = (value: unknown): Record<string, unknown> =>
const asRecord = (value: unknown): Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : { value }
/** Immutable fold of the durable facts a step's writer has recorded so far. */
export interface StepRecord {
/** The model produced visible output this attempt, which bars transparent retries and overflow recovery. */
readonly outputStarted: boolean
readonly providerFailed: boolean
/** The step's recorded assistant failure, if any. */
readonly failure?: SessionError.Error
/** Present once the provider finished the step normally. */
readonly finish?: {
readonly finish: Extract<LLMEvent, { type: "step-finish" }>["reason"]["normalized"]
readonly tokens: ReturnType<typeof SessionUsage.tokens>
}
readonly calls: ReadonlyArray<{
readonly id: string
readonly name: string
readonly called: boolean
readonly settled: boolean
readonly providerExecuted: boolean
}>
}
/** Derives canonical model content from a provider-hosted tool result. */
const hostedContent = (result: ToolResultValue): Tool.NonEmptyContent => {
if (result.type === "content") {
@@ -35,7 +56,17 @@ const hostedContent = (result: ToolResultValue): Tool.NonEmptyContent => {
return [{ type: "text", text: Tool.stringify(result.value) }]
}
/** Persist one step without executing tools or starting a continuation step. */
/**
* Persist one step without executing tools or starting a continuation step.
*
* Concurrency invariant: the provider loop and each owned tool fiber call these methods
* concurrently without a lock. Two rules keep that safe, and every method must preserve
* them. (1) Commit state marks synchronously before the first await: never a yield
* between a check (`tool.settled`, `stepStarted`, ...) and its mark, so check-and-mark
* stays atomic under cooperative scheduling. (2) Never require a cross-source event
* order: each publishing fiber is sequential, so per-source order holds by construction,
* and consumers fold by callID/ordinal rather than global position.
*/
export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish">, input: Input) => {
const tools = new Map<
string,
@@ -54,14 +85,9 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
let stepStarted = false
let stepFailed = false
let providerFailed = false
let retryEvidence = false
let outputStarted = false
let stepFailure: SessionError.Error | undefined
let stepSettlement:
| {
readonly finish: Extract<LLMEvent, { type: "step-finish" }>["reason"]["normalized"]
readonly tokens: ReturnType<typeof SessionUsage.tokens>
}
| undefined
let stepSettlement: StepRecord["finish"]
const startAssistant = Effect.fnUntraced(function* () {
if (stepStarted) return assistantMessageID
@@ -123,13 +149,14 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
const text = fragments(
"text",
(_textID, value, ordinal) =>
(_textID, value, ordinal, state) =>
Effect.gen(function* () {
yield* events.publish(SessionEvent.Text.Ended, {
sessionID: input.sessionID,
assistantMessageID: yield* currentAssistantMessageID(),
ordinal,
text: value,
state,
})
}),
true,
@@ -232,21 +259,27 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
yield* flushFragments()
})
const failTool = Effect.fnUntraced(function* (callID: string, error: SessionError.Error) {
const tool = tools.get(callID)
if (!tool || tool.settled) return false
tool.settled = true
yield* events.publish(SessionEvent.Tool.Failed, {
sessionID: input.sessionID,
assistantMessageID: tool.assistantMessageID,
callID,
error,
...failureSnapshot(tool),
executed: tool.providerExecuted,
})
return true
})
const failTools = Effect.fnUntraced(function* (error: SessionError.Error, mode: "all" | "hosted" | "uncalled") {
let failed = false
for (const [callID, tool] of tools) {
if (tool.settled || (mode === "hosted" && !tool.providerExecuted) || (mode === "uncalled" && tool.called))
continue
tool.settled = true
failed = true
yield* events.publish(SessionEvent.Tool.Failed, {
sessionID: input.sessionID,
assistantMessageID: tool.assistantMessageID,
callID,
error,
...failureSnapshot(tool),
executed: tool.providerExecuted,
})
failed = (yield* failTool(callID, error)) || failed
}
return failed
})
@@ -277,9 +310,9 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
const failUnsettledTools = Effect.fn("SessionRunner.failUnsettledTools")(function* (
error: SessionError.Error,
hostedOnly = false,
scope: "hosted" | "all" = "all",
) {
return yield* failTools(error, hostedOnly ? "hosted" : "all")
return yield* failTools(error, scope)
})
const assistantMessageIDForTool = (callID: string) => {
@@ -293,8 +326,8 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
yield* startAssistant()
return
case "text-start":
retryEvidence = true
const startedTextOrdinal = yield* text.start(event.id)
outputStarted = true
const startedTextOrdinal = yield* text.start(event.id, providerState(event.providerMetadata))
yield* events.publish(SessionEvent.Text.Started, {
sessionID: input.sessionID,
assistantMessageID: yield* startAssistant(),
@@ -302,7 +335,7 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
})
return
case "text-delta":
const deltaTextOrdinal = yield* text.append(event.id, event.text)
const deltaTextOrdinal = yield* text.append(event.id, event.text, providerState(event.providerMetadata))
yield* events.publish(SessionEvent.Text.Delta, {
sessionID: input.sessionID,
assistantMessageID: yield* currentAssistantMessageID(),
@@ -311,10 +344,10 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
})
return
case "text-end":
yield* text.end(event.id)
yield* text.end(event.id, providerState(event.providerMetadata))
return
case "reasoning-start":
retryEvidence = true
outputStarted = true
const startedReasoningOrdinal = yield* reasoning.start(event.id, providerState(event.providerMetadata))
yield* events.publish(SessionEvent.Reasoning.Started, {
sessionID: input.sessionID,
@@ -340,7 +373,7 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
yield* reasoning.end(event.id, providerState(event.providerMetadata))
return
case "tool-input-start":
retryEvidence = true
outputStarted = true
yield* startToolInput(event)
return
case "tool-input-delta": {
@@ -362,11 +395,11 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
yield* endToolInput(event)
return
case "tool-input-error":
retryEvidence = true
outputStarted = true
yield* failMalformedToolInput(event)
return
case "tool-call": {
retryEvidence = true
outputStarted = true
if (!tools.has(event.id)) yield* startToolInput(event)
const tool = tools.get(event.id)!
if (toolInput.has(event.id)) yield* endToolInput(event)
@@ -379,7 +412,7 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
sessionID: input.sessionID,
assistantMessageID: tool.assistantMessageID,
callID: event.id,
input: record(event.input),
input: asRecord(event.input),
executed: tool.providerExecuted,
state: providerState(event.providerMetadata),
})
@@ -525,12 +558,24 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
toolExecution,
flush,
failAssistant,
failTool,
publishStepFailure,
failUnsettledTools,
hasProviderError: () => providerFailed,
hasRetryEvidence: () => retryEvidence,
stepFailure: () => stepFailure,
stepSettlement: () => stepSettlement,
/** Immutable snapshot of everything recorded for this step so far. */
record: (): StepRecord => ({
outputStarted,
providerFailed,
failure: stepFailure,
finish: stepSettlement,
calls: Array.from(tools, ([id, tool]) => ({
id,
name: tool.name,
called: tool.called,
settled: tool.settled,
providerExecuted: tool.providerExecuted,
})),
}),
startAssistant,
assistantMessageID: assistantMessageIDForTool,
}
@@ -113,7 +113,14 @@ const assistant = (message: SessionMessage.Assistant, model: ModelV2.Ref, provid
const sameModel = sameProvider && String(message.model.id) === String(model.id)
const reuseProviderMetadata = sameModel && message.error === undefined
const content = message.content.flatMap((item): ContentPart[] => {
if (item.type === "text") return [{ type: "text", text: item.text }]
if (item.type === "text")
return [
{
type: "text",
text: item.text,
providerMetadata: sameProvider ? providerMetadata(providerMetadataKey, item.state) : undefined,
},
]
if (item.type === "reasoning")
return reuseProviderMetadata
? [
+1 -1
View File
@@ -24,7 +24,7 @@ const source = {
}
```
Leaves own resolution, permission, and side-effect ordering. Translate only expected typed errors into `ToolFailure`; do not use `catchCause`, because interruption and defects must survive.
Leaves own resolution, permission, and side-effect ordering. Translate only expected typed errors into `ToolFailure`; do not use `catchCause`, because interruption and defects must survive. User declines from `PermissionV2.assert` and question dismissals travel as defects beneath leaf `mapError` blankets and resurface as typed failures at `SessionModelRequest.executeTool`; leaves must never catch or convert them. A decline with feedback (`PermissionV2.CorrectedError`) stays typed so the leaf converts it into `ToolFailure` and the model continues.
## Registration
+31 -28
View File
@@ -3,7 +3,7 @@ export type { Registration } from "./tool"
import { CodeMode, Tool, toolError } from "@opencode-ai/codemode"
import type { ToolContent } from "@opencode-ai/ai"
import { Effect, Ref, Schema } from "effect"
import { Effect, Ref, Schema, Semaphore } from "effect"
import { execute, make, toLLMDefinition, type Content, type Metadata, type Registration } from "./tool"
const ExecuteFile = Schema.Struct({
@@ -34,10 +34,12 @@ 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>) => {
@@ -50,12 +52,11 @@ 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) =>
@@ -66,7 +67,7 @@ export const create = (registrations: ReadonlyMap<string, Registration>) => {
agent: context.agent,
messageID: context.messageID,
callID: context.callID,
progress: context.progress,
progress: () => Effect.void,
}).pipe(Effect.mapError((failure) => toolError(failure.message, failure)))
const outputFileParts = outputFiles(executed.content)
if (outputFileParts.length > 0)
@@ -74,26 +75,28 @@ export const create = (registrations: ReadonlyMap<string, Registration>) => {
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)
+29 -12
View File
@@ -7,6 +7,7 @@ import path from "path"
import { FileSystem } from "../filesystem"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Location } from "../location"
import { LocationMutation } from "../location-mutation"
import { Ripgrep } from "../ripgrep"
import { RelativePath } from "../schema"
import { PermissionV2 } from "../permission"
@@ -17,10 +18,10 @@ export const name = "glob"
export const Input = Schema.Struct({
pattern: FileSystem.GlobInput.fields.pattern.annotate({ description: "Glob pattern to match files against" }),
path: RelativePath.pipe(Schema.optional).annotate({
description: "Relative directory to search. Defaults to the active Location.",
description: "Directory to search. Defaults to the current working directory.",
}),
limit: FileSystem.GlobInput.fields.limit.annotate({
description: `Maximum results to return (default: ${FileSystem.DEFAULT_SEARCH_LIMIT})`,
description: `Maximum number of matching files to return (default: ${FileSystem.DEFAULT_SEARCH_LIMIT})`,
}),
})
@@ -45,6 +46,7 @@ export const Plugin = {
const fs = yield* FSUtil.Service
const ripgrep = yield* Ripgrep.Service
const location = yield* Location.Service
const mutation = yield* LocationMutation.Service
const permission = yield* PermissionV2.Service
yield* ctx.tool
@@ -53,36 +55,51 @@ export const Plugin = {
name,
Tool.make({
description:
"Find files by glob pattern within the active Location. Returns concise relative file resources. Use a relative path to narrow the search and limit to bound the result count.",
'Search file paths using a glob pattern (examples: "**/*.ts", "src/**/*.tsx").',
input: Input,
output: Output,
execute: (input, context) =>
Effect.gen(function* () {
const searchPath = input.path === "undefined" || input.path === "null" ? undefined : input.path
const source = { type: "tool" as const, messageID: context.messageID, callID: context.callID }
const target = yield* mutation.resolve({ path: searchPath ?? ".", kind: "directory" })
const external = target.externalDirectory
if (external)
yield* permission.assert({
...LocationMutation.externalDirectoryPermission(external),
sessionID: context.sessionID,
agent: context.agent,
source,
})
yield* permission.assert({
action: name,
resources: [input.pattern],
save: ["*"],
metadata: {
root: input.path ?? ".",
path: input.path,
root: searchPath ?? ".",
path: searchPath,
limit: input.limit,
},
sessionID: context.sessionID,
agent: context.agent,
source: { type: "tool", messageID: context.messageID, callID: context.callID },
source,
})
const cwd = path.resolve(location.directory, input.path ?? ".")
yield* fs
.stat(cwd)
const info = yield* fs
.stat(target.canonical)
.pipe(
Effect.catchReason("PlatformError", "NotFound", () =>
Effect.fail(new ToolFailure({ message: `Search path does not exist: ${input.path ?? "."}` })),
Effect.fail(new ToolFailure({ message: `Search path does not exist: ${searchPath ?? "."}` })),
),
)
if (info.type !== "Directory")
return yield* Effect.fail(
new ToolFailure({ message: `Search path is not a directory: ${searchPath ?? "."}` }),
)
const root = path.resolve(location.directory, searchPath ?? ".")
const limit = input.limit ?? FileSystem.DEFAULT_SEARCH_LIMIT
const entries = yield* ripgrep
.glob({
cwd,
cwd: target.canonical,
pattern: input.pattern,
limit: limit + 1,
})
@@ -91,7 +108,7 @@ export const Plugin = {
result.map((entry) =>
FileSystem.Entry.make({
...entry,
path: RelativePath.make(path.relative(location.directory, path.resolve(cwd, entry.path))),
path: RelativePath.make(path.relative(location.directory, path.resolve(root, entry.path))),
}),
),
),
+3
View File
@@ -91,6 +91,9 @@ export const Plugin = {
.pipe(Effect.orDie),
),
Effect.flatMap((state) => {
// Deliberate defect tunnel (see PermissionV2.assert): a dismissal must dodge
// leaf `mapError` blankets so it never becomes model-facing tool output; it
// resurfaces as a typed failure at SessionModelRequest.executeTool.
if (state.status === "cancelled") return Effect.die(new CancelledError())
const output = {
answers: input.questions.map((_, index): QuestionV2.Answer => {
+1 -3
View File
@@ -22,9 +22,7 @@ export const Output = Schema.Struct({
output: Schema.String,
})
export const description = [
"Load a specialized skill when the task at hand matches one of the available skills in the instructions.",
"",
"Use this tool to inject the skill's instructions and resources into the current conversation. The output may contain detailed workflow guidance as well as references to scripts, files, etc. in the same directory as the skill.",
"Load a specialized skill's instructions and resources into the current conversation when the task at hand matches its description.",
"",
"The skill ID must match one of the available skills in the instructions.",
].join("\n")
+10 -19
View File
@@ -66,20 +66,8 @@ describe("CodeModeInstructions.render", () => {
expect(instructions).toContain("- orders (1 tool)")
expect(instructions).toContain(` - ${lookup.signature} // Look up an order by ID`)
expect(instructions).not.toContain("## Search")
expect(instructions).toContain("Do not infer or normalize tool names")
expect(instructions).toContain('`tools.<namespace>["tool-name"](input)`')
})
test("describes the runtime and execution lifecycle concisely", () => {
const instructions = render([lookup])
expect(instructions).toContain("Run JavaScript to orchestrate tool calls and compose their results.")
expect(instructions).toContain("Imports, direct filesystem access, and timers are unavailable.")
expect(instructions).toContain("Do not use `fetch`; all external access goes through `tools`.")
expect(instructions).toContain(
"Prefer an explicit `return`; if omitted, the final top-level expression becomes the result.",
)
expect(instructions).toContain("any calls still pending when execution ends are interrupted")
expect(instructions).toContain("Run independent calls concurrently with `Promise.all`.")
expect(instructions).toContain("The Code Mode tool catalog below is complete.")
expect(instructions).not.toContain("surrounding top-level agent tools")
})
test("adds search guidance when the catalog exceeds the budget", () => {
@@ -87,10 +75,10 @@ describe("CodeModeInstructions.render", () => {
expect(partial).toContain("## Available tools")
expect(partial).toContain("- orders (1 tool, none shown)")
expect(partial).toContain("## Search")
expect(partial).toContain("Only some tool signatures are shown.")
expect(partial).toContain("The Code Mode tool catalog below is partial.")
expect(partial).not.toContain("surrounding top-level agent tools")
expect(partial).toContain("- search(input: {")
expect(partial).toContain(" limit?: number,\n offset?: number,")
expect(partial).toContain("or returned by `search`")
expect(partial).not.toContain("tools.orders.lookup(input:")
})
@@ -125,7 +113,9 @@ describe("CodeModeInstructions.render", () => {
})
test("renders only the no-tools notice for an empty catalog", () => {
expect(render([])).toBe("No tools are currently available.")
expect(render([])).toBe(
"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.",
)
})
})
@@ -135,7 +125,8 @@ describe("CodeModeInstructions.update", () => {
test("renders additions, changes, and removals as a compact semantic delta", () => {
const changed = { ...echo, signature: "tools.notes.echo(input: {\n text: string,\n}): Promise<string>" }
const added = entry("notes.list", "List notes")
const text = update([echo, lookup], [changed, added])
const unchanged = Array.from({ length: 5 }, (_, index) => entry(`stable.tool${index}`, `Stable ${index}`))
const text = update([echo, lookup, ...unchanged], [changed, added, ...unchanged])
expect(text).toContain("The Code Mode tool catalog has changed.")
expect(text).toContain(`New tools are available in addition to those previously listed:\n - ${added.signature}`)
expect(text).toContain(
@@ -169,7 +160,7 @@ describe("CodeModeInstructions.update", () => {
expect(text).toContain("This catalog supersedes the previous Code Mode tool catalog.")
expect(text).toContain("## Available tools")
expect(text).not.toContain("## Search")
expect(text).not.toContain("must not be called")
expect(text).not.toContain("The following tools are no longer available")
})
test("renders namespace-only deltas without persisting hidden tool entries", () => {
@@ -21,6 +21,25 @@ const lookup: CodeModeCatalog.Entry = {
}
describe("CodeModeInstructions", () => {
it.effect("instructs the model not to call execute while the catalog is empty", () =>
Effect.gen(function* () {
const initialized = yield* readInitial(CodeModeInstructions.make([]))
expect(initialized.text).toBe(
"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 added = yield* readUpdate(CodeModeInstructions.make([echo]), initialized)
expect(added.text).toContain("New tools are available in addition to those previously listed:")
expect(added.text).toContain(echo.signature)
expect(yield* readUpdate(CodeModeInstructions.make([]), { values: added.values })).toMatchObject({
text:
"The Code Mode tool catalog has changed. This catalog supersedes the previous Code Mode tool catalog.\n\n" +
"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.",
})
}),
)
it.effect("renders the initial catalog, semantic deltas, and removal", () =>
Effect.gen(function* () {
const initialized = yield* readInitial(CodeModeInstructions.make([echo]))
+16
View File
@@ -32,6 +32,22 @@ export function waitForTool(
})
}
export function waitForCodeModeTool(
registry: ToolRegistry.Interface,
path: string,
remaining = 1000,
): Effect.Effect<ToolRegistry.ToolSet, Error> {
return Effect.gen(function* () {
const toolSet = yield* registry.snapshot()
if (toolSet.codeModeCatalog?.some((tool) => tool.path === path)) return toolSet
if (remaining === 0) {
return yield* Effect.fail(new Error(`Timed out waiting for Code Mode tool: ${path}`))
}
yield* Effect.promise(() => Bun.sleep(1))
return yield* waitForCodeModeTool(registry, path, remaining - 1)
})
}
/**
* Registers a core tool plugin's tools against the real registry without booting the
* full plugin host. Only the tool domain is live; focused tool tests exercise
@@ -93,6 +93,60 @@ describe("McpInstructions", () => {
),
)
it.effect("keeps MCP instructions when Code Mode is disabled and execute is denied", () =>
Effect.gen(function* () {
const service = yield* McpInstructions.Service
const generation = yield* service
.load(selection([{ action: "execute", resource: "*", effect: "deny" }]))
.pipe(Effect.flatMap(readInitial))
expect(generation.text).toBe(
[
"<mcp_instructions>",
' <server name="alpha">',
" Alpha instructions",
" </server>",
"</mcp_instructions>",
].join("\n"),
)
}).pipe(
Effect.provide(
layer(
() => [instructions("alpha", "Alpha instructions")],
() => [new MCP.Tool({ server: MCP.ServerName.make("alpha"), name: "search", codemode: false })],
),
),
),
)
it.effect("restates guidance when Code Mode is disabled for a server", () => {
let tools = [tool("alpha")]
return Effect.gen(function* () {
const service = yield* McpInstructions.Service
const initialized = yield* service.load(selection()).pipe(Effect.flatMap(readInitial))
tools = [new MCP.Tool({ server: MCP.ServerName.make("alpha"), name: "search", codemode: false })]
const changed = yield* readUpdate(yield* service.load(selection()), initialized)
expect(changed.text).toBe(
[
"The available MCP server instructions have changed. This list supersedes the previous one.",
"<mcp_instructions>",
' <server name="alpha">',
" Alpha instructions",
" </server>",
"</mcp_instructions>",
].join("\n"),
)
}).pipe(
Effect.provide(
layer(
() => [instructions("alpha", "Alpha instructions")],
() => tools,
),
),
)
})
it.effect("renders additions, changes, and removal", () => {
let catalog = [instructions("alpha", "Alpha instructions")]
const tools = [tool("alpha"), tool("beta")]
+14 -8
View File
@@ -33,7 +33,7 @@ import { Image } from "@opencode-ai/core/image"
import { testEffect } from "./lib/effect"
import { imagePassthrough } from "./lib/image"
import { location } from "./fixture/location"
import { executeTool, toolDefinitions, toolIdentity, waitForTool } from "./lib/tool"
import { executeTool, toolDefinitions, toolIdentity, waitForCodeModeTool, waitForTool } from "./lib/tool"
let assertion: Deferred.Deferred<PermissionV2.AssertInput> | undefined
let decision: Effect.Effect<void, PermissionV2.Error> = Effect.void
@@ -802,10 +802,16 @@ test("serializes concurrent MCP lifecycle operations", async () => {
it.effect("advertises MCP output schemas to Code Mode", () =>
Effect.gen(function* () {
const registry = yield* ToolRegistry.Service
yield* waitForTool(registry, "execute")
const definitions = yield* toolDefinitions(registry)
const execute = definitions.find((tool) => tool.name === "execute")
const toolSet = yield* waitForCodeModeTool(registry, "demo.search")
const execute = toolSet.definitions.find((tool) => tool.name === "execute")
expect(toolSet.definitions.map((tool) => tool.name)).toEqual([
"direct_fail",
"direct_lookup",
"direct_media",
"execute",
])
expect(toolSet.codeModeCatalog?.find((tool) => tool.path === "demo.search")?.signature).toContain("ok: boolean")
expect(execute?.description).not.toContain("tools.demo.search")
}),
)
@@ -873,9 +879,9 @@ it.effect("waits for permission before calling an MCP tool", () =>
const permission = yield* Deferred.make<void>()
decision = Deferred.await(permission)
const registry = yield* ToolRegistry.Service
yield* waitForTool(registry, "execute")
const toolSet = yield* waitForCodeModeTool(registry, "demo.search")
const fiber = yield* executeTool(registry, {
const fiber = yield* toolSet.execute({
sessionID: SessionV2.ID.make("ses_mcp_permission"),
...toolIdentity,
call: {
@@ -912,9 +918,9 @@ it.effect("does not call MCP when permission is blocked", () =>
assertion = yield* Deferred.make<PermissionV2.AssertInput>()
decision = Effect.fail(new PermissionV2.BlockedError({ rules: [], permission: "demo_search", resources: ["*"] }))
const registry = yield* ToolRegistry.Service
yield* waitForTool(registry, "execute")
const toolSet = yield* waitForCodeModeTool(registry, "demo.search")
const execution = yield* executeTool(registry, {
const execution = yield* toolSet.execute({
sessionID: SessionV2.ID.make("ses_mcp_blocked"),
...toolIdentity,
call: {
-26
View File
@@ -64,29 +64,3 @@ describe("Npm.add", () => {
expect(entries.fallback.entrypoint).toEndWith("/index.js")
})
})
describe("Npm.install", () => {
test("respects omit from project .npmrc", async () => {
await using tmp = await tmpdir()
await writePackage(tmp.path, {
name: "fixture",
dependencies: {
"prod-pkg": "file:./prod-pkg",
},
devDependencies: {
"dev-pkg": "file:./dev-pkg",
},
})
await Bun.write(path.join(tmp.path, ".npmrc"), "omit=dev\n")
await fs.mkdir(path.join(tmp.path, "prod-pkg"))
await fs.mkdir(path.join(tmp.path, "dev-pkg"))
await writePackage(path.join(tmp.path, "prod-pkg"), { name: "prod-pkg" })
await writePackage(path.join(tmp.path, "dev-pkg"), { name: "dev-pkg" })
await Npm.install(tmp.path)
await expect(fs.stat(path.join(tmp.path, "node_modules", "prod-pkg"))).resolves.toBeDefined()
await expect(fs.stat(path.join(tmp.path, "node_modules", "dev-pkg"))).rejects.toThrow()
})
})
@@ -105,6 +105,31 @@ describe("SessionExecution lifecycle", () => {
}),
)
it.effect("starts every suspended execution without waiting for earlier drains to finish", () =>
Effect.gen(function* () {
const database = yield* Database.Service
const sessionIDs = Array.from({ length: 5 }, (_, index) => SessionV2.ID.make(`ses_resume_concurrent_${index}`))
yield* seedSessions(database, sessionIDs, { time_suspended: Date.now() })
const fourStarted = yield* Deferred.make<void>()
const started: SessionV2.ID[] = []
const scope = yield* Scope.make()
yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void))
const context = yield* buildExecution(scope, ({ sessionID }) =>
Effect.sync(() => {
started.push(sessionID)
if (started.length === 4) Deferred.doneUnsafe(fourStarted, Effect.void)
}).pipe(Effect.andThen(Effect.never)),
)
const execution = Context.get(context, SessionExecution.Service)
const restart = Context.get(context, SessionRestart.Service)
yield* restart.resumeSuspendedSessions.pipe(Effect.forkIn(scope))
yield* Deferred.await(fourStarted)
expect([...(yield* execution.active)].toSorted()).toEqual(sessionIDs.toSorted())
}),
)
it.effect("resumes each suspended Session at most once", () =>
Effect.gen(function* () {
const database = yield* Database.Service
@@ -115,9 +140,11 @@ describe("SessionExecution lifecycle", () => {
const drained: string[] = []
const scope = yield* Scope.make()
const context = yield* buildExecution(scope, ({ sessionID }) => Effect.sync(() => void drained.push(sessionID)))
const execution = Context.get(context, SessionExecution.Service)
const restart = Context.get(context, SessionRestart.Service)
yield* restart.resumeSuspendedSessions
yield* Effect.forEach([first, second], execution.awaitIdle, { discard: true })
expect(drained.toSorted()).toEqual([first, second])
expect(yield* suspensions(database)).toEqual({ [first]: false, [second]: false })
@@ -767,4 +767,35 @@ Recent work
},
])
})
test("preserves assistant text provider state across same-provider model changes and failures", () => {
const messages = toLLMMessages(
[
SessionMessage.Assistant.make({
id: id("assistant-phase"),
type: "assistant",
agent: build,
model: { id: ModelV2.ID.make("old"), providerID: ProviderV2.ID.make("provider") },
content: [
SessionMessage.AssistantText.make({
type: "text",
text: "Checking.",
state: { phase: "commentary" },
}),
],
error: { type: "provider.unknown", message: "Interrupted after commentary" },
time: { created, completed: created },
}),
],
ModelV2.Ref.make({ id: ModelV2.ID.make("new"), providerID: ProviderV2.ID.make("provider") }),
)
expect(messages[0]?.content).toEqual([
{
type: "text",
text: "Checking.",
providerMetadata: { provider: { phase: "commentary" } },
},
])
})
})
@@ -186,6 +186,7 @@ describe("SessionRunnerLLM recorded", () => {
yield* agents.transform((draft) =>
draft.update(AgentV2.ID.make("build"), (agent) => {
agent.mode = "primary"
agent.permissions.push({ action: "execute", resource: "*", effect: "deny" })
}),
)
const pluginHost = host({
@@ -259,7 +259,7 @@ test("step finish records settlement without publishing step ended", async () =>
await Effect.runPromise(publisher.publish(LLMEvent.stepFinish({ index: 0, reason: { normalized: "stop" } })))
expect(published.some((event) => event.type === "step.ended.2")).toBe(false)
expect(publisher.stepSettlement()).toMatchObject({ finish: "stop" })
expect(publisher.record().finish).toMatchObject({ finish: "stop" })
})
test("content-filter finish retains failure evidence until step closeout", async () => {
@@ -280,7 +280,7 @@ test("content-filter finish retains failure evidence until step closeout", async
)
expect(published.map((event) => event.type)).toEqual(["session.step.started.1"])
const settlement = publisher.stepSettlement()
const settlement = publisher.record().finish
expect(settlement).toMatchObject({
finish: "content-filter",
tokens: { input: 8, output: 2, reasoning: 1 },
@@ -88,7 +88,7 @@ describe("ToolRegistry", () => {
expect(error).toBeInstanceOf(Tool.RegistrationError)
expect(error.message).toBe('Invalid tool namespace: "slack..admin"')
expect((yield* service.snapshot()).definitions).toEqual([])
expect((yield* service.snapshot()).definitions.map((tool) => tool.name)).toEqual(["execute"])
}),
)
@@ -102,7 +102,7 @@ describe("ToolRegistry", () => {
.register({ "echo.tool": make(), echo_tool: make() }, { codemode: false })
.pipe(Effect.flip)
expect(collision.message).toBe("Duplicate normalized tool name: echo_tool")
expect((yield* service.snapshot()).definitions).toEqual([])
expect((yield* service.snapshot()).definitions.map((tool) => tool.name)).toEqual(["execute"])
}),
)
@@ -117,7 +117,7 @@ describe("ToolRegistry", () => {
.pipe(Effect.flip)
expect(error).toBeInstanceOf(Tool.RegistrationError)
expect((yield* service.snapshot()).definitions).toEqual([])
expect((yield* service.snapshot()).definitions.map((tool) => tool.name)).toEqual(["execute"])
}),
)
@@ -148,6 +148,20 @@ describe("ToolRegistry", () => {
}),
)
it.effect("keeps execute available without Code Mode tools unless explicitly denied", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
const available = yield* service.snapshot()
expect(available.definitions.map((tool) => tool.name)).toEqual(["execute"])
expect(available.codeModeCatalog).toEqual([])
const denied = yield* service.snapshot([{ action: "execute", resource: "*", effect: "deny" }])
expect(denied.definitions).toEqual([])
expect(denied.codeModeCatalog).toBeUndefined()
}),
)
it.effect("filters disabled tools with edit aliases and ordered wildcard precedence", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
@@ -156,7 +170,12 @@ describe("ToolRegistry", () => {
const names = (permissions: PermissionV2.Ruleset) =>
toolDefinitions(service, permissions).pipe(Effect.map((definitions) => definitions.map((tool) => tool.name)))
expect(yield* names([{ action: "question", resource: "*", effect: "deny" }])).toEqual(["bash", "edit", "write"])
expect(yield* names([{ action: "question", resource: "*", effect: "deny" }])).toEqual([
"bash",
"edit",
"write",
"execute",
])
expect(
yield* names([
{ action: "*", resource: "*", effect: "deny" },
@@ -169,7 +188,11 @@ describe("ToolRegistry", () => {
{ action: "*", resource: "*", effect: "deny" },
]),
).toEqual([])
expect(yield* names([{ action: "edit", resource: "*", effect: "deny" }])).toEqual(["bash", "question"])
expect(yield* names([{ action: "edit", resource: "*", effect: "deny" }])).toEqual([
"bash",
"question",
"execute",
])
}),
)
@@ -182,7 +205,7 @@ describe("ToolRegistry", () => {
expect(
(yield* toolDefinitions(service, [{ action: "edit", resource: "*", effect: "deny" }])).map((tool) => tool.name),
).toEqual(["first"])
).toEqual(["first", "execute"])
}),
)
@@ -191,9 +214,9 @@ describe("ToolRegistry", () => {
const service = yield* ToolRegistry.Service
const scope = yield* Scope.make()
yield* service.register({ echo: make() }, { codemode: false }).pipe(Scope.provide(scope))
expect((yield* toolDefinitions(service)).map((tool) => tool.name)).toEqual(["echo"])
expect((yield* toolDefinitions(service)).map((tool) => tool.name)).toEqual(["echo", "execute"])
yield* Scope.close(scope, Exit.void)
expect(yield* toolDefinitions(service)).toEqual([])
expect((yield* toolDefinitions(service)).map((tool) => tool.name)).toEqual(["execute"])
}),
)
@@ -213,9 +236,9 @@ describe("ToolRegistry", () => {
yield* Deferred.await(registered)
yield* Fiber.interrupt(fiber)
expect((yield* toolDefinitions(service)).map((tool) => tool.name)).toEqual(["echo"])
expect((yield* toolDefinitions(service)).map((tool) => tool.name)).toEqual(["echo", "execute"])
yield* Scope.close(scope, Exit.void)
expect(yield* toolDefinitions(service)).toEqual([])
expect((yield* toolDefinitions(service)).map((tool) => tool.name)).toEqual(["execute"])
}),
)
@@ -515,7 +538,7 @@ describe("ToolRegistry", () => {
}),
)
it.effect("executes codemode tools advertised in a model request", () =>
it.effect("executes and reports progress for codemode tools advertised in a model request", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
const executed: string[] = []
@@ -526,8 +549,11 @@ describe("ToolRegistry", () => {
description: "Echo text",
input: Schema.Struct({ text: Schema.String }),
output: Schema.Struct({ text: Schema.String }),
execute: ({ text }) =>
Effect.sync(() => executed.push(`old:${text}`)).pipe(Effect.as({ output: { text } })),
execute: ({ text }, context) =>
Effect.sync(() => executed.push(`old:${text}`)).pipe(
Effect.andThen(context.progress({ stage: "old" })),
Effect.as({ output: { text } }),
),
}),
})
.pipe(Scope.provide(scope))
@@ -546,6 +572,7 @@ describe("ToolRegistry", () => {
}),
})
const progress: ToolRegistry.Progress[] = []
const execution = yield* toolSet.execute({
...call("execute"),
call: {
@@ -554,10 +581,15 @@ describe("ToolRegistry", () => {
name: "execute",
input: { code: 'return await tools.echo({ text: "request" })' },
},
progress: (update) => Effect.sync(() => progress.push(update)),
})
expect(execution).toMatchObject({ status: "completed", content: [{ type: "text" }] })
expect(executed).toEqual(["old:request"])
expect(progress).toEqual([
{ toolCalls: [{ tool: "echo", status: "running", input: { text: "request" } }] },
{ toolCalls: [{ tool: "echo", status: "completed", input: { text: "request" } }] },
])
}),
)
})
+83 -2
View File
@@ -880,6 +880,46 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("advertises execute and durable guidance for an empty Code Mode catalog", () =>
Effect.gen(function* () {
const session = yield* setup
const empty = {
catalog: [],
tool: Tool.make({
description: "Execute Code Mode",
input: Schema.Struct({ code: Schema.String }),
output: Schema.String,
execute: () => Effect.succeed({ output: "unused" }),
}),
}
codeModeMaterializations = [empty, empty, {}]
yield* admit(session, "Continue without Code Mode tools")
response = reply.stop()
yield* session.resume(sessionID)
yield* admit(session, "Still no Code Mode tools")
yield* session.resume(sessionID)
yield* admit(session, "Code Mode denied")
yield* session.resume(sessionID)
expect(requests).toHaveLength(3)
expect(requests[0]?.tools.map((tool) => tool.name)).toEqual(["defect", "echo", "storefail", "execute"])
expect(requests[0]?.system.some((part) => part.text.includes("Do not call `execute`"))).toBe(true)
expect(requests[1]?.tools.map((tool) => tool.name)).toEqual(["defect", "echo", "storefail", "execute"])
expect(requests[1]?.messages.filter((message) => message.role === "system")).toEqual([])
expect(requests[2]?.tools.map((tool) => tool.name)).toEqual(["defect", "echo", "storefail"])
expect(
requests[2]?.messages.some(
(message) =>
message.role === "system" &&
message.content.some(
(part) => part.type === "text" && part.text.includes("Code Mode tools are no longer available"),
),
),
).toBe(true)
}),
)
it.effect("applies session context hooks without exposing unavailable tools", () =>
Effect.gen(function* () {
const session = yield* setup
@@ -2632,6 +2672,47 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("restores durable text provider metadata in the next request", () =>
Effect.gen(function* () {
const session = yield* setup
yield* admit(session, "Check first")
response = [
LLMEvent.stepStart({ index: 0 }),
LLMEvent.textStart({ id: "commentary", providerMetadata: { openai: { phase: "commentary" } } }),
LLMEvent.textDelta({ id: "commentary", text: "Checking." }),
LLMEvent.textEnd({
id: "commentary",
providerMetadata: { openai: { phase: "commentary" }, anthropic: { ignored: true } },
}),
LLMEvent.stepFinish({ index: 0, reason: { normalized: "stop" } }),
LLMEvent.finish({ reason: { normalized: "stop" } }),
]
yield* session.resume(sessionID)
yield* replaySessionProjection(sessionID)
expect(yield* session.context(sessionID)).toMatchObject([
{ type: "user", text: "Check first" },
{
type: "assistant",
content: [{ type: "text", text: "Checking.", state: { phase: "commentary" } }],
},
])
yield* admit(session, "Continue")
response = []
yield* session.resume(sessionID)
expect(requests[1]?.messages[1]?.content).toEqual([
{
type: "text",
text: "Checking.",
providerMetadata: { openai: { phase: "commentary" } },
},
])
}),
)
it.effect("replays durable provider-executed tool results inline in the next request", () =>
Effect.gen(function* () {
const session = yield* setup
@@ -3569,7 +3650,7 @@ describe("SessionRunnerLLM", () => {
{
type: "tool",
id: "call-declined",
state: { status: "error", error: { message: "Tool execution interrupted" } },
state: { status: "error", error: { type: "aborted", message: "The user declined this tool call" } },
},
],
},
@@ -3721,7 +3802,7 @@ describe("SessionRunnerLLM", () => {
{
type: "tool",
id: "call-question",
state: { status: "error", error: { type: "aborted", message: "Tool execution interrupted" } },
state: { status: "error", error: { type: "aborted", message: "The user dismissed this question" } },
},
],
},
+6 -4
View File
@@ -137,10 +137,12 @@ describe("EditTool", () => {
Effect.andThen(
withTool(tmp.path, (registry) =>
Effect.gen(function* () {
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["edit"])
expect(yield* toolDefinitions(registry, [{ action: "edit", resource: "*", effect: "deny" }])).toEqual(
[],
)
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["edit", "execute"])
expect(
(yield* toolDefinitions(registry, [{ action: "edit", resource: "*", effect: "deny" }])).map(
(tool) => tool.name,
),
).toEqual(["execute"])
const settled = yield* executeTool(
registry,
call({ path: "hello.txt", oldString: "before", newString: "after" }),
+57 -1
View File
@@ -4,7 +4,7 @@ import { Tool } from "@opencode-ai/core/tool/tool"
import { Agent } from "@opencode-ai/schema/agent"
import { Session } from "@opencode-ai/schema/session"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import { Effect, Schema } from "effect"
import { Deferred, Effect, Fiber, Schema } from "effect"
const context = {
sessionID: Session.ID.make("ses_execute"),
@@ -14,6 +14,19 @@ const context = {
progress: () => Effect.void,
}
test("execute describes invariant Code Mode behavior", () => {
expect(ExecuteTool.create(new Map()).description).toBe(
[
"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"),
)
})
test("canonical execution distinguishes declared, model-only, and raw schema outputs", async () => {
const declared = Tool.make({
description: "Declared",
@@ -131,3 +144,46 @@ test("execute supports callable namespace tools", async () => {
})
expect(result.content).toEqual([{ type: "text", text: '[\n "admin",\n "created"\n]' }])
})
test("execute marks every admitted child call failed when interrupted", async () => {
const child = Tool.make({
description: "Wait forever",
input: Schema.Struct({ id: Schema.Number }),
output: Schema.String,
execute: () => Effect.never,
})
const execute = ExecuteTool.create(new Map([["wait", { tool: child, name: "wait", permission: "wait" }]]))
const updates: Tool.Metadata[] = []
await Effect.runPromise(
Effect.gen(function* () {
const started = yield* Deferred.make<void>()
const fiber = yield* Tool.execute(
execute,
{ code: "return await Promise.all([tools.wait({ id: 1 }), tools.wait({ id: 2 })])" },
{
...context,
progress: (update) =>
Effect.gen(function* () {
updates.push(update)
if (updates.length > 1) return
yield* Deferred.succeed(started, undefined)
yield* Effect.never
}),
},
).pipe(Effect.forkChild)
yield* Deferred.await(started)
yield* Effect.yieldNow
yield* Effect.yieldNow
yield* Fiber.interrupt(fiber)
}),
)
expect(updates[0]).toEqual({ toolCalls: [{ tool: "wait", status: "running", input: { id: 1 } }] })
expect(updates.at(-1)).toEqual({
toolCalls: [
{ tool: "wait", status: "error", input: { id: 1 } },
{ tool: "wait", status: "error", input: { id: 2 } },
],
})
})
+1 -1
View File
@@ -181,7 +181,7 @@ describe("PatchTool", () => {
Effect.andThen(
withTool(tmp.path, (registry) =>
Effect.gen(function* () {
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["patch"])
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["patch", "execute"])
const settled = yield* executeTool(
registry,
call(
+6 -2
View File
@@ -97,7 +97,11 @@ describe("QuestionTool", () => {
deny = true
const registry = yield* ToolRegistry.Service
expect(yield* toolDefinitions(registry, [{ action: "question", resource: "*", effect: "deny" }])).toEqual([])
expect(
(yield* toolDefinitions(registry, [{ action: "question", resource: "*", effect: "deny" }])).map(
(tool) => tool.name,
),
).toEqual(["execute"])
expect(
yield* executeTool(registry, {
sessionID,
@@ -142,7 +146,7 @@ describe("QuestionTool", () => {
},
]
expect((yield* toolDefinitions(registry)).map((definition) => definition.name)).toEqual(["question"])
expect((yield* toolDefinitions(registry)).map((definition) => definition.name)).toEqual(["question", "execute"])
expect(
yield* executeTool(registry, {
sessionID,
+6 -2
View File
@@ -197,8 +197,12 @@ describe("ReadTool", () => {
Effect.gen(function* () {
const registry = yield* ToolRegistry.Service
expect(yield* toolDefinitions(registry)).toMatchObject([{ name: "read" }])
expect(yield* toolDefinitions(registry, [{ action: "read", resource: "*", effect: "deny" }])).toEqual([])
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["read", "execute"])
expect(
(yield* toolDefinitions(registry, [{ action: "read", resource: "*", effect: "deny" }])).map(
(tool) => tool.name,
),
).toEqual(["execute"])
const execution = yield* executeTool(registry, {
sessionID,
...toolIdentity,
+121 -14
View File
@@ -8,6 +8,7 @@ import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { FileSystem } from "@opencode-ai/core/filesystem"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Location } from "@opencode-ai/core/location"
import { LocationMutation } from "@opencode-ai/core/location-mutation"
import { PermissionV2 } from "@opencode-ai/core/permission"
import { Ripgrep } from "@opencode-ai/core/ripgrep"
import { AbsolutePath } from "@opencode-ai/core/schema"
@@ -24,27 +25,27 @@ import { executeTool, registerToolPlugin, toolIdentity } from "./lib/tool"
const globToolNode = makeLocationNode({
name: "test/glob-tool-plugin",
layer: Layer.effectDiscard(registerToolPlugin(GlobTool.Plugin)),
deps: [ToolRegistry.toolsNode, FSUtil.node, Ripgrep.node, Location.node, PermissionV2.node],
deps: [
ToolRegistry.toolsNode,
FSUtil.node,
Ripgrep.node,
Location.node,
LocationMutation.node,
PermissionV2.node,
],
})
const grepToolNode = makeLocationNode({
name: "test/grep-tool-plugin",
layer: Layer.effectDiscard(registerToolPlugin(GrepTool.Plugin)),
deps: [ToolRegistry.toolsNode, FSUtil.node, Ripgrep.node, Location.node, PermissionV2.node],
})
const permission = Layer.succeed(
PermissionV2.Service,
PermissionV2.Service.of({
assert: () => Effect.void,
ask: () => Effect.die("unused"),
reply: () => Effect.die("unused"),
get: () => Effect.die("unused"),
forSession: () => Effect.die("unused"),
list: () => Effect.die("unused"),
}),
)
const sessionID = SessionV2.ID.make("ses_search_tool_test")
const withTools = <A, E, R>(directory: string, body: (registry: ToolRegistry.Interface) => Effect.Effect<A, E, R>) =>
const withTools = <A, E, R>(
directory: string,
body: (registry: ToolRegistry.Interface) => Effect.Effect<A, E, R>,
assertions?: PermissionV2.AssertInput[],
) =>
Effect.gen(function* () {
return yield* body(yield* ToolRegistry.Service)
}).pipe(
@@ -54,7 +55,23 @@ const withTools = <A, E, R>(directory: string, body: (registry: ToolRegistry.Int
Location.node,
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
],
[PermissionV2.node, permission],
[
PermissionV2.node,
Layer.succeed(
PermissionV2.Service,
PermissionV2.Service.of({
assert: (input) =>
Effect.sync(() => {
assertions?.push(input)
}),
ask: () => Effect.die("unused"),
reply: () => Effect.die("unused"),
get: () => Effect.die("unused"),
forSession: () => Effect.die("unused"),
list: () => Effect.die("unused"),
}),
),
],
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
]),
),
@@ -125,4 +142,94 @@ describe("search tools", () => {
),
)
}
it.live("reports a file used as the glob search path", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) =>
Effect.promise(() => fs.writeFile(path.join(tmp.path, "file.txt"), "content\n")).pipe(
Effect.andThen(
withTools(tmp.path, (registry) =>
executeTool(registry, call("glob", { path: "file.txt", pattern: "*" })),
),
),
Effect.tap((result) =>
Effect.sync(() => {
expect(result).toEqual({
status: "error",
error: { type: "tool.execution", message: "Search path is not a directory: file.txt" },
})
}),
),
),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
)
it.live("requires external_directory approval for an explicit external glob path", () =>
Effect.acquireUseRelease(
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
([active, outside]) => {
const assertions: PermissionV2.AssertInput[] = []
return Effect.promise(() => fs.writeFile(path.join(outside.path, "outside.txt"), "outside\n")).pipe(
Effect.andThen(
withTools(
active.path,
(registry) => executeTool(registry, call("glob", { path: outside.path, pattern: "*.txt" })),
assertions,
),
),
Effect.tap((result) =>
Effect.sync(() => {
expect(result.status).toBe("completed")
expect(assertions.map((input) => input.action)).toEqual(["external_directory", "glob"])
expect(assertions[0]?.resources).toEqual([
path.join(outside.path, "*").replaceAll("\\", "/"),
])
}),
),
)
},
([active, outside]) =>
Effect.promise(() =>
Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined),
),
),
)
it.live("globs through an in-location external symlink without external approval", () =>
Effect.acquireUseRelease(
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
([active, outside]) => {
if (process.platform === "win32") return Effect.void
const assertions: PermissionV2.AssertInput[] = []
return Effect.promise(async () => {
await fs.writeFile(path.join(outside.path, "outside.txt"), "outside\n")
await fs.symlink(outside.path, path.join(active.path, "linked"))
}).pipe(
Effect.andThen(
withTools(
active.path,
(registry) => executeTool(registry, call("glob", { path: "linked", pattern: "*.txt" })),
assertions,
),
),
Effect.tap((result) =>
Effect.sync(() => {
expect(result.status).toBe("completed")
expect(assertions.map((input) => input.action)).toEqual(["glob"])
expect(result).toMatchObject({
output: [{ path: path.join("linked", "outside.txt"), type: "file" }],
content: [{ type: "text", text: path.join(active.path, "linked", "outside.txt") }],
})
}),
),
)
},
([active, outside]) =>
Effect.promise(() =>
Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined),
),
),
)
})
+1 -1
View File
@@ -92,7 +92,7 @@ describe("WebFetchTool registration", () => {
const registry = yield* ToolRegistry.Service
const url = "http://example.com/public"
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["webfetch"])
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["webfetch", "execute"])
expect(yield* executeTool(registry, call({ url, format: "text", timeout: 4 }))).toEqual({
status: "completed",
output: { url, contentType: "text/plain", format: "text", output: "hello" },
+1 -1
View File
@@ -154,7 +154,7 @@ describe("WebSearchTool registration", () => {
config = { provider: "exa", enableExa: false, enableParallel: false }
const registry = yield* ToolRegistry.Service
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["websearch"])
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["websearch", "execute"])
expect(
yield* executeTool(registry, {
sessionID,
+1 -1
View File
@@ -118,7 +118,7 @@ describe("WriteTool", () => {
reset()
return withTool(tmp.path, (registry) =>
Effect.gen(function* () {
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["write"])
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["write", "execute"])
const settled = yield* executeTool(registry, call({ path: "src/new.txt", content: "created" }))
expect(settled).toEqual({
status: "completed",
@@ -0,0 +1,22 @@
import { describe, expect, test } from "bun:test"
import { isTrustedNavigationUrl } from "./navigation-policy"
describe("isTrustedNavigationUrl", () => {
test("allows packaged renderer pages", () => {
expect(isTrustedNavigationUrl("oc://renderer/index.html", undefined)).toBe(true)
expect(isTrustedNavigationUrl("oc://renderer/assets/index.js", undefined)).toBe(true)
})
test("rejects other hosts and protocols", () => {
expect(isTrustedNavigationUrl("oc://attacker/index.html", undefined)).toBe(false)
expect(isTrustedNavigationUrl("https://example.com", undefined)).toBe(false)
expect(isTrustedNavigationUrl("not a url", undefined)).toBe(false)
})
test("allows only the configured development origin", () => {
const devUrl = "http://localhost:5173"
expect(isTrustedNavigationUrl("http://localhost:5173/index.html", devUrl)).toBe(true)
expect(isTrustedNavigationUrl("http://localhost:5174/index.html", devUrl)).toBe(false)
expect(isTrustedNavigationUrl("https://localhost:5173/index.html", devUrl)).toBe(false)
})
})
@@ -0,0 +1,10 @@
const rendererProtocol = "oc:"
const rendererHost = "renderer"
export function isTrustedNavigationUrl(value: string, devUrl = process.env.ELECTRON_RENDERER_URL) {
if (!URL.canParse(value)) return false
const url = new URL(value)
if (url.protocol === rendererProtocol && url.host === rendererHost) return true
if (!devUrl || !URL.canParse(devUrl)) return false
return url.origin === new URL(devUrl).origin
}
+14
View File
@@ -9,6 +9,7 @@ import { dirname, isAbsolute, join, relative, resolve } from "node:path"
import { fileURLToPath, pathToFileURL } from "node:url"
import type { TitlebarTheme } from "../preload/types"
import { exportDebugLogs, write as writeLog } from "./logging"
import { isTrustedNavigationUrl } from "./navigation-policy"
import { getStore, removeStoreFile } from "./store"
import { PINCH_ZOOM_ENABLED_KEY, WINDOW_IDS_KEY } from "./store-keys"
import { createUnresponsiveSampler } from "./unresponsive"
@@ -203,6 +204,7 @@ export function createMainWindow(id: string = randomUUID()) {
})
allowRendererPermissions(win)
restrictNavigation(win)
wireWindowRecovery(win, id)
win.webContents.session.webRequest.onBeforeSendHeaders((details, callback) => {
@@ -229,6 +231,18 @@ export function createMainWindow(id: string = randomUUID()) {
return win
}
function restrictNavigation(win: BrowserWindow) {
win.webContents.on("will-navigate", (event, url) => {
if (isTrustedNavigationUrl(url)) return
event.preventDefault()
writeLog("window", "blocked renderer navigation", { url }, "warn")
})
win.webContents.setWindowOpenHandler(({ url }) => {
writeLog("window", "blocked renderer window", { url }, "warn")
return { action: "deny" }
})
}
function registerWindow(win: BrowserWindow, id: string) {
windowIDs.set(win, id)
registry.register(id, win)
-22
View File
@@ -1,22 +0,0 @@
# V2 documentation guide
## Structure
- This directory is a standalone Mintlify site deployed from `packages/docs` on the `dev` branch.
- Write documentation in MDX. Every page should have `title` and `description` frontmatter.
- `docs.json` owns site configuration and navigation. Add, move, or remove its page entries whenever the corresponding MDX pages change.
- Put static files in `assets/` and reference them with root-relative paths such as `/assets/example.svg`.
- The API endpoint reference is generated by Mintlify from `openapi.json`; do not duplicate endpoint documentation as hand-written MDX.
- Keep documentation aligned with the V2 packages. Do not use `packages/opencode` as the source of truth unless the task explicitly concerns V1.
## Local development
- At the start of documentation work, launch `bun dev` from `packages/docs` using the shell tool with `background: true`. Never run the dev server in a foreground shell call and do not poll the process; wait for the background completion notification.
- Preview the site at `http://localhost:3333`. Mintlify does not expose a host option and binds the preview to all network interfaces. The server reloads changes to MDX and `docs.json` automatically.
- Use the running preview to verify navigation, links, Mintlify components, code blocks, and desktop and mobile layout.
## Validation
- Run `bun validate` from `packages/docs` after making documentation or configuration changes.
- Run `bun broken-links` from `packages/docs` when pages, navigation, headings, or links change.
- Treat validation errors and broken internal links as blockers. Also verify external links relevant to the change when practical.
-33
View File
@@ -1,33 +0,0 @@
# OpenCode documentation
The V2 documentation is a Mintlify site deployed from `packages/docs` on the `dev` branch.
## Local preview
From this directory, run:
```bash
bun dev
```
The preview opens at `http://localhost:3333` and reloads when MDX or `docs.json` changes.
Validate changes before opening a pull request:
```bash
bun validate
bun broken-links
```
The V2 theme token reference is generated from
`packages/tui/src/theme/v2/schema.ts`. Regenerate it after schema changes:
```bash
bun run generate
```
`bun validate` checks that the committed snippet is current. The repository's
generation workflow also refreshes it on pushes to `dev`, so Mintlify always
receives the generated MDX as part of the published docs tree.
The hosted preview is available at [opencode.mintlify.site](https://opencode.mintlify.site).
-6
View File
@@ -1,6 +0,0 @@
---
title: "API Reference"
description: "OpenCode HTTP API."
---
The endpoint reference is generated from the current OpenCode V2 [OpenAPI specification](/openapi.json).
-5
View File
@@ -1,5 +0,0 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18 18H6V6H18V18Z" fill="#F5F5F5"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M3 3H21V21H3V3ZM8 8V16H16V8H8Z" fill="#3B7DD8"/>
<path d="M16 8H20V12H16V8Z" fill="#FAB283"/>
</svg>

Before

Width:  |  Height:  |  Size: 297 B

-10
View File
@@ -1,10 +0,0 @@
<svg width="234" height="42" viewBox="0 0 234 42" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18 30H6V18H18V30Z" fill="#4B4646"/><path d="M18 12H6V30H18V12ZM24 36H0V6H24V36Z" fill="#B7B1B1"/>
<path d="M48 30H36V18H48V30Z" fill="#4B4646"/><path d="M36 30H48V12H36V30ZM54 36H36V42H30V6H54V36Z" fill="#B7B1B1"/>
<path d="M84 24V30H66V24H84Z" fill="#4B4646"/><path d="M84 24H66V30H84V36H60V6H84V24ZM66 18H78V12H66V18Z" fill="#B7B1B1"/>
<path d="M108 36H96V18H108V36Z" fill="#4B4646"/><path d="M108 12H96V36H90V6H108V12ZM114 36H108V12H114V36Z" fill="#B7B1B1"/>
<path d="M144 30H126V18H144V30Z" fill="#4B4646"/><path d="M144 12H126V30H144V36H120V6H144V12Z" fill="#F1ECEC"/>
<path d="M168 30H156V18H168V30Z" fill="#4B4646"/><path d="M168 12H156V30H168V12ZM174 36H150V6H174V36Z" fill="#F1ECEC"/>
<path d="M198 30H186V18H198V30Z" fill="#4B4646"/><path d="M198 12H186V30H198V12ZM204 36H180V6H198V0H204V36Z" fill="#F1ECEC"/>
<path d="M234 24V30H216V24H234Z" fill="#4B4646"/><path d="M216 12V18H228V12H216ZM234 24H216V30H234V36H210V6H234V24Z" fill="#F1ECEC"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

-10
View File
@@ -1,10 +0,0 @@
<svg width="234" height="42" viewBox="0 0 234 42" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18 30H6V18H18V30Z" fill="#CFCECD"/><path d="M18 12H6V30H18V12ZM24 36H0V6H24V36Z" fill="#656363"/>
<path d="M48 30H36V18H48V30Z" fill="#CFCECD"/><path d="M36 30H48V12H36V30ZM54 36H36V42H30V6H54V36Z" fill="#656363"/>
<path d="M84 24V30H66V24H84Z" fill="#CFCECD"/><path d="M84 24H66V30H84V36H60V6H84V24ZM66 18H78V12H66V18Z" fill="#656363"/>
<path d="M108 36H96V18H108V36Z" fill="#CFCECD"/><path d="M108 12H96V36H90V6H108V12ZM114 36H108V12H114V36Z" fill="#656363"/>
<path d="M144 30H126V18H144V30Z" fill="#CFCECD"/><path d="M144 12H126V30H144V36H120V6H144V12Z" fill="#211E1E"/>
<path d="M168 30H156V18H168V30Z" fill="#CFCECD"/><path d="M168 12H156V30H168V12ZM174 36H150V6H174V36Z" fill="#211E1E"/>
<path d="M198 30H186V18H198V30Z" fill="#CFCECD"/><path d="M198 12H186V30H198V12ZM204 36H180V6H198V0H204V36Z" fill="#211E1E"/>
<path d="M234 24V30H216V24H234Z" fill="#CFCECD"/><path d="M216 12V18H228V12H216ZM234 24H216V30H234V36H210V6H234V24Z" fill="#211E1E"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

-183
View File
@@ -1,183 +0,0 @@
---
title: "Client"
description: "Connect an application to the OpenCode HTTP API."
---
`@opencode-ai/client` is the generated TypeScript client for the OpenCode HTTP
API. Use it when your application connects to an OpenCode server over the
network. Its types and methods are generated from the same contract as the
[API reference](/api).
<Warning>
The V2 API and client are beta. Method names, inputs, and outputs may change
before the stable release.
</Warning>
## Install
```sh
bun add @opencode-ai/client@next
```
## Create a client
Create a client with the server URL, then call methods grouped by API resource:
```ts
import { OpenCode } from "@opencode-ai/client"
const client = OpenCode.make({
baseUrl: "http://localhost:4096",
})
const session = await client.session.create({
location: { directory: "/workspace" },
})
await client.session.prompt({
sessionID: session.id,
text: "Review the current changes",
})
```
## Headers and requests
Pass default authentication or application headers to `OpenCode.make` with
`headers`. You can also supply a custom `fetch` implementation. Each operation
accepts request options as its final argument for an `AbortSignal` or
per-request headers.
```ts
const client = OpenCode.make({
baseUrl: "https://opencode.example.com",
headers: {
authorization: `Bearer ${process.env.OPENCODE_TOKEN}`,
},
})
await client.session.list(undefined, {
signal: AbortSignal.timeout(10_000),
})
```
## Stream events
Streaming endpoints return async iterables:
```ts
for await (const event of client.event.subscribe()) {
console.log(event.type)
}
```
## Local background service
The main client entrypoints are browser-compatible and do not include local
process management. In a Node application, import the native Promise service
API from `@opencode-ai/client/service`.
- `Service.discover()` returns a healthy registered endpoint without starting
a process.
- `Service.ensure()` returns a compatible service, starting one when needed.
- `Service.stop()` stops the exact registered service instance.
- `Service.headers(endpoint)` creates the authentication headers for a client.
```ts
import { OpenCode } from "@opencode-ai/client"
import { Service } from "@opencode-ai/client/service"
const endpoint = await Service.ensure()
const client = OpenCode.make({
baseUrl: endpoint.url,
headers: Service.headers(endpoint),
})
const health = await client.health.get()
```
`Service.ensure()` accepts an optional registration file, required version,
service command, and `onStart` callback:
```ts
const endpoint = await Service.ensure({
file: "/var/run/opencode/service.json",
version: "2.0.0",
command: ["opencode", "serve", "--service"],
onStart(reason, previousVersion) {
console.log(reason, previousVersion)
},
})
```
Omit these options to use the standard registration path and
`opencode serve --service` command.
## Effect
OpenCode provides a first-class Effect client through the
`@opencode-ai/client/effect` entrypoint. It returns typed Effects and Streams
and decodes responses into OpenCode schema values.
```sh
bun add @opencode-ai/client@next effect
```
### Create a client
```ts
import { AbsolutePath, Location, OpenCode } from "@opencode-ai/client/effect"
import { Effect } from "effect"
import { FetchHttpClient } from "effect/unstable/http"
const program = Effect.gen(function* () {
const client = yield* OpenCode.make({ baseUrl: "http://localhost:4096" })
const session = yield* client.session.create({
location: Location.Ref.make({
directory: AbsolutePath.make("/workspace"),
}),
})
return yield* client.session.get({ sessionID: session.id })
})
const session = await Effect.runPromise(
program.pipe(Effect.provide(FetchHttpClient.layer)),
)
```
Streaming operations, including `client.event.subscribe()` and
`client.session.log(...)`, return Effect `Stream` values.
### Local background service
The Node-only `@opencode-ai/client/effect/service` entrypoint exposes the same
operations as Effect values. Add `@effect/platform-node` and provide its
filesystem layer when running them.
```sh
bun add @effect/platform-node
```
```ts
import { NodeFileSystem } from "@effect/platform-node"
import { OpenCode } from "@opencode-ai/client/effect"
import { Service } from "@opencode-ai/client/effect/service"
import { Effect } from "effect"
import { FetchHttpClient } from "effect/unstable/http"
const program = Effect.gen(function* () {
const endpoint = yield* Service.ensure()
const client = yield* OpenCode.make({
baseUrl: endpoint.url,
headers: Service.headers(endpoint),
})
return yield* client.health.get()
})
const health = await Effect.runPromise(
program.pipe(
Effect.provide(FetchHttpClient.layer),
Effect.provide(NodeFileSystem.layer),
),
)
```
-24
View File
@@ -1,24 +0,0 @@
---
title: "Build"
description: "Build on the engine used by millions daily."
mode: "wide"
---
<CardGroup cols={1}>
<Card title="Extend OpenCode" href="/build/plugins">
Build plugins that add tools, integrations, commands, agents, and custom behavior while keeping the rest of OpenCode
intact.
</Card>
<Card title="Run it as a server" href="/build/client">
Connect to OpenCode with the same client used by the TUI and desktop app, then build any interface, workflow, or agent
experience around it.
</Card>
<Card title="Embed it" href="/build/sdk">
Embed OpenCode directly into your application and build a completely custom agent, interface, or developer product
around it.
</Card>
</CardGroup>
<Warning>
The plugin API, client, and SDK are still being finalized during beta and may change before OpenCode 2.0 is stable.
</Warning>
-446
View File
@@ -1,446 +0,0 @@
---
title: "Plugins"
description: "Extend OpenCode with plugins."
---
Plugins extend OpenCode in-process. They can transform agents, models, commands,
integrations, references, skills, and tools; intercept model requests and tool
execution; and call a subset of the V2 client.
<Warning>
The V2 plugin API is beta. Entrypoints, hooks, draft shapes, and configuration may change before the stable release.
Use the `/v2` exports described on this page.
</Warning>
## Load plugins
Plugins can be loaded from npm packages, explicit local paths, or config
directories. Each module must have one default export containing a unique
plugin `id` and a `setup` function.
### Configuration
Add ordered entries to the `plugins` field in `opencode.json(c)`:
```jsonc title="opencode.jsonc"
{
"$schema": "https://opencode.ai/config.json",
"plugins": [
"opencode-acme-plugin@1.2.0",
"@acme/opencode-plugin",
"./plugins/local.ts",
{
"package": "./plugins/reviewer.ts",
"options": {
"agent": "reviewer",
"strict": true,
},
},
],
}
```
A string is either a package specifier or a local path. Local paths must start
with `./` or `../` and resolve relative to the configuration file containing
the entry. Absolute paths and `file://` URLs are also supported. Both scoped
packages and versioned package specifiers are supported.
Use the object form to pass JSON configuration to the plugin. OpenCode passes
`options` unchanged as `ctx.options`; omitted options become an empty object.
The plugin owns validation and defaults for its options.
See [Config](/config#locations) for configuration locations and precedence.
Entries from all applicable files are processed from lowest to highest
precedence rather than replacing the entire array.
### Local discovery
OpenCode automatically scans this directory in every discovered OpenCode config
directory:
```text
.opencode/plugins/
```
The equivalent global directory is `~/.config/opencode/plugins/`. Direct `.ts`
and `.js` children are loaded. An immediate child directory is also loaded as a
package when OpenCode can resolve a string `exports`, `module`, or `main`
entrypoint, or an `index.ts` or `index.js` file.
A `plugins/` directory beside a project-root `opencode.json` is not discovered
automatically. Put it under `.opencode/`, or add its file explicitly with a
relative config entry.
### Enable and disable
A string beginning with `-` disables plugins by their exported `id`. `*`
matches every ID, and a suffix of `.*` matches an ID prefix. Directives are
applied in order:
```jsonc title="opencode.jsonc"
{
"plugins": ["./plugins/reviewer.ts", "-acme.reviewer", "-opencode.provider.*", "opencode.provider.openai"],
}
```
Package specifiers and local paths locate plugin modules; they are not disable
selectors. Use the `id` from the plugin's default export to disable it. A later
ID entry re-enables a loaded or built-in plugin. Explicit config directives run
after local auto-discovery, so they can disable discovered plugins by ID.
User plugins are activated in configured order between OpenCode's internal
plugin phases. Hooks run sequentially in registration order, and later hooks
observe earlier mutations. Do not depend on the internal phase ordering while
the API is beta.
### Installation and dependencies
OpenCode installs bare package entries and their production dependencies into
an isolated cache. Package installation does not run lifecycle scripts.
Published packages should expose their plugin entrypoint and include every
runtime import in `dependencies`.
Local files and local package directories are imported directly. OpenCode does
**not** install their dependencies. Install dependencies in a `package.json`
visible from the plugin file, for example:
```sh
cd .opencode
bun add @opencode-ai/plugin@next
```
Match the plugin package version to the OpenCode release you target.
Configuration and discovered plugin files under watched config directories are
reloaded when they change. Reloading replaces the active plugin generation and
releases its scoped registrations. Restart OpenCode after changing an npm
package version or a local dependency when no watched file changed.
## Create a plugin
Export the result of `Plugin.define` as the module default:
```ts title=".opencode/plugins/reviewer.ts"
import { Plugin } from "@opencode-ai/plugin/v2"
export default Plugin.define({
id: "acme.reviewer",
setup: async (ctx) => {
const description =
typeof ctx.options.description === "string" ? ctx.options.description : "Reviews code for regressions"
await ctx.agent.transform((agents) => {
agents.update("reviewer", (agent) => {
agent.description = description
agent.mode = "subagent"
})
})
},
})
```
`setup` runs each time the plugin is activated. Register long-lived behavior
during setup; do not wait there on an infinite event stream. It may return a
synchronous or asynchronous cleanup function. OpenCode awaits that cleanup
when the plugin is disabled, reloaded, or shut down:
```ts
setup: async (ctx) => {
const controller = new AbortController()
const task = synchronize(ctx, controller.signal)
return async () => {
controller.abort()
await task
}
}
```
Hook registrations are released automatically with the same plugin scope. Use
the returned cleanup for resources the plugin owns, such as timers, watchers,
connections, and background tasks.
### Context
The plugin context is essentially an [OpenCode server client](/build/client).
Its read and action methods use the same inputs and responses as the client. It
adds plugin-only methods for transforms, runtime hooks, reloads, registrations,
and plugin options.
| Capability | Available operations |
| ---------------------- | -------------------------------------------------------------------------------------------- |
| `ctx.agent` | `list`, `get`, `transform`, `reload` |
| `ctx.catalog.provider` | `list`, `get` |
| `ctx.catalog.model` | `list`, `get`, `default` |
| `ctx.catalog` | `transform`, `reload` |
| `ctx.command` | `list`, `transform`, `reload` |
| `ctx.integration` | `list`, `get`, `connect`, `attempt`, `transform`, `reload`, and connection lookup/resolution |
| `ctx.plugin` | `list` currently active plugin IDs |
| `ctx.reference` | `list`, `transform`, `reload` |
| `ctx.session` | `create`, `get`, `prompt`, `command`, `synthetic`, `interrupt`, and `hook` |
| `ctx.skill` | `list`, `transform`, `reload` |
| `ctx.tool` | `transform` and `hook` |
| `ctx.aisdk` | `hook` |
| `ctx.event` | `subscribe` to the current public server event stream |
| `ctx.options` | Readonly options from the matching config object |
### Transform hooks
Transform hooks let a plugin modify how OpenCode is configured. Use them to add
or remove definitions, override settings, choose defaults, and provide tools or
other sources.
| Transform | Draft operations |
| ----------------------- | ------------------------------------------------------------------------------------------------------- |
| `agent.transform` | `list`, `get`, `default`, `update`, `remove` |
| `catalog.transform` | Provider `list`, `get`, `update`, `remove`; model `get`, `update`, `remove`; default model `get`, `set` |
| `command.transform` | `list`, `get`, `update`, `remove` |
| `integration.transform` | Integration `list`, `get`, `update`, `remove`; method `list`, `update`, `remove` |
| `reference.transform` | `add`, `remove`, `list` |
| `skill.transform` | `source`, `list` |
| `tool.transform` | `add` |
Here's an example that keeps models synced from a remote source:
```js title=".opencode/plugins/remote-models.js"
import { Plugin } from "@opencode-ai/plugin/v2"
export default Plugin.define({
id: "acme.remote-models",
setup: async (ctx) => {
let models = []
await ctx.catalog.transform((catalog) => {
for (const model of models) {
catalog.model.update(model.providerID, model.id, (draft) => Object.assign(draft, model))
}
})
const refresh = async () => {
const response = await fetch("https://example.com/opencode/models.json", {
signal: AbortSignal.timeout(10_000),
})
if (!response.ok) return
models = await response.json()
await ctx.catalog.reload()
}
await refresh()
const timer = setInterval(() => void refresh().catch(console.error), 60_000)
return () => clearInterval(timer)
},
})
```
`ctx.catalog.reload()` replays every catalog transform to derive the new
catalog. Each plugin's logic remains composed with the others, so a later
plugin can still modify models added by an earlier one. The catalog updates
without restarting OpenCode.
### Runtime hooks
Runtime hooks intercept live operations. Their event objects expose specific
mutable fields:
| Hook | Mutable fields |
| ------------------------------------------- | ------------------------------------------------------------------------------ |
| `ctx.aisdk.hook("sdk", callback)` | `sdk`, after inspecting `model`, `package`, and `options` |
| `ctx.aisdk.hook("language", callback)` | `language`, after inspecting `model`, `sdk`, and `options` |
| `ctx.session.hook("request", callback)` | `system`, `messages`, and the `tools` record immediately before model dispatch |
| `ctx.tool.hook("execute.before", callback)` | `input`, before the selected tool executes |
| `ctx.tool.hook("execute.after", callback)` | Terminal `content`, `metadata`, and `outputPaths`; `error` on failure |
For example, remove a tool from selected model requests and normalize another
tool's input:
```ts title=".opencode/plugins/guards.ts"
import { Plugin } from "@opencode-ai/plugin/v2"
export default Plugin.define({
id: "acme.guards",
setup: async (ctx) => {
await ctx.session.hook("request", (event) => {
delete event.tools.write
})
await ctx.tool.hook("execute.before", (event) => {
if (event.tool !== "lookup" || typeof event.input !== "object" || event.input === null) return
event.input = { ...event.input, source: "plugin" }
})
},
})
```
A hook failure fails the operation it intercepts. Keep runtime hooks fast and
handle expected errors inside the callback.
## Examples
### Add a tool
Create an executable tool with `Tool.make`, then register it with a name
and registration options. Define its input with JSON Schema and use an async
executor:
```js title=".opencode/plugins/greeting.js"
import { Plugin } from "@opencode-ai/plugin/v2"
import { Tool } from "@opencode-ai/plugin/v2/tool"
export default Plugin.define({
id: "acme.greeting",
setup: async (ctx) => {
await ctx.tool.transform((tools) => {
tools.add(
"greeting",
Tool.make({
description: "Create a greeting",
input: {
type: "object",
properties: {
name: { type: "string" },
},
required: ["name"],
additionalProperties: false,
},
output: {
type: "object",
properties: { greeting: { type: "string" } },
required: ["greeting"],
additionalProperties: false,
},
execute: async ({ name }) => {
const text = `Hello, ${name}!`
return {
output: { greeting: text },
content: text,
}
},
}),
)
})
},
})
```
Unsupported characters in tool names are normalized to underscores. Namespace
segments must begin with a letter, contain at most 64 letters, digits,
underscores, or hyphens, and are joined with dots. Pass the optional third
argument to `tools.add` to configure the registration with
`{ namespace, codemode }`:
- `namespace` prefixes and groups the exposed tool name.
- `codemode` defaults to `true` and makes the tool available through the
`execute` CodeMode tool. Set `codemode: false` to expose it directly to the
provider.
The executor receives a second context argument containing `sessionID`,
`agent`, `messageID`, `callID`, and `progress`. A tool with `output`
must return `output`; Effect and Standard Schema codecs validate it, while raw
JSON Schema definitions enforce JSON compatibility only. A tool
without `output` returns model-visible `content` instead.
### Add a command
```js title=".opencode/plugins/review-command.js"
import { Plugin } from "@opencode-ai/plugin/v2"
export default Plugin.define({
id: "acme.review-command",
setup: async (ctx) => {
await ctx.command.transform((commands) => {
commands.update("review", (command) => {
command.description = "Review the current changes"
command.template = "Review the current changes for correctness and missing tests."
})
})
},
})
```
### Set the default model
```js title=".opencode/plugins/default-model.js"
import { Plugin } from "@opencode-ai/plugin/v2"
export default Plugin.define({
id: "acme.default-model",
setup: async (ctx) => {
await ctx.catalog.transform((catalog) => {
catalog.model.default.set("anthropic", "claude-sonnet-4-5")
})
},
})
```
## Publish a package
A package plugin uses the same default export as a local plugin. A minimal
manifest is:
```json title="package.json"
{
"name": "opencode-acme-plugin",
"version": "1.0.0",
"type": "module",
"exports": "./src/index.ts",
"dependencies": {
"@opencode-ai/plugin": "next"
}
}
```
Use versions compatible with the OpenCode release you target and test the
installed package, not only a workspace-linked copy. Because the plugin API is
beta, publish compatible plugin updates when V2 entrypoints or contracts
change.
## Verify loading
List active plugin IDs through the V2 API:
```sh
opencode2 api get /api/plugin
```
If a plugin is absent, check the server log described in
[Troubleshooting](/troubleshooting#read-logs). Invalid modules and setup failures are
logged; one failing package does not prevent unrelated valid packages from
being resolved.
## Effect
OpenCode provides a first-class Effect API for plugins through the
`@opencode-ai/plugin/v2/effect` entrypoint. Install `effect` alongside the
plugin package and export an `effect` function instead of `setup`:
```sh
bun add @opencode-ai/plugin@next effect
```
```ts title=".opencode/plugins/reviewer-effect.ts"
import { Plugin } from "@opencode-ai/plugin/v2/effect"
import { Effect } from "effect"
export default Plugin.define({
id: "acme.reviewer-effect",
effect: (ctx) =>
Effect.gen(function* () {
yield* ctx.agent.transform((agents) => {
agents.update("reviewer", (agent) => {
agent.description = "Reviews code for regressions"
agent.mode = "subagent"
})
})
}),
})
```
Context operations return Effects. The plugin effect is scoped, so finalizers,
fibers, and registrations are released when the plugin reloads or unloads.
OpenCode does not expose its private Core services to the plugin; use the
capabilities on `ctx`.
Typed tools can use `Schema` from `effect` and `Tool.make` from
`@opencode-ai/plugin/v2/effect/tool`. Effect and Promise plugins use the same
`tools.add(name, tool, options?)` registration shape. Effect executors
return an Effect and may fail with the typed tool failure channel.
-84
View File
@@ -1,84 +0,0 @@
---
title: "SDK"
description: "Embed OpenCode directly in your application."
---
We're working on a general-purpose SDK for embedding OpenCode directly inside
your application. The regular SDK is coming soon.
An Effect-native version is available now for applications built with Effect.
Its current documentation is below. For other applications, run OpenCode as a
server and use the [TypeScript client](/build/client) in the meantime.
## Effect
`@opencode-ai/sdk-next` hosts OpenCode in-process. Unlike the
[network client](/build/client), it assembles the OpenCode server and routes API
calls through its HTTP router in memory. It opens no HTTP listener and adds no
network hop between the client and server.
<Warning>
The V2 SDK is beta and currently private to the OpenCode workspace. It is not
published for external installation yet, and its package name and API may
change before release.
</Warning>
## Create a host
`OpenCode.create()` creates a scoped host. Closing its Effect Scope releases
the router, location services, fibers, and scoped plugin registrations.
```ts
import {
AbsolutePath,
Location,
OpenCode,
} from "@opencode-ai/sdk-next"
import { Effect } from "effect"
const program = Effect.scoped(
Effect.gen(function* () {
const opencode = yield* OpenCode.create()
const session = yield* opencode.sessions.create({
location: Location.Ref.make({
directory: AbsolutePath.make("/workspace"),
}),
})
return yield* opencode.sessions.get({ sessionID: session.id })
}),
)
const session = await Effect.runPromise(program)
```
The embedded host uses the same routes, middleware, codecs, errors, and schema
values as `@opencode-ai/client/effect`. It exposes the full generated client and
adds the convenience aliases `sessions` and `events` for the session and event
groups.
## Use as a service
Use `OpenCode.layer` when the host should be provided through Effect dependency
injection:
```ts
import { OpenCode } from "@opencode-ai/sdk-next"
import { Effect } from "effect"
const program = Effect.gen(function* () {
const opencode = yield* OpenCode.Service
return yield* opencode.sessions.active()
})
const active = await Effect.runPromise(
program.pipe(Effect.provide(OpenCode.layer)),
)
```
## Register plugins
Call `opencode.plugin(...)` to register an embedded V2 plugin. Embedded plugins
use the same discovery and location-scoped activation path as configured
plugins. The SDK also exports `Tool` for plugin-defined tools. See the
[Plugins guide](/build/plugins) for the plugin shape and available hooks.
-78
View File
@@ -1,78 +0,0 @@
{
"$schema": "https://mintlify.com/docs.json",
"theme": "mint",
"name": "OpenCode",
"description": "OpenCode documentation.",
"colors": {
"primary": "#3B7DD8",
"light": "#3B7DD8",
"dark": "#FAB283"
},
"favicon": "/assets/favicon.svg",
"logo": {
"light": "/assets/logo-light.svg",
"dark": "/assets/logo-dark.svg"
},
"navigation": {
"tabs": [
{
"tab": "Docs",
"groups": [
{
"group": "Get started",
"pages": ["index", "migrate-v1", "config", "troubleshooting"]
},
{
"group": "Configure",
"pages": [
"providers",
"models",
"agents",
"permissions",
"sharing",
"snapshots",
"commands",
"skills",
"instructions",
"mcp-servers",
"attachments",
"compaction",
"warming",
"themes",
"formatters",
"lsp",
"references"
]
}
]
},
{
"tab": "Build",
"pages": ["build/index", "build/plugins", "build/client", "build/sdk"]
},
{
"tab": "API",
"groups": [
{
"group": "Overview",
"pages": ["api/index"]
},
{
"group": "Endpoints",
"openapi": "openapi.json"
}
]
}
],
"global": {}
},
"contextual": {
"options": ["copy", "view", "chatgpt", "claude", "mcp", "cursor", "vscode"]
},
"footer": {
"socials": {
"github": "https://github.com/anomalyco/opencode",
"x": "https://x.com/opencode"
}
}
}
-105
View File
@@ -1,105 +0,0 @@
---
title: "LSP"
description: ""
---
Language Server Protocol (LSP) integrations can provide code diagnostics,
symbols, definitions, references, and other language-aware context.
<Warning>
OpenCode V2 does not yet have an LSP runtime or built-in language servers.
The `lsp` configuration is accepted and preserved, but it does not currently
start or download servers, expose an LSP tool, or add diagnostics to file tool
results.
</Warning>
## Built-in servers
There are no built-in LSP servers in the current V2 implementation. Setting
`lsp` to `true` declares that built-ins should be enabled, but has no runtime
effect until V2 provides a server registry and LSP runtime.
```jsonc title="opencode.jsonc"
{
"$schema": "https://opencode.ai/config.json",
"lsp": true
}
```
## Configuration
The `lsp` field accepts a boolean or an object keyed by server name:
```jsonc title="opencode.jsonc"
{
"$schema": "https://opencode.ai/config.json",
"lsp": {
"custom-typescript": {
"command": ["typescript-language-server", "--stdio"],
"extensions": [".ts", ".tsx"],
"env": {
"TSS_LOG": "-level verbose"
},
"initialization": {
"preferences": {
"importModuleSpecifierPreference": "relative"
}
}
}
}
}
```
Each enabled server entry has this shape:
| Property | Type | Required | Description |
| --- | --- | --- | --- |
| `command` | `string[]` | Yes | Executable followed by any arguments. |
| `extensions` | `string[]` | No | File extensions associated with the server, including the leading dot. |
| `disabled` | `boolean` | No | Disables the entry when `true`. |
| `env` | `Record<string, string>` | No | Environment variables for the server process. The property is named `env`, not `environment`. |
| `initialization` | `Record<string, unknown>` | No | Server-specific options for the LSP `initialize` request. |
The only entry that may omit `command` is the disable-only form:
```jsonc
{
"lsp": {
"typescript": {
"disabled": true
}
}
}
```
Server names are arbitrary. The V2 schema permits `extensions` to be omitted,
including for a custom server, although a future runtime will need a way to
associate that server with files.
## Disable LSP
Omit `lsp` when no configuration is needed. Set it to `false` to explicitly
disable the whole integration, including when a lower-priority configuration
set it to `true` or supplied an object:
```jsonc title="opencode.jsonc"
{
"$schema": "https://opencode.ai/config.json",
"lsp": false
}
```
Use `{ "disabled": true }` under a server name to disable one server while
retaining the object form. `OPENCODE_DISABLE_LSP_DOWNLOAD` is not used by V2;
V2 currently performs no automatic LSP downloads.
## Current usage
V2 loads and validates the configuration shape for compatibility and future
integration. It does not currently use LSP when reading, writing, editing, or
patching files, and those tools do not notify a language server or return LSP
diagnostics.
For reliable feedback today, have the agent run the project's lint, typecheck,
test, or compiler commands. Record those commands in an `AGENTS.md` file or a
skill so the agent knows when and where to run them.
-17
View File
@@ -1,17 +0,0 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/docs",
"private": true,
"scripts": {
"dev": "bun run generate && bun --bun mint dev --no-open --port 3333",
"generate": "bun script/generate-theme-tokens.ts",
"check:generated": "bun script/generate-theme-tokens.ts --check",
"validate": "bun run check:generated && bun --bun mint validate",
"broken-links": "bun --bun mint broken-links"
},
"devDependencies": {
"effect": "catalog:",
"mint": "4.2.666",
"prettier": "3.6.2"
}
}
-209
View File
@@ -1,209 +0,0 @@
---
title: "Permissions"
description: ""
---
Permissions control whether an agent may perform an action on a resource. V2
configuration uses the `permissions` field and an ordered array of rules.
<Warning>
The V1 object syntax uses different field and action names. Do not use
`permission`, `bash`, or `task` in V2 configuration; use `permissions`,
`shell`, and `subagent`.
</Warning>
## Rule schema
Each rule has three required string fields:
```jsonc
{
"$schema": "https://opencode.ai/config.json",
"permissions": [
{ "action": "*", "resource": "*", "effect": "ask" },
{ "action": "read", "resource": "*", "effect": "allow" },
{ "action": "read", "resource": "*.env", "effect": "deny" },
{ "action": "shell", "resource": "git status *", "effect": "allow" },
{ "action": "shell", "resource": "git push *", "effect": "deny" },
{ "action": "edit", "resource": "packages/docs/*.mdx", "effect": "allow" }
]
}
```
- `action` matches a tool permission action.
- `resource` matches the value the tool is trying to use, such as a path,
command, URL, query, or agent ID.
- `effect` is `"allow"`, `"deny"`, or `"ask"`.
`allow` proceeds without prompting, `deny` blocks the operation, and `ask`
waits for a user decision. If no rule matches, the result is `ask`.
## Matching and order
Both `action` and `resource` support simple wildcards:
- `*` matches zero or more characters, including `/`.
- `?` matches exactly one character.
- All other characters are literal.
Matches cover the entire value. Slashes are normalized, and matching is
case-insensitive on Windows. For shell convenience, a pattern ending in
`" *"` also matches the command without arguments: `"git status *"` matches
both `git status` and `git status --short`.
The **last matching rule wins**. Put broad rules first and exceptions later.
Rules from lower-priority configuration files are loaded first. OpenCode then
appends all global rules before agent-specific rules, so a matching agent rule
overrides a global rule.
Some operations check several resources at once, such as a patch touching
multiple files. OpenCode denies the operation if any resource resolves to
`deny`; otherwise it asks if any resolves to `ask`; otherwise it allows it.
## Actions and resources
V2 action names are strings, so plugins may introduce additional actions. The
current built-in actions use these resources:
| Action | Resource matched |
| --- | --- |
| `read` | Location-relative path for an internal file or directory; canonical absolute path for an external target |
| `edit` | Target path for `edit`, `write`, and `patch`; all three tools share this action |
| `glob` | The requested glob pattern |
| `grep` | The requested regular expression, not the search path |
| `shell` | The complete raw shell command string |
| `subagent` | The target agent ID |
| `skill` | The skill ID |
| `question` | `*` |
| `webfetch` | The requested URL |
| `websearch` | The search query |
| `external_directory` | A canonical external directory boundary, normally ending in `/*` |
| `<server>_<tool>` | `*` for an MCP tool; unsupported characters in both names become `_` |
| `execute` | `*`; controls availability of the Code Mode dispatcher, while each nested tool still enforces its own permission |
Built-in agent policy also reserves `plan_enter` and `plan_exit` for plan-mode
transitions. `doom_loop` and `lsp` are not current V2 Core permission actions.
## External directories
An external path requires a separate `external_directory` decision before the
tool's own `read` or `edit` decision. This applies to external paths used by
`read`, `edit`, `write`, and `patch`, and to an external `shell` working
directory.
```jsonc
{
"$schema": "https://opencode.ai/config.json",
"permissions": [
{
"action": "external_directory",
"resource": "~/projects/reference/*",
"effect": "allow"
},
{
"action": "read",
"resource": "~/projects/reference/*",
"effect": "allow"
},
{
"action": "edit",
"resource": "~/projects/reference/*",
"effect": "deny"
}
]
}
```
For `external_directory`, `read`, and `edit` resources, a leading `~`, `~/`,
`$HOME`, or `$HOME/` is expanded when configuration loads. Shell resources are
raw command text and are **not** home-expanded.
<Warning>
`shell` runs with the host user's filesystem, process, and network authority.
Its resource is raw text, not a parsed command. External command arguments
produce only best-effort warnings; `external_directory` is enforced for the
working directory, not every path embedded in a command. Prefer a narrow
shell allowlist over patterns intended to identify every dangerous command.
</Warning>
Relative mutation paths cannot escape the active Location, and symlink escapes
from inside it are rejected. Explicit external paths are canonicalized before
matching, so authorize only trusted directory boundaries.
## Defaults
The evaluator's fallback is `ask`, but shipped agents include ordered defaults:
| Agent | Effective default policy |
| --- | --- |
| `build` | Allows most actions; asks for external directories and `.env` reads; allows questions and entering plan mode; denies exiting plan mode |
| `plan` | Uses the same base, allows questions and exiting plan mode, and denies edits except OpenCode plan files |
| `general` | Uses the base policy but cannot launch another subagent; questions and plan transitions remain denied |
| `explore` | Denies everything except `read`, `glob`, `grep`, `webfetch`, and `websearch`; cannot launch subagents and asks for external directories |
| Hidden maintenance agents | Deny all actions |
The base read rules are ordered as follows:
```jsonc
[
{ "action": "read", "resource": "*", "effect": "allow" },
{ "action": "read", "resource": "*.env", "effect": "ask" },
{ "action": "read", "resource": "*.env.*", "effect": "ask" },
{ "action": "read", "resource": "*.env.example", "effect": "allow" }
]
```
OpenCode also permits its managed tool-output and temporary directories where
needed. These exceptions do not grant general external-directory access.
## Agent overrides
Configure shared policy at the top level and append narrower rules to a named
agent under `agents.<id>.permissions`:
```jsonc
{
"$schema": "https://opencode.ai/config.json",
"permissions": [
{ "action": "shell", "resource": "*", "effect": "ask" },
{ "action": "shell", "resource": "git diff *", "effect": "allow" },
{ "action": "shell", "resource": "git status *", "effect": "allow" }
],
"agents": {
"reviewer": {
"description": "Review code without changing it",
"mode": "subagent",
"permissions": [
{ "action": "edit", "resource": "*", "effect": "deny" },
{ "action": "shell", "resource": "git diff *", "effect": "allow" },
{ "action": "shell", "resource": "git status *", "effect": "allow" }
]
}
}
}
```
Agent rules do not replace the global array; they are appended after it. A
custom subagent executes with its own permissions, not a permission subset
derived from the parent agent.
## Approval choices
When an `ask` rule matches, clients can reply with:
- **Allow once** (`once`): approve only the pending request.
- **Allow always** (`always`): approve this request and save the patterns
proposed by the tool for the current project.
- **Reject** (`reject`): reject the request. Rejecting also rejects other
pending permission requests in the same session; clients may attach feedback.
Saved approvals are durable and project-scoped. They are additional `allow`
rules, but they can never override a configured `deny`. The proposed saved
pattern may be broader than the displayed resource: several tools propose `*`,
shell proposes the exact command text, and skills and subagents propose their
IDs. Review the confirmation carefully and remove saved approvals that are no
longer needed.
For non-interactive runs, `opencode2 run --auto` replies `once` to permission
requests. It does not save approvals, and explicit `deny` rules remain enforced.
Without `--auto`, a non-interactive run rejects permission requests.
-177
View File
@@ -1,177 +0,0 @@
---
title: "References"
description: ""
---
References give OpenCode named access to directories outside the current
project. Use them for documentation, shared libraries, examples, or source from
another repository.
Configure references by alias in `opencode.json` or `opencode.jsonc`:
```jsonc title="opencode.jsonc"
{
"$schema": "https://opencode.ai/config.json",
"references": {
"docs": {
"path": "../product-docs",
"description": "Use for product behavior and terminology"
},
"effect": {
"repository": "Effect-TS/effect",
"branch": "main",
"description": "Use for Effect implementation details"
}
}
}
```
## Local directories
Use `path` for a local directory:
```jsonc
{
"references": {
"design-system": {
"path": "../design-system",
"description": "Use when working with components or design tokens"
}
}
}
```
Relative paths resolve from the directory containing the config file that
defines them. Absolute paths and home-relative paths such as `~/docs` are also
supported.
The string shorthand is useful when no other fields are needed:
```jsonc
{
"references": {
"docs": "../docs",
"shared": "~/work/shared"
}
}
```
<Note>
A shorthand string is treated as a local path only when it starts with `.`,
`/`, or `~`. Use `./docs`, not `docs`; a bare `docs` value is interpreted as
a Git repository.
</Note>
## Git repositories
Use `repository` for a remote Git repository. GitHub `owner/repo` shorthand,
Git URLs, host/path forms, and SCP-style remotes are supported.
```jsonc
{
"references": {
"effect": {
"repository": "Effect-TS/effect",
"branch": "main"
},
"internal-sdk": {
"repository": "git@gitlab.example.com:platform/sdk.git",
"branch": "release/v2"
}
}
}
```
Without `branch`, OpenCode checks out and refreshes the remote's default
branch. Branch names may contain letters, numbers, `/`, `_`, `.`, and `-`, but
cannot start with `-` or contain `..`. Local `file:` repositories are not
supported.
Git references also support shorthand:
```jsonc
{
"references": {
"effect": "Effect-TS/effect",
"sdk": "gitlab.com/platform/sdk"
}
}
```
### Cloning and storage
OpenCode normalizes a remote and stores one checkout under its global data
directory at `opencode/repos/<host>/<repository-path>`. On a typical Linux
installation, for example, `Effect-TS/effect` is stored at:
```text
~/.local/share/opencode/repos/github.com/Effect-TS/effect
```
Missing repositories are cloned. Existing checkouts are fetched and reset to
the requested branch, or to the remote default branch when `branch` is omitted.
Materialization runs asynchronously when references load or reload, so a new
reference can appear before its checkout is ready. Clone and refresh failures
are logged and do not stop other references from loading.
<Warning>
The cache has one checkout per normalized remote, not one per branch. Do not
configure the same repository at multiple branches; only one branch can be
exposed. Avoid editing cached checkouts because a refresh resets them.
</Warning>
## Description and visibility
`description` tells agents when a reference is relevant. References with a
description are included in agent instructions with their alias and resolved
path. References without one remain available in `@` autocomplete but are not
advertised automatically.
Set `hidden` to `true` to remove a reference from TUI `@` autocomplete:
```jsonc
{
"references": {
"internal": {
"path": "../internal",
"description": "Use for internal service behavior",
"hidden": true
}
}
}
```
`hidden` controls only autocomplete visibility. It does not remove the
reference from the reference API or agent instructions when a description is
present.
## Use references
Type `@` in the TUI and select a reference alias to attach its root directory:
```text
Compare the current implementation with @effect
```
The attachment provides a non-recursive listing of the root's immediate files
and directories. V2 currently attaches references by root alias;
`@alias/path` is not a reference-specific file browser. Ask the agent to
inspect a particular path when more detail is needed.
References do not grant extra tool permissions. Access outside the active
Location remains subject to the agent's normal tool rules and the
`external_directory` permission. Editing a reference additionally requires the
applicable edit permission.
## Fields
| Field | Local | Git | Description |
| --- | --- | --- | --- |
| `path` | Required | No | Local directory path |
| `repository` | No | Required | Remote Git repository |
| `branch` | No | Optional | Branch to fetch and check out |
| `description` | Optional | Optional | Guidance describing when agents should use it |
| `hidden` | Optional | Optional | Hide it from TUI `@` autocomplete |
An alias cannot be empty or contain `/`, `\`, whitespace, a backtick, or a
comma.
+1
View File
@@ -314,6 +314,7 @@ export namespace Text {
assistantMessageID: SessionMessage.ID,
ordinal: NonNegativeInt,
text: Schema.String,
state: SessionMessage.ProviderState.pipe(optional),
},
})
export type Ended = typeof Ended.Type
+1
View File
@@ -155,6 +155,7 @@ export interface AssistantText extends Schema.Schema.Type<typeof AssistantText>
export const AssistantText = Schema.Struct({
type: Schema.tag("text"),
text: Schema.String,
state: ProviderState.pipe(optional),
}).annotate({ identifier: "Session.Message.Assistant.Text" })
export interface AssistantReasoning extends Schema.Schema.Type<typeof AssistantReasoning> {}
+16 -4
View File
@@ -1,8 +1,9 @@
import { RGBA } from "@opentui/core"
import { useTheme } from "../context/theme"
import { Spinner } from "./spinner"
export function Reconnecting() {
const { themeV2 } = useTheme()
const { themeV2 } = useTheme().contextual("elevated")
return (
<box
@@ -12,12 +13,23 @@ export function Reconnecting() {
right={0}
bottom={0}
left={0}
backgroundColor={themeV2.background.default}
backgroundColor={RGBA.fromInts(0, 0, 0, 150)}
alignItems="center"
justifyContent="center"
>
<box width={62} maxWidth="90%" flexDirection="column" alignItems="center" gap={1}>
<Spinner color={themeV2.text.subdued}>Waiting for background service...</Spinner>
<box
width={48}
maxWidth="90%"
flexDirection="column"
backgroundColor={themeV2.background.default}
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
paddingRight={2}
gap={1}
>
<Spinner color={themeV2.text.default}>Restarting service...</Spinner>
<text fg={themeV2.text.subdued}>Your session will resume automatically.</text>
</box>
</box>
)
+20 -14
View File
@@ -81,7 +81,15 @@ import { PluginSlot } from "../../plugin/context"
import { Keymap, type KeymapCommand } from "../../context/keymap"
import { usePathFormatter } from "../../context/path-format"
import { useLocation } from "../../context/location"
import { createSessionRows, messageBoundaryIDs, resolvePart, type PartRef, type SessionRow } from "./rows"
import {
cacheReuseDrop,
createSessionRows,
messageBoundaryIDs,
resolvePart,
type CacheUsage,
type PartRef,
type SessionRow,
} from "./rows"
import { switchLabel } from "../../util/model"
import { findMessageBoundary, messageNavigationSlack } from "./message-navigation"
import { stringWidth } from "../../util/string-width"
@@ -1079,7 +1087,7 @@ function SessionRowView(props: SessionRowViewProps) {
{(row) => (
<TurnTokenUsage
messageIDs={row().messageIDs}
previousCacheRead={row().previousCacheRead}
previousCache={row().previousCache}
message={props.message}
/>
)}
@@ -1091,13 +1099,13 @@ function SessionRowView(props: SessionRowViewProps) {
function TurnTokenUsage(props: {
messageIDs: string[]
previousCacheRead?: number
previousCache?: CacheUsage
message: (messageID: string) => SessionMessageInfo | undefined
}) {
const config = useConfig()
const { themeV2 } = useTheme()
const steps = createMemo(() => {
let previousCacheRead = props.previousCacheRead
let previousCache = props.previousCache
return props.messageIDs.flatMap((messageID) => {
const message = props.message(messageID)
if (message?.type !== "assistant" || !message.tokens) return []
@@ -1109,18 +1117,16 @@ function TurnTokenUsage(props: {
message.tokens.cache.write
if (total === 0) return []
const newTokens = total - message.tokens.cache.read
const cacheBust =
previousCacheRead !== undefined && message.tokens.cache.read < previousCacheRead
? previousCacheRead - message.tokens.cache.read
: undefined
previousCacheRead = message.tokens.cache.read
const currentCache = { read: message.tokens.cache.read, model: message.model }
const reuseDrop = cacheReuseDrop(previousCache, currentCache)
previousCache = currentCache
return [
{
finish: message.finish === "tool-calls" ? "tool-call" : (message.finish ?? "unknown"),
newTokens,
cached: message.tokens.cache.read,
total,
cacheBust,
reuseDrop,
},
]
})
@@ -1165,9 +1171,9 @@ function TurnTokenUsage(props: {
{" "}
{item.total.toLocaleString().padStart(columns().total)}
</text>
<Show when={item.cacheBust !== undefined}>
<text fg={themeV2.text.feedback.error.default}>
! Cache bust: {item.cacheBust?.toLocaleString()} fewer cached tokens than the previous step
<Show when={item.reuseDrop !== undefined}>
<text fg={themeV2.text.feedback.warning.default}>
! Likely cache bust: {item.reuseDrop?.toLocaleString()} fewer cached tokens than the previous step
</text>
</Show>
</box>
@@ -2866,7 +2872,7 @@ function Execute(props: ToolProps) {
const isLoading = createMemo(() => props.part.state.status === "streaming" || props.part.state.status === "running")
const calls = createMemo(() => executeCalls(props.metadata.toolCalls))
const output = createMemo(() => stripAnsi(props.output?.trim() ?? ""))
const hasRuntimeError = createMemo(() => props.metadata.error === true)
const hasRuntimeError = createMemo(() => props.metadata.error === true || props.part.state.status === "error")
const outputPreview = createMemo(() => collapseToolOutput(output(), 4, 4 * Math.max(20, ctx.width - 6)).output)
const showOutput = createMemo(() => output() && hasRuntimeError())
const content = createMemo(() => {
+25 -6
View File
@@ -10,6 +10,11 @@ export type PartRef = {
partID: string
}
export type CacheUsage = {
read: number
model: SessionMessageAssistant["model"]
}
export type SessionRow =
| { type: "message"; messageID: string }
| { type: "compaction-queued"; inputID: string }
@@ -28,7 +33,7 @@ export type SessionRow =
completed: boolean
}
| { type: "assistant-footer"; messageID: string }
| { type: "turn-usage"; messageIDs: string[]; previousCacheRead?: number }
| { type: "turn-usage"; messageIDs: string[]; previousCache?: CacheUsage }
export function createSessionRows(sessionID: Accessor<string>) {
const data = useData()
@@ -280,7 +285,7 @@ export function reduceSessionRows(
const pendingCompactions = messages.filter((message) => message.type === "compaction" && message.status === "running")
const pending = new Set([...pendingCompactions.map((message) => message.id), ...inputs])
const usage = turnTokens
? { steps: [] as SessionMessageAssistant[], previousTurnCacheRead: undefined as number | undefined }
? { steps: [] as SessionMessageAssistant[], previousTurnCache: undefined as CacheUsage | undefined }
: undefined
return [
...messages.filter((message) => !pending.has(message.id)),
@@ -289,6 +294,8 @@ export function reduceSessionRows(
].reduce<SessionRow[]>((rows, message) => {
if (message.type !== "assistant") {
if (message.type === "synthetic" && !message.description?.trim()) return rows
if (message.type === "compaction" && message.status === "completed" && usage)
usage.previousTurnCache = undefined
if (!pending.has(message.id)) completePrevious(rows)
rows.push({ type: "message", messageID: message.id })
return rows
@@ -312,11 +319,9 @@ export function reduceSessionRows(
rows.push({
type: "turn-usage",
messageIDs: stepsWithUsage.map((step) => step.id),
...(usage.previousTurnCacheRead === undefined
? {}
: { previousCacheRead: usage.previousTurnCacheRead }),
...(usage.previousTurnCache === undefined ? {} : { previousCache: usage.previousTurnCache }),
})
usage.previousTurnCacheRead = last.tokens.cache.read
usage.previousTurnCache = { read: last.tokens.cache.read, model: last.model }
}
usage.steps.length = 0
}
@@ -324,6 +329,20 @@ export function reduceSessionRows(
}, [])
}
export function cacheReuseDrop(previous: CacheUsage | undefined, current: CacheUsage) {
if (previous === undefined) return
if (
previous.model.providerID !== current.model.providerID ||
previous.model.id !== current.model.id ||
previous.model.variant !== current.model.variant
)
return
const drop = previous.read - current.read
// OpenAI cache reads can move between one and two 1,024-token buckets without a material loss of reuse.
if (current.model.providerID === "openai" && drop >= 1_024 && drop <= 2_048) return
return drop > 0 ? drop : undefined
}
function hasTokenUsage(
message: SessionMessageAssistant,
): message is SessionMessageAssistant & { tokens: NonNullable<SessionMessageAssistant["tokens"]> } {
+87 -1
View File
@@ -1,6 +1,92 @@
import { expect, test } from "bun:test"
import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client"
import { messageBoundaryIDs, reduceSessionRows } from "../../../src/routes/session/rows"
import { cacheReuseDrop, messageBoundaryIDs, reduceSessionRows } from "../../../src/routes/session/rows"
test("filters OpenAI cache quantization from cache reuse drops", () => {
const openai = { id: "gpt", providerID: "openai" }
expect(cacheReuseDrop(undefined, { read: 10_000, model: openai })).toBeUndefined()
expect(cacheReuseDrop({ read: 10_000, model: openai }, { read: 11_000, model: openai })).toBeUndefined()
expect(cacheReuseDrop({ read: 10_000, model: openai }, { read: 8_977, model: openai })).toBe(1_023)
expect(cacheReuseDrop({ read: 10_000, model: openai }, { read: 8_976, model: openai })).toBeUndefined()
expect(cacheReuseDrop({ read: 10_000, model: openai }, { read: 8_500, model: openai })).toBeUndefined()
expect(cacheReuseDrop({ read: 10_000, model: openai }, { read: 7_952, model: openai })).toBeUndefined()
expect(cacheReuseDrop({ read: 10_000, model: openai }, { read: 7_951, model: openai })).toBe(2_049)
})
test("compares cache reuse only for the same model", () => {
const previous = { read: 10_000, model: { id: "claude", providerID: "anthropic" } }
expect(cacheReuseDrop(previous, { read: 8_976, model: { id: "gpt", providerID: "openai" } })).toBeUndefined()
expect(cacheReuseDrop(previous, { read: 8_976, model: { id: "claude", providerID: "anthropic" } })).toBe(1_024)
expect(
cacheReuseDrop(
{ read: 10_000, model: { id: "gpt", providerID: "openai", variant: "low" } },
{ read: 8_976, model: { id: "gpt", providerID: "openai", variant: "high" } },
),
).toBeUndefined()
})
test("carries model identity with the cross-turn cache baseline", () => {
const first = assistant("assistant-1", [])
first.model = { id: "claude", providerID: "anthropic" }
first.finish = "stop"
first.tokens = { input: 1, output: 0, reasoning: 0, cache: { read: 10_000, write: 0 } }
const second = assistant("assistant-2", [])
second.model = { id: "gpt", providerID: "openai" }
second.finish = "stop"
second.tokens = { input: 1, output: 0, reasoning: 0, cache: { read: 8_976, write: 0 } }
const rows = reduceSessionRows(
[
{ type: "user", id: "user-1", text: "First", time: { created: 0 } },
first,
{ type: "user", id: "user-2", text: "Second", time: { created: 2 } },
second,
],
new Set(),
true,
).filter((row) => row.type === "turn-usage")
expect(rows).toEqual([
{ type: "turn-usage", messageIDs: ["assistant-1"] },
{
type: "turn-usage",
messageIDs: ["assistant-2"],
previousCache: { read: 10_000, model: { id: "claude", providerID: "anthropic" } },
},
])
})
test("resets the cross-turn cache baseline after compaction", () => {
const first = assistant("assistant-1", [])
first.finish = "stop"
first.tokens = { input: 1, output: 0, reasoning: 0, cache: { read: 370_176, write: 0 } }
const second = assistant("assistant-2", [])
second.finish = "stop"
second.tokens = { input: 1, output: 0, reasoning: 0, cache: { read: 13_824, write: 0 } }
const rows = reduceSessionRows(
[
first,
{
type: "compaction",
id: "compaction-1",
status: "completed",
reason: "auto",
summary: "Compacted context",
recent: "",
time: { created: 2 },
},
second,
],
new Set(),
true,
).filter((row) => row.type === "turn-usage")
expect(rows).toEqual([
{ type: "turn-usage", messageIDs: ["assistant-1"] },
{ type: "turn-usage", messageIDs: ["assistant-2"] },
])
})
test("assigns assistant boundaries to the first rendered row instead of the first text row", () => {
const messages: SessionMessageInfo[] = [
+4 -6
View File
@@ -1,6 +1,4 @@
.source
.tanstack
.wrangler
dist
node_modules
worker-configuration.d.ts
.blume/
dist/
node_modules/
.blume-verify/
+22
View File
@@ -0,0 +1,22 @@
# Website and documentation guide
## Structure
- This package owns the `opencode.ai` website.
- Add custom marketing routes as Astro files under `pages/`.
- The whole project is mounted at `/v2/` by `deployment.base`, and documentation lives under `content/docs/` at `/v2/docs` through `basePath` in `blume.config.ts`.
- Write documentation in MDX. Every page should have `title` and `description` frontmatter.
- Use parenthesized content folders for sidebar groups that must not add a URL segment. Keep ungrouped top-level pages directly under `content/docs/`.
- Put static files in `public/` and reference them with root-relative paths.
- The API reference is generated from `openapi.json`; do not duplicate endpoint documentation as hand-written MDX.
- Keep documentation aligned with the V2 packages. Do not use `packages/opencode` as the source of truth unless the task explicitly concerns V1.
## Local development
- Run `bun dev` from this package and preview the site at `http://localhost:3000/v2/`.
- Verify both custom marketing routes and documentation routes after changing shared navigation or layout code.
## Validation
- Run `bun typecheck`, `bun validate`, and `bun run build` from this package after documentation or configuration changes.
- Treat validation and build errors as blockers.
+7 -4
View File
@@ -1,6 +1,10 @@
# OpenCode website
The server-rendered `opencode.ai` website. It uses TanStack Start on Cloudflare Workers and serves the V2 documentation with Fumadocs.
The OpenCode V2 website, powered by Blume and deployed with Wrangler at `https://opencode.ai/v2/`. Blume mounts the documentation at `/v2/docs`, and `https://v2.opencode.ai` redirects to the same deployment.
Wrangler deploys the site through Blume's Cloudflare server adapter. Documentation pages are prerendered, while custom dynamic routes and endpoints can run in the Worker. Production uses `opencode-www` at `opencode.ai/v2/`; dev uses `opencode-www-dev` at `dev.opencode.ai/v2/`. The `v2.opencode.ai` alias is handled by a Cloudflare Redirect Rule outside this project.
The `deploy-www` GitHub workflow deploys the `dev` branch to the dev Worker and the `v2` branch to the production Worker.
## Development
@@ -10,13 +14,12 @@ From this directory, run:
bun dev
```
The site opens at `http://localhost:3000`; documentation is available at `http://localhost:3000/docs`.
The site opens at `http://localhost:3000/v2/`; documentation is available at `http://localhost:3000/v2/docs`.
## Verification
```bash
bun typecheck
bun validate
bun run build
```
The existing `packages/web`, `packages/docs`, and `packages/stats/app` deployments remain in place until their routes have been migrated and verified.
+43
View File
@@ -0,0 +1,43 @@
import { defineConfig } from "blume"
export default defineConfig({
title: "OpenCode",
description: "The open source AI coding agent.",
basePath: "/docs",
logo: {
image: {
light: "/assets/logo-light.svg",
dark: "/assets/logo-dark.svg",
alt: "OpenCode",
},
text: "",
href: "/",
},
content: {
root: "content/docs",
},
github: {
owner: "anomalyco",
repo: "opencode",
branch: "dev",
dir: "packages/www",
},
navigation: {
tabs: [
{ label: "Docs", path: "/" },
{ label: "Build", path: "/build" },
{ label: "API", path: "/api" },
],
},
openapi: {
enabled: true,
route: "/api",
spec: "./openapi.json",
},
deployment: {
adapter: "cloudflare",
base: "/v2/",
output: "server",
site: process.env.BLUME_ENV === "dev" ? "https://dev.opencode.ai" : "https://opencode.ai",
},
})
+8
View File
@@ -0,0 +1,8 @@
import { defineComponents } from "blume"
import ThemeTokens from "./snippets/generated/theme-tokens.mdx"
export default defineComponents({
mdx: {
ThemeTokens,
},
})
@@ -227,10 +227,10 @@ shell commands, `edit` for all edit/write/patch tools, and `subagent` for child
agents. Other tools generally use their tool name, such as `read`, `glob`,
`grep`, `webfetch`, `websearch`, and `skill`.
<Tip>
<Callout type="tip">
Put broad wildcard rules first and exceptions afterward. For example, deny
all subagents first, then allow `explore`.
</Tip>
</Callout>
`~` and `$HOME` are expanded in filesystem resources for `read`, `edit`, and
`external_directory`. Shell resources are raw command text and are not
@@ -274,10 +274,10 @@ The V2 schema accepts per-agent request `headers` and JSON `body` overlays:
}
```
<Warning>
<Callout type="warning">
The current V2 session runner preserves these overlays on the agent
definition but does not yet apply them to model requests. Configure effective
request settings on the provider, model, or model variant instead. Do not use
legacy top-level agent fields such as `temperature`, `top_p`, `prompt`,
`permission`, `tools`, `disable`, or `maxSteps` in new V2 configuration.
</Warning>
</Callout>
@@ -17,12 +17,12 @@ and other binary prompt attachments are not currently included in the model
request. Some clients may let you select a PDF, but V2 does not yet make that
PDF visible to the model.
<Warning>
<Callout type="warning">
Use a model that supports image input before attaching an image. OpenCode
passes supported image media to the selected provider, but the provider and
model still enforce their own formats, dimensions, file counts, and size
limits. A text-only model may reject the request.
</Warning>
</Callout>
## Add attachments
@@ -131,12 +131,12 @@ All fields are optional:
| `max_height` | `2000` | Maximum height in pixels. Must be a positive integer. |
| `max_base64_bytes` | `5242880` | Maximum byte length of the Base64-encoded image string. Must be a positive integer. |
<Note>
<Callout type="note">
In the current V2 runtime, these settings apply to image media produced by
the built-in `read` tool. Images attached directly through the TUI, desktop,
web, CLI, or API bypass this normalization. Resize direct attachments before
adding them if the provider requires smaller media.
</Note>
</Callout>
The `read` tool recognizes PNG, JPEG, GIF, and WebP by their contents and will
ingest at most 20 MiB of source image bytes. It decodes the image and compares
@@ -137,10 +137,10 @@ project location and inserts its combined output into the template. Argument
interpolation happens first, so avoid placing untrusted arguments inside shell
interpolations.
<Warning>
<Callout type="warning">
Shell interpolations run when the command is evaluated, outside the agent's
tool permission flow. Only use commands from sources you trust.
</Warning>
</Callout>
No other template interpolation is performed. In particular, an `@path`
written into a stored template remains ordinary prompt text; V2 does not
@@ -6,10 +6,10 @@ description: ""
OpenCode V2 accepts formatter configuration, but it does not yet include a
formatter runtime. File writes and edits are not automatically formatted.
<Warning>
<Callout type="warning">
V2 currently has no built-in formatters. The built-in formatter list and
automatic post-edit formatting documented for V1 do not apply to V2.
</Warning>
</Callout>
## Configuration
@@ -43,10 +43,10 @@ If the Location is outside the project root, only the global file is loaded.
Setting `OPENCODE_DISABLE_PROJECT_CONFIG=1` also skips project `AGENTS.md`
discovery but does not disable the global file.
<Note>
<Callout type="note">
Current V2 discovery only recognizes `AGENTS.md`. The `CLAUDE.md` fallback
and related precedence described by older OpenCode documentation do not apply.
</Note>
</Callout>
### Nested instructions
@@ -80,13 +80,13 @@ Configuration is loaded from global through project-local files. If more than
one config defines `instructions`, the highest-precedence, closest config's
entire array is selected; arrays are not merged.
<Warning>
<Callout type="warning">
V2 currently parses and retains this field but does not resolve its entries
into instruction sources. Local files, glob patterns, and HTTP or HTTPS URLs
in `instructions` therefore do not reach the model yet. Use `AGENTS.md` for
active V2 instructions. URL fetching and timeout behavior documented for V1
are not supported by the current V2 implementation.
</Warning>
</Callout>
See [Config](/config) for config locations and general precedence.

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