Compare commits

...
11 Commits
62 changed files with 874 additions and 397 deletions
+35 -35
View File
@@ -32,7 +32,7 @@
},
"packages/ai": {
"name": "@opencode/ai",
"version": "2.0.3",
"version": "2.0.4",
"dependencies": {
"@aws-sdk/credential-providers": "3.1057.0",
"@opencode/schema": "workspace:*",
@@ -54,7 +54,7 @@
},
"packages/app": {
"name": "@opencode/app",
"version": "2.0.3",
"version": "2.0.4",
"dependencies": {
"@corvu/drawer": "catalog:",
"@dnd-kit/abstract": "0.5.0",
@@ -113,7 +113,7 @@
},
"packages/cli": {
"name": "@opencode/cli",
"version": "2.0.3",
"version": "2.0.4",
"bin": {
"opencode2": "./bin/opencode2.cjs",
},
@@ -177,7 +177,7 @@
},
"packages/client": {
"name": "@opencode/client",
"version": "2.0.3",
"version": "2.0.4",
"dependencies": {
"@opencode/protocol": "workspace:*",
"@opencode/schema": "workspace:*",
@@ -203,7 +203,7 @@
},
"packages/codemode": {
"name": "@opencode/codemode",
"version": "2.0.3",
"version": "2.0.4",
"dependencies": {
"acorn": "8.15.0",
"effect": "catalog:",
@@ -217,7 +217,7 @@
},
"packages/console/app": {
"name": "@opencode/console-app",
"version": "2.0.3",
"version": "2.0.4",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -253,7 +253,7 @@
},
"packages/console/core": {
"name": "@opencode/console-core",
"version": "2.0.3",
"version": "2.0.4",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -280,7 +280,7 @@
},
"packages/console/function": {
"name": "@opencode/console-function",
"version": "2.0.3",
"version": "2.0.4",
"dependencies": {
"@openauthjs/openauth": "0.0.0-20250322224806",
"@opencode/console-core": "workspace:*",
@@ -297,7 +297,7 @@
},
"packages/console/mail": {
"name": "@opencode/console-mail",
"version": "2.0.3",
"version": "2.0.4",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -321,7 +321,7 @@
},
"packages/console/support": {
"name": "@opencode/console-support",
"version": "2.0.3",
"version": "2.0.4",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@opencode/console-core": "workspace:*",
@@ -341,7 +341,7 @@
},
"packages/core": {
"name": "@opencode/core",
"version": "2.0.3",
"version": "2.0.4",
"dependencies": {
"@ai-sdk/cohere": "3.0.27",
"@ai-sdk/gateway": "3.0.104",
@@ -409,7 +409,7 @@
},
"packages/desktop": {
"name": "@opencode/desktop",
"version": "2.0.3",
"version": "2.0.4",
"dependencies": {
"@zip.js/zip.js": "2.7.62",
"electron-context-menu": "4.1.2",
@@ -461,7 +461,7 @@
},
"packages/enterprise": {
"name": "@opencode/enterprise",
"version": "2.0.3",
"version": "2.0.4",
"dependencies": {
"@hono/standard-validator": "catalog:",
"@opencode-ai/sdk": "1.18.21",
@@ -498,7 +498,7 @@
},
"packages/function": {
"name": "@opencode/function",
"version": "2.0.3",
"version": "2.0.4",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -514,7 +514,7 @@
},
"packages/http-recorder": {
"name": "@opencode/http-recorder",
"version": "2.0.3",
"version": "2.0.4",
"dependencies": {
"@effect/platform-node-shared": "4.0.0-rc.112",
},
@@ -533,7 +533,7 @@
},
"packages/httpapi-codegen": {
"name": "@opencode/httpapi-codegen",
"version": "2.0.3",
"version": "2.0.4",
"dependencies": {
"effect": "catalog:",
"prettier": "3.6.2",
@@ -546,7 +546,7 @@
},
"packages/latex": {
"name": "@opencode/latex",
"version": "2.0.3",
"version": "2.0.4",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opentui/core": "catalog:",
@@ -560,7 +560,7 @@
},
"packages/merman": {
"name": "@opencode/merman",
"version": "2.0.3",
"version": "2.0.4",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opentui/core": "catalog:",
@@ -575,7 +575,7 @@
},
"packages/plugin": {
"name": "@opencode/plugin",
"version": "2.0.3",
"version": "2.0.4",
"dependencies": {
"@ai-sdk/provider": "3.0.8",
"@opencode/ai": "workspace:*",
@@ -614,7 +614,7 @@
},
"packages/plugin-browser": {
"name": "@opencode/plugin-browser",
"version": "2.0.3",
"version": "2.0.4",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opencode/schema": "workspace:*",
@@ -644,7 +644,7 @@
},
"packages/protocol": {
"name": "@opencode/protocol",
"version": "2.0.3",
"version": "2.0.4",
"dependencies": {
"@opencode/schema": "workspace:*",
"effect": "catalog:",
@@ -659,7 +659,7 @@
},
"packages/schema": {
"name": "@opencode/schema",
"version": "2.0.3",
"version": "2.0.4",
"dependencies": {
"@standard-schema/spec": "catalog:",
"effect": "catalog:",
@@ -683,7 +683,7 @@
},
"packages/sdk": {
"name": "@opencode/sdk",
"version": "2.0.3",
"version": "2.0.4",
"dependencies": {
"@opencode/client": "workspace:*",
"@opencode/core": "workspace:*",
@@ -704,7 +704,7 @@
},
"packages/server": {
"name": "@opencode/server",
"version": "2.0.3",
"version": "2.0.4",
"dependencies": {
"@effect/platform-node": "catalog:",
"@effect/platform-node-shared": "catalog:",
@@ -726,7 +726,7 @@
},
"packages/session-ui": {
"name": "@opencode/session-ui",
"version": "2.0.3",
"version": "2.0.4",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode/client": "workspace:*",
@@ -761,7 +761,7 @@
},
"packages/simulation": {
"name": "@opencode/simulation",
"version": "2.0.3",
"version": "2.0.4",
"dependencies": {
"@opencode/ai": "workspace:*",
"@opencode/core": "workspace:*",
@@ -781,7 +781,7 @@
},
"packages/stats/app": {
"name": "@opencode/stats-app",
"version": "2.0.3",
"version": "2.0.4",
"dependencies": {
"@ibm/plex": "6.4.1",
"@kobalte/core": "catalog:",
@@ -815,7 +815,7 @@
},
"packages/stats/core": {
"name": "@opencode/stats-core",
"version": "2.0.3",
"version": "2.0.4",
"dependencies": {
"@aws-sdk/client-athena": "3.933.0",
"@planetscale/database": "1.19.0",
@@ -834,7 +834,7 @@
},
"packages/stats/server": {
"name": "@opencode/stats-server",
"version": "2.0.3",
"version": "2.0.4",
"dependencies": {
"@aws-sdk/client-firehose": "3.933.0",
"@effect/platform-node": "catalog:",
@@ -880,7 +880,7 @@
},
"packages/theme": {
"name": "@opencode/theme",
"version": "2.0.3",
"version": "2.0.4",
"dependencies": {
"@opentui/core": "catalog:",
"effect": "catalog:",
@@ -894,7 +894,7 @@
},
"packages/tui": {
"name": "@opencode/tui",
"version": "2.0.3",
"version": "2.0.4",
"dependencies": {
"@opencode/client": "workspace:*",
"@opencode/core": "workspace:*",
@@ -929,7 +929,7 @@
},
"packages/ui": {
"name": "@opencode/ui",
"version": "2.0.3",
"version": "2.0.4",
"dependencies": {
"@kobalte/core": "catalog:",
"@pierre/diffs": "catalog:",
@@ -964,7 +964,7 @@
},
"packages/util": {
"name": "@opencode/util",
"version": "2.0.3",
"version": "2.0.4",
"dependencies": {
"@effect/opentelemetry": "catalog:",
"@effect/platform-node": "catalog:",
@@ -997,7 +997,7 @@
},
"packages/web": {
"name": "@opencode/web",
"version": "2.0.3",
"version": "2.0.4",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -1038,7 +1038,7 @@
},
"services/update": {
"name": "@opencode/update",
"version": "2.0.3",
"version": "2.0.4",
"dependencies": {
"jose": "6.0.11",
"semver": "catalog:",
+1 -1
View File
@@ -168,7 +168,7 @@ else
package_scope="@opencode"
if [ -z "$requested_version" ]; then
metadata=$(curl -fsSL https://opencode.ai/update/api/beta/cli/npm || true)
metadata=$(curl -fsSL https://opencode.ai/update/api/latest/cli/npm || true)
specific_version=$(echo "$metadata" | sed -n 's/.*"version":"\([^"]*\)".*/\1/p')
package=$(echo "$metadata" | sed -n 's/.*"package":"\([^"]*\)".*/\1/p')
+1 -1
View File
@@ -2,7 +2,7 @@
"$schema": "https://json.schemastore.org/package.json",
"name": "opencode",
"description": "AI-powered development tool",
"version": "2.0.3",
"version": "2.0.4",
"private": true,
"type": "module",
"packageManager": "bun@1.4.2",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "2.0.3",
"version": "2.0.4",
"name": "@opencode/ai",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/app",
"version": "2.0.3",
"version": "2.0.4",
"description": "",
"type": "module",
"exports": {
+10 -5
View File
@@ -33,6 +33,15 @@ export default function Layout(props: ParentProps) {
},
install: installUpdate,
}
// A plain object avoids the compiler's conditional-prop memo, which leaks when read from event handlers.
const debugTools = import.meta.env.DEV
? {
get visible() {
return state.debugTools
},
toggle: () => setState("debugTools", (value) => !value),
}
: undefined
return (
<TitlebarRightProvider>
@@ -53,11 +62,7 @@ export default function Layout(props: ParentProps) {
<Titlebar
update={update}
verticalTabs={verticalTabs() ? { mount: state.tabsMount } : undefined}
debugTools={
import.meta.env.DEV
? { visible: state.debugTools, toggle: () => setState("debugTools", (value) => !value) }
: undefined
}
debugTools={debugTools}
/>
<div class="flex flex-1 min-h-0 min-w-0 flex-row">
<Show when={verticalTabs()}>
+1 -1
View File
@@ -792,7 +792,7 @@ function ChannelIndicator(props: {
if (!channel || channel === "prod") return null
const label = () => language.t(`titlebar.channel.${channel}`)
const debug = () => (channel === "dev" ? props.debugTools : undefined)
const debug = () => (channel === "dev" || channel === "local" ? props.debugTools : undefined)
return (
<Tooltip
placement={props.sidebar ? "right" : "bottom"}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/cli",
"version": "2.0.3",
"version": "2.0.4",
"type": "module",
"license": "MIT",
"bin": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/client",
"version": "2.0.3",
"version": "2.0.4",
"type": "module",
"license": "MIT",
"repository": {
+5 -2
View File
@@ -88,8 +88,11 @@ runtime.catalog() // structured tool descriptions
runtime.execute(source) // Effect<CodeMode.Result, never, ToolServices>
```
The Effect environment is inferred from the supplied tools. `onToolCallStart` observes admitted calls with decoded
input; `onToolCallEnd` observes settled outcomes and duration. Both hooks return Effects and must not fail.
The Effect environment is inferred from the supplied tools. `hooks` surround every call the program makes into the
host: `tool.before`/`tool.after` receive `{ name, input }` with decoded input, `extension.before`/`extension.after`
receive `{ extension, name, args }`. An `after` hook also receives how the call ended (`success` with its value,
`failure` with its error, or `interrupted`). A failing `before` hook denies the call, and the program catches the
failure as a thrown error.
### `Values`
+2
View File
@@ -467,6 +467,8 @@ Nothing is exposed unless a host provides it; extension calls are not tool calls
- [x] A host function inside a result becomes a program function whose calls cross the same way, so a result can
carry methods (`res.json()`) whose host closures keep the host state. Diagnostics name it by its path
(`fetch.json`). Like any program function it vanishes at the data boundary.
- [x] Each call to an extension global runs inside the host's `extension.before`/`extension.after` hooks as
`{ extension, name, args }`, with the host's own error on failure; calls to functions inside results do not.
- [x] A host `Promise` becomes a program promise. Whatever host code returns, resolves, throws, or rejects with
crosses the same way, so `catch (e)` receives a copy of the thrown value (an `Error` of the matching type, or
plain data).
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/codemode",
"version": "2.0.3",
"version": "2.0.4",
"description": "Effect-native confined code execution over schema-described tools",
"type": "module",
"license": "MIT",
+16 -4
View File
@@ -7,7 +7,14 @@ import { type Services, type ToolDescription, ToolRuntime } from "./tool-runtime
import type { Tools } from "./tools.js"
/** A tool call admitted during an execution. */
export type { ToolCall, ToolCallEnded, ToolCallHooks, ToolCallStarted, ToolDescription } from "./tool-runtime.js"
export type {
CallResult,
ExtensionInvocation,
Hooks,
ToolCall,
ToolDescription,
ToolInvocation,
} from "./tool-runtime.js"
/** Signature-construction helpers for host-owned catalog instructions. */
export { searchSignature, toolExpression } from "./tool-runtime.js"
@@ -34,9 +41,11 @@ export type ResolvedExecutionLimits = {
}
/** Configuration shared by `CodeMode.make` and `CodeMode.execute`. */
export type Options<Provided extends Record<string, unknown> = {}> = ToolRuntime.ToolCallHooks<Services<Provided>> & {
export type Options<Provided extends Record<string, unknown> = {}> = {
/** Explicit tools exposed to the program as `tools`. */
tools?: Provided & Tools<Services<Provided>>
/** Hooks around every tool and extension call the program makes; see `Hooks`. */
hooks?: ToolRuntime.Hooks<Services<Provided>>
/** Host functions exposed as globals; see `Extension.make`. */
extensions?: ReadonlyArray<Extension>
/** Resource limits enforced on each execution. */
@@ -147,7 +156,10 @@ export const make = <const Provided extends Record<string, unknown> = {}>(
}
}
return {
catalog: prepared.catalog,
execute: (code) => executeProgram(code, prepared, limits, options, (ctx) => extensionGlobals(ctx, extensions)),
get catalog() {
return prepared.catalog
},
execute: (code) =>
executeProgram(code, prepared, limits, options.hooks ?? {}, (ctx) => extensionGlobals(ctx, extensions)),
}
}
+1 -1
View File
@@ -16,7 +16,7 @@ export const executeProgram = <R>(
code: string,
prepared: ToolRuntime.Prepared<R>,
limits: ResolvedExecutionLimits,
hooks: ToolRuntime.ToolCallHooks<R>,
hooks: ToolRuntime.Hooks<R>,
globals?: (ctx: Interpreter<R>) => ReadonlyArray<readonly [string, unknown]>,
): Effect.Effect<Result, never, R> => {
if (code.trim().length === 0) {
+22 -13
View File
@@ -1,6 +1,7 @@
import { Effect } from "effect"
import type { Extension } from "../extension.js"
import { coerceToString } from "../stdlib/value.js"
import { type ExtensionInvocation, hooked } from "../tool-runtime.js"
import type { Interpreter } from "./interpreter.js"
import { createErrorValue, isErrorType } from "./intrinsics.js"
import { MAX_VALUE_DEPTH } from "./limits.js"
@@ -11,7 +12,6 @@ import {
define,
entries,
get,
type Native,
Arr,
Bytes,
DateObj,
@@ -128,26 +128,35 @@ export const extensionGlobals = <R>(
throw typeError(`${label} produced ${describeHost(value)}, which the program cannot hold.`)
}
// A host function as a program function: arguments cross in, and whatever it returns, resolves, throws, or
// rejects with crosses out, so the program catches what the author threw.
const wrap = (value: Function, label: string): Native<R> =>
// A host function as a program function. Arguments cross in; a global's call runs inside the host's extension
// hooks, which see the host's own error on failure; then whatever came back, or was thrown, crosses out so the
// program catches a copy. Functions inside results are part of a value's API and skip the hooks.
const wrap = (value: Function, label: string, describe?: (args: ReadonlyArray<unknown>) => ExtensionInvocation) =>
fn<R>(builtins, value.name, value.length, (_, values) => {
const converted = values.map((item, index) => toHost(item, `Argument ${index + 1} to ${label}`))
const thrown = (reason: unknown) => new Throw(fromHost(reason, label))
const args = values.map((item, index) => toHost(item, `Argument ${index + 1} to ${label}`))
const hooks = ctx.tools.hooks
const settle = (run: Effect.Effect<unknown, unknown, R>) =>
(describe === undefined
? run
: hooked(describe(args), hooks["extension.before"], hooks["extension.after"], run)
).pipe(
Effect.mapError((reason) => new Throw(fromHost(reason, label))),
Effect.map((settled) => fromHost(settled, label)),
)
let result: unknown
try {
result = value.apply(undefined, converted)
result = value.apply(undefined, args)
} catch (reason) {
return Effect.fail(thrown(reason))
return settle(Effect.fail(reason))
}
if (!(result instanceof Promise)) return fromHost(result, label)
return ctx.pending.create(
Effect.map(Effect.tryPromise({ try: () => result, catch: thrown }), (settled) => fromHost(settled, label)),
)
if (!(result instanceof Promise)) return settle(Effect.succeed(result))
return ctx.pending.create(settle(Effect.tryPromise({ try: () => result, catch: (reason) => reason })))
})
return extensions.flatMap((extension) =>
Object.entries(extension.globals).map(([name, value]) => [name, wrap(value, name)] as const),
Object.entries(extension.globals).map(
([name, value]) => [name, wrap(value, name, (args) => ({ extension: extension.name, name, args }))] as const,
),
)
}
+76 -55
View File
@@ -36,26 +36,48 @@ export type ToolCall = {
readonly name: string
}
export type ToolCallStarted = {
readonly index: number
/** A tool call the program is making, with its decoded input. */
export type ToolInvocation = { readonly name: string; readonly input: unknown }
/** A call the program is making to an extension global, with its arguments. */
export type ExtensionInvocation = {
readonly extension: string
readonly name: string
readonly input: unknown
readonly args: ReadonlyArray<unknown>
}
export type ToolCallEnded = {
readonly index: number
readonly name: string
readonly input: unknown
readonly durationMs: number
readonly outcome: "success" | "failure" | "interrupted"
readonly message?: string
/** How a call ended; `after` hooks observe it and cannot change it. */
export type CallResult =
| { readonly status: "success"; readonly value: unknown }
| { readonly status: "failure"; readonly error: unknown }
| { readonly status: "interrupted" }
/** Hooks around every call the program makes into the host. A failing `before` denies the call. */
export type Hooks<R = never> = {
readonly "tool.before"?: ((call: ToolInvocation) => Effect.Effect<void, unknown, R>) | undefined
readonly "tool.after"?: ((call: ToolInvocation, result: CallResult) => Effect.Effect<void, never, R>) | undefined
readonly "extension.before"?: ((call: ExtensionInvocation) => Effect.Effect<void, unknown, R>) | undefined
readonly "extension.after"?:
| ((call: ExtensionInvocation, result: CallResult) => Effect.Effect<void, never, R>)
| undefined
}
export type ToolCallHooks<R = never> = {
/** Observes decoded tool input immediately before tool execution. */
readonly onToolCallStart?: ((call: ToolCallStarted) => Effect.Effect<void, never, R>) | undefined
/** Observes each admitted tool call as it succeeds, fails, or is interrupted. */
readonly onToolCallEnd?: ((call: ToolCallEnded) => Effect.Effect<void, never, R>) | undefined
/** Runs `before`, then `run`, then `after` with how it ended, including when interrupted. */
export const hooked = <Call, A, R>(
call: Call,
before: ((call: Call) => Effect.Effect<void, unknown, R>) | undefined,
after: ((call: Call, result: CallResult) => Effect.Effect<void, never, R>) | undefined,
run: Effect.Effect<A, unknown, R>,
): Effect.Effect<A, unknown, R> => {
const observed =
after === undefined
? run
: Effect.onExit(run, (exit) => {
if (Exit.isSuccess(exit)) return after(call, { status: "success", value: exit.value })
if (Cause.hasInterruptsOnly(exit.cause)) return after(call, { status: "interrupted" })
return after(call, { status: "failure", error: Cause.squash(exit.cause) })
})
return before === undefined ? observed : Effect.andThen(before(call), observed)
}
export type ToolDescription = {
@@ -144,15 +166,24 @@ const flattenTools = <R>(
]
}
const describeTool = <R>(visible: VisibleTool<R>): ToolDescription => ({
path: visible.path,
description: visible.tool.description,
signature: isEmptyInput(visible.tool)
? `${toolExpression(visible.path)}(): Promise<${outputTypeScript(visible.tool, true)}>`
: `${toolExpression(visible.path)}(input: ${inputTypeScript(visible.tool, true)}): Promise<${outputTypeScript(visible.tool, true)}>`,
})
const describeTool = <R>(visible: VisibleTool<R>): ToolDescription => {
let signature: string | undefined
return {
path: visible.path,
description: visible.tool.description,
get signature() {
// Search ranks paths and descriptions first; only returned matches need their schemas rendered.
// Joining the final fragments avoids retaining the rendering's intermediate string ropes in JSC.
return (signature ??= [
toolExpression(visible.path),
isEmptyInput(visible.tool) ? "()" : `(input: ${inputTypeScript(visible.tool, true)})`,
`: Promise<${outputTypeScript(visible.tool, true)}>`,
].join(""))
},
}
}
/** Tools indexed once per runtime: the lookup trie plus the model-facing catalog and search index. */
/** Tools indexed once per runtime, with discovery materialized on demand. */
export type Prepared<R = never> = {
readonly root: ToolNode<R>
readonly catalog: ReadonlyArray<ToolDescription>
@@ -264,12 +295,20 @@ const toSearchEntry = <R>(visible: VisibleTool<R>): SearchEntry => ({
export const prepare = <R>(tools: Tools<R>): Prepared<R> => {
const root = toolTrie(tools)
// Discovery bytes are durable instructions, so order only after canonical-path collisions settle.
const visible = flattenTools(root).sort((left, right) => compareText(left.path, right.path))
let searchIndex: ReadonlyArray<SearchEntry> | undefined
let catalog: ReadonlyArray<ToolDescription> | undefined
return {
root,
catalog: visible.map(describeTool),
searchIndex: visible.map(toSearchEntry),
get catalog() {
return (catalog ??= this.searchIndex.map((entry) => entry.description))
},
get searchIndex() {
// Executing known tools only needs the trie. Render discovery when it is actually read,
// ordering after canonical-path collisions settle so instruction bytes stay deterministic.
return (searchIndex ??= flattenTools(root)
.sort((left, right) => compareText(left.path, right.path))
.map(toSearchEntry))
},
}
}
@@ -316,6 +355,7 @@ export class ToolRuntimeError extends Error {
/** The tool bridge of one execution. Arguments arrive and results leave as JSON; program values never enter. */
export type ToolRuntime<R = never> = {
readonly calls: Array<ToolCall>
readonly hooks: Hooks<R>
readonly execute: (
path: ReadonlyArray<string>,
args: Array<Json | undefined>,
@@ -328,27 +368,10 @@ export type ToolRuntime<R = never> = {
export const make = <R>(
prepared: Prepared<R>,
maxToolCalls: number | undefined,
hooks?: ToolCallHooks<R>,
hooks: Hooks<R> = {},
): ToolRuntime<R> => {
const calls: Array<ToolCall> = []
const root = prepared.root
const searchTool = makeSearchTool(prepared.searchIndex)
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.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 Error ? error.message : Cause.pretty(exit.cause)
return onEnd({ ...call, durationMs, outcome: "failure", message })
}),
)
}
const recordCall = (call: ToolCall): void => {
if (maxToolCalls !== undefined && calls.length >= maxToolCalls) {
@@ -371,14 +394,12 @@ export const make = <R>(
name === "search" ? [] : ["The signature may have changed. Use search to get the current signature."],
),
})
const index = yield* Effect.sync(() => {
recordCall({ name })
return calls.length - 1
})
const call = { index, name, input }
return yield* observeEnd(
yield* Effect.sync(() => recordCall({ name }))
return yield* hooked(
{ name, input },
hooks["tool.before"],
hooks["tool.after"],
Effect.gen(function* () {
if (hooks?.onToolCallStart !== undefined) yield* hooks.onToolCallStart(call)
const raw = yield* Effect.suspend(() => tool.execute(input)).pipe(
Effect.catchCause((cause) => {
if (Cause.hasInterruptsOnly(cause)) return Effect.interrupt
@@ -400,20 +421,20 @@ export const make = <R>(
catch: (cause) => new ToolRuntimeError("InvalidToolOutput", `Invalid output from tool '${name}': ${cause}`),
})
}),
call,
)
})
return {
calls,
hooks,
keys: (path) => namespaceKeys(root, path),
search: (args) => Effect.suspend(() => executeTool("search", searchTool, args)),
search: (args) => Effect.suspend(() => executeTool("search", makeSearchTool(prepared.searchIndex), args)),
execute: (path, args) =>
Effect.suspend(() => {
const segments = canonicalSegments(path)
// Models often write `tools.search(...)` for the bare `search(...)`; honor it unless a tool owns that path.
if (segments.length === 1 && segments[0] === "search" && lookup(root, segments) === undefined)
return executeTool("search", searchTool, args)
return executeTool("search", makeSearchTool(prepared.searchIndex), args)
return executeTool(segments.join("."), resolve(root, path), args)
}),
}
+152 -106
View File
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import { Cause, Effect, Schema } from "effect"
import { CodeMode, Tool, toolError } from "../src/index.js"
import { CodeMode, Extension, Tool, toolError } from "../src/index.js"
const run = (tool: Tool.Tool<never>) =>
Effect.runPromise(CodeMode.make({ tools: { host: { call: tool } } }).execute("return await tools.host.call({})"))
@@ -160,77 +160,154 @@ describe("CodeMode host failure boundary", () => {
})
})
describe("CodeMode tool-call observation", () => {
test("reports the tools actually invoked with decoded input", async () => {
const calls: Array<unknown> = []
describe("CodeMode call hooks", () => {
const ended = (result: CodeMode.CallResult) => {
if (result.status !== "failure") return result.status
return `failure:${result.error instanceof Error ? result.error.message : String(result.error)}`
}
// Every hook logs its event, so the order across tools and extensions is visible.
const observe = (events: Array<string>): CodeMode.Hooks => ({
"tool.before": (call) => Effect.sync(() => void events.push(`tool.before ${JSON.stringify(call)}`)),
"tool.after": (call, result) => Effect.sync(() => void events.push(`tool.after ${call.name} ${ended(result)}`)),
"extension.before": (call) => Effect.sync(() => void events.push(`extension.before ${JSON.stringify(call)}`)),
"extension.after": (call, result) =>
Effect.sync(() => void events.push(`extension.after ${call.name} ${ended(result)}`)),
})
test("surround the tools actually invoked, with decoded input", async () => {
const events: Array<string> = []
const lookup = Tool.make({
description: "Look up a value",
input: Schema.Struct({ query: Schema.String }),
output: Schema.String,
execute: ({ query }) => Effect.succeed(query),
execute: ({ query }) => (query === "boom" ? Effect.fail(toolError("Lookup refused")) : Effect.succeed(query)),
})
const runtime = CodeMode.make({ tools: { context: { lookup } }, hooks: observe(events) })
const result = await Effect.runPromise(
CodeMode.make({
tools: { context: { lookup } },
onToolCallStart: (call) => Effect.sync(() => calls.push(call)),
}).execute(`
const success = await Effect.runPromise(
runtime.execute(`
if (false) await tools.context.lookup({ query: "not called" })
return await tools.context.lookup({ query: "deployment failure" })
`),
)
expect(result.ok).toBe(true)
expect(calls).toStrictEqual([{ index: 0, name: "context.lookup", input: { query: "deployment failure" } }])
})
test("observes settled calls with outcome and duration", async () => {
const events: Array<{ phase: string; index: number; name: string; outcome?: string; message?: string }> = []
const lookup = Tool.make({
description: "Look up a value",
input: Schema.Struct({ query: Schema.String }),
output: Schema.String,
execute: ({ query }) =>
query === "boom"
? Effect.fail(toolError("Lookup refused"))
: query === "defect"
? Effect.die("broken")
: Effect.succeed(query),
})
const runtime = CodeMode.make({
tools: { context: { lookup } },
onToolCallStart: (call) =>
Effect.sync(() => {
events.push({ phase: "start", index: call.index, name: call.name })
}),
onToolCallEnd: (call) =>
Effect.sync(() => {
expect(call.durationMs).toBeGreaterThanOrEqual(0)
events.push({
phase: "end",
index: call.index,
name: call.name,
outcome: call.outcome,
...(call.message === undefined ? {} : { message: call.message }),
})
}),
})
const success = await Effect.runPromise(runtime.execute(`return await tools.context.lookup({ query: "ok" })`))
expect(success.ok).toBe(true)
expect(success).toMatchObject({ ok: true, value: "deployment failure" })
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).toEqual([
'tool.before {"name":"context.lookup","input":{"query":"deployment failure"}}',
"tool.after context.lookup success",
'tool.before {"name":"context.lookup","input":{"query":"boom"}}',
"tool.after context.lookup failure:Lookup refused",
])
})
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: "broken" },
test("surround each call to an extension global, with its arguments, but not functions inside results", async () => {
const events: Array<string> = []
const runtime = CodeMode.make({
extensions: [
Extension.make({
name: "web",
globals: {
fetch: async (url: string, init?: { method?: string }) => ({
url,
method: init?.method ?? "GET",
json: () => 1,
}),
fail: () => {
throw new RangeError("nope")
},
},
}),
],
hooks: observe(events),
})
const result = await Effect.runPromise(
runtime.execute(`
const res = await fetch("https://a.test/", { method: "POST" })
res.json()
try { fail() } catch {}
return res.url
`),
)
expect(result).toMatchObject({ ok: true, value: "https://a.test/", toolCalls: [] })
expect(events).toEqual([
'extension.before {"extension":"web","name":"fetch","args":["https://a.test/",{"method":"POST"}]}',
"extension.after fetch success",
'extension.before {"extension":"web","name":"fail","args":[]}',
"extension.after fail failure:nope",
])
})
test("a failing before hook denies the call; the program catches the host's error", async () => {
const runtime = CodeMode.make({
tools: {
lookup: Tool.make({
description: "Look up",
input: Schema.Struct({}),
output: Schema.String,
execute: () => Effect.succeed("ok"),
}),
},
extensions: [Extension.make({ name: "web", globals: { fetch: async (url: string) => url } })],
hooks: { "extension.before": (call) => Effect.fail(new Error(`${call.name} is not allowed`)) },
})
expect(
await Effect.runPromise(
runtime.execute(`
const denied = await fetch("https://a.test/").catch((e) => [e instanceof Error, e.message])
return [denied, await tools.lookup({})]
`),
),
).toMatchObject({ ok: true, value: [[true, "fetch is not allowed"], "ok"] })
expect(await Effect.runPromise(runtime.execute(`await fetch("https://a.test/")`))).toMatchObject({
ok: false,
error: { kind: "ExecutionFailure", message: "Error: fetch is not allowed" },
})
const refused = CodeMode.make({
tools: {
lookup: Tool.make({
description: "Look up",
input: Schema.Struct({}),
output: Schema.String,
execute: () => Effect.succeed("ok"),
}),
},
hooks: { "tool.before": () => Effect.fail(toolError("lookup is not allowed")) },
})
expect(
await Effect.runPromise(
refused.execute(`try { await tools.lookup({}) } catch (e) { return [e.name, e.message] }`),
),
).toMatchObject({ ok: true, value: ["Error", "lookup is not allowed"], toolCalls: [{ name: "lookup" }] })
expect(await Effect.runPromise(refused.execute(`await tools.lookup({})`))).toMatchObject({
ok: false,
error: { kind: "ToolFailure", message: "lookup is not allowed" },
})
})
test("see a tool defect as the failure the program gets, and an interrupted extension call", async () => {
const events: Array<string> = []
const broken = Tool.make({
description: "Broken",
input: Schema.Struct({}),
output: Schema.String,
execute: () => Effect.die("broken"),
})
const runtime = CodeMode.make({
tools: { broken },
extensions: [Extension.make({ name: "slow", globals: { forever: () => new Promise(() => {}) } })],
hooks: observe(events),
})
expect(await Effect.runPromise(runtime.execute(`await tools.broken({})`))).toMatchObject({ ok: false })
expect(await Effect.runPromise(runtime.execute(`forever(); return "done"`))).toMatchObject({
ok: true,
value: "done",
})
expect(events).toEqual([
'tool.before {"name":"broken","input":{}}',
"tool.after broken failure:broken",
'extension.before {"extension":"slow","name":"forever","args":[]}',
"extension.after forever interrupted",
])
})
@@ -243,15 +320,11 @@ describe("CodeMode tool-call observation", () => {
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({})"),
CodeMode.make({ tools: { host: { call } }, hooks: observe(events) }).execute("return await tools.host.call({})"),
)
expect(exit._tag).toBe("Failure")
expect(events).toEqual(["start", "end:interrupted"])
expect(events).toEqual(['tool.before {"name":"host.call","input":{}}', "tool.after host.call interrupted"])
})
test("observes running calls interrupted during completion", async () => {
@@ -263,39 +336,17 @@ describe("CodeMode tool-call observation", () => {
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"'),
CodeMode.make({ tools: { host: { call } }, hooks: observe(events) }).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"])
expect(events).toEqual(['tool.before {"name":"host.call","input":{}}', "tool.after host.call interrupted"])
})
test("observes calls interrupted by the execution timeout", async () => {
const outcomes: Array<string> = []
const events: Array<string> = []
const call = Tool.make({
description: "Pending",
input: Schema.Struct({}),
@@ -303,15 +354,13 @@ describe("CodeMode tool-call observation", () => {
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({})"),
CodeMode.make({ tools: { host: { call } }, limits: { timeoutMs: 10 }, hooks: observe(events) }).execute(
"return await tools.host.call({})",
),
)
expect(result).toMatchObject({ ok: false, error: { kind: "TimeoutExceeded" } })
expect(outcomes).toEqual(["interrupted"])
expect(events).toEqual(['tool.before {"name":"host.call","input":{}}', "tool.after host.call interrupted"])
})
})
@@ -840,20 +889,17 @@ describe("CodeMode public contract", () => {
}
})
test("search is a counted tool call: it burns maxToolCalls and fires the hooks", async () => {
const started: Array<string> = []
const ended: Array<string> = []
test("search is a counted tool call: it burns maxToolCalls and is observed", async () => {
const observed: Array<CodeMode.ToolInvocation> = []
const limited = CodeMode.make({
tools,
limits: { maxToolCalls: 1 },
onToolCallStart: (call) => Effect.sync(() => void started.push(call.name)),
onToolCallEnd: (call) => Effect.sync(() => void ended.push(`${call.name}:${call.outcome}`)),
hooks: { "tool.before": (call) => Effect.sync(() => void observed.push(call)) },
})
const result = await Effect.runPromise(limited.execute(`search({}); return search({})`))
expect(result.ok).toBe(false)
if (!result.ok) expect(result.error.kind).toBe("ToolCallLimitExceeded")
expect(started).toEqual(["search"])
expect(ended).toEqual(["search:success"])
expect(observed).toEqual([{ name: "search", input: {} }])
})
test("search is an opaque, shadowable global like other built-ins", async () => {
@@ -1089,7 +1135,7 @@ describe("CodeMode public contract", () => {
})
const runtime = CodeMode.make({
tools: { math: { double: transformed } },
onToolCallStart: (call) => Effect.sync(() => observed.push(call.input)),
hooks: { "tool.before": (call) => Effect.sync(() => void observed.push(call.input)) },
})
const success = await Effect.runPromise(runtime.execute(`return await tools.math.double({ value: "21" })`))
+10
View File
@@ -192,6 +192,16 @@ describe("values are converted at the boundary, never shared", () => {
expect((await failure(`big()`, target)).message).toContain("big produced a bigint")
})
test("a value that cannot come out carries a location like any built-in error, sync or async", async () => {
const target = CodeMode.make({
extensions: [
Extension.make({ name: "odd", globals: { sym: () => Symbol("s"), later: async () => Symbol("s") } }),
],
})
expect((await failure(`sym()`, target)).location).toEqual((await failure(`JSON.parse("{")`, target)).location)
expect((await failure(`await later()`, target)).location).toBeDefined()
})
test("a class instance cannot come out", async () => {
class Other {}
const target = CodeMode.make({
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/console-app",
"version": "2.0.3",
"version": "2.0.4",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/console-core",
"version": "2.0.3",
"version": "2.0.4",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/console-function",
"version": "2.0.3",
"version": "2.0.4",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/console-mail",
"version": "2.0.3",
"version": "2.0.4",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/console-support",
"version": "2.0.3",
"version": "2.0.4",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "2.0.3",
"version": "2.0.4",
"name": "@opencode/core",
"type": "module",
"license": "MIT",
+44 -27
View File
@@ -13,6 +13,7 @@ import type {
import { Effect, Ref, Schema, Semaphore } from "effect"
import { definition, normalizedName } from "../tool/runtime.js"
import { CodeModeCatalog } from "./catalog.js"
import { CodeModeWeb } from "./web.js"
const ExecuteFile = Schema.Struct({
data: Schema.String,
@@ -59,8 +60,8 @@ export type Inventory = {
// Invariant model-facing guidance; the changing tool catalog is delivered through Instructions.
const description = [
"Run JavaScript in a confined Code Mode runtime 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`.",
"Run JavaScript in a confined Code Mode runtime to script tool calls and HTTP requests and compose their results.",
"`fetch` is available for HTTP requests. Imports, direct filesystem access, and timers are unavailable; all other external access goes through `tools`.",
"Within `{ code }`, the only callable tools are those explicitly listed in the Code Mode catalog instructions or returned by the `search` function. 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.",
@@ -82,9 +83,9 @@ export const create = (
const files = yield* Ref.make<Array<CollectedFiles>>([])
const calls = yield* Ref.make<Array<ExecuteCall>>([])
const lock = Semaphore.makeUnsafe(1)
const updateCalls = (update: (items: Array<ExecuteCall>) => Array<ExecuteCall>) =>
const record = (update: (items: Array<ExecuteCall>) => Array<ExecuteCall>) =>
lock.withPermit(
Ref.updateAndGet(calls, update).pipe(Effect.flatMap((toolCalls) => context.progress({ toolCalls }))),
Ref.updateAndGet(calls, update).pipe(Effect.tap((toolCalls) => context.progress({ toolCalls }))),
)
const result = yield* runtime(
inventory,
@@ -103,27 +104,7 @@ export const create = (
const text = content.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n")
return text === "" ? null : text
}),
{
onToolCallStart: ({ index, name, input }) => {
const shown = displayInput(input)
return updateCalls((items) => {
const next = [...items]
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
})
},
},
progressHooks(record),
).execute(code)
const toolCalls = yield* Ref.get(calls)
const collected = (yield* Ref.get(files))
@@ -158,6 +139,42 @@ export const create = (
} satisfies Info
}
// Rows appear in start order; the same call object arrives at both hooks, so a call finds its row again.
function progressHooks(record: (update: (items: Array<ExecuteCall>) => Array<ExecuteCall>) => Effect.Effect<unknown>) {
const rows = new WeakMap<object, number>()
const start = (call: object, entry: ExecuteCall) =>
record((items) => {
rows.set(call, items.length)
return [...items, entry]
})
const settle = (call: object, result: CodeMode.CallResult) => {
const index = rows.get(call)
if (index === undefined) return Effect.void
return record((items) => {
const next = [...items]
next[index] = { ...items[index], status: result.status === "success" ? "completed" : "error" }
return next
})
}
return {
"tool.before": (call) => {
const shown = displayInput(call.input)
return start(call, { tool: call.name, status: "running", ...(shown ? { input: shown } : {}) })
},
"tool.after": settle,
// Only listed extension functions get a row; anything else stays out of the TUI.
"extension.before": (call) => {
switch (call.name) {
case "fetch":
return start(call, { tool: call.name, status: "running", input: CodeModeWeb.display(call.args) })
default:
return Effect.void
}
},
"extension.after": settle,
} satisfies CodeMode.Hooks
}
export const catalog = (inventory: Inventory) => {
const pinned = new Set(
Array.from(inventory.tools.values())
@@ -204,7 +221,7 @@ function renderCatalog(root: CatalogNode): ReadonlyArray<CodeModeCatalog.Tool |
function runtime(
inventory: Inventory,
executeTool: (name: string, tool: Info, input: unknown) => Effect.Effect<unknown, unknown>,
hooks?: CodeMode.ToolCallHooks,
hooks?: CodeMode.Hooks,
) {
// A path may carry namespace metadata, a callable tool, child tools, or all three.
const root: ToolNode = { children: new Map() }
@@ -219,7 +236,7 @@ function runtime(
})
}
const tools = renderTools(root)
return CodeMode.make<typeof tools>({ tools, ...hooks })
return CodeMode.make<typeof tools>({ tools, extensions: [CodeModeWeb.extension], hooks })
}
function getNode<T>(root: Node<T>, path: string) {
+46
View File
@@ -0,0 +1,46 @@
export * as CodeModeWeb from "./web.js"
import { Extension } from "@opencode/codemode"
const TIMEOUT_MS = 30_000
type Init = {
readonly method?: string
readonly headers?: Record<string, string> | Array<[string, string]>
readonly body?: string | Uint8Array<ArrayBuffer> | URLSearchParams
}
const fetch = async (input: string | URL, init: Init = {}) => {
const response = await globalThis.fetch(input, {
method: init.method,
headers: init.headers,
body: init.body,
signal: AbortSignal.timeout(TIMEOUT_MS),
})
const bytes = await response.bytes()
const headers = Object.fromEntries(response.headers)
const text = () => new TextDecoder().decode(bytes)
return {
url: response.url,
status: response.status,
statusText: response.statusText,
ok: response.ok,
redirected: response.redirected,
headers: {
get: (name: string) => headers[name.toLowerCase()] ?? null,
has: (name: string) => name.toLowerCase() in headers,
entries: () => Object.entries(headers),
},
text: async () => text(),
json: async () => JSON.parse(text()) as unknown,
bytes: async () => bytes,
}
}
export const extension = Extension.make({ name: "web", globals: { fetch } })
/** What to show for a fetch call: its method and URL. */
export const display = (args: ReadonlyArray<unknown>) => {
const [input, init] = args as [string | URL, Init | undefined]
return { method: init?.method?.toUpperCase() ?? "GET", url: String(input) }
}
+6 -1
View File
@@ -168,7 +168,12 @@ export const provider = (options: Options): OAuthClientProvider => {
if (!resource) return identity
if (!checkResourceAllowed({ requestedResource: identity, configuredResource: resource }))
throw new Error(`Protected resource ${resource} does not cover ${identity}`)
return new URL(resource)
const canonical = new URL(resource)
// The transport dials the configured URL with extra query parameters and some servers echo
// that back as the resource. Query is transport detail, not identity: the token stays bound
// to the configured URL so a later refresh names the same resource the login did.
if (canonical.origin === identity.origin && canonical.pathname === identity.pathname) return identity
return canonical
},
...(options.clientMetadataUrl ? { clientMetadataUrl: options.clientMetadataUrl } : {}),
...(redirect ? { state: () => redirect.state } : {}),
+76 -57
View File
@@ -68,7 +68,7 @@ export interface Interface extends State.Transformable<Editor> {
export class Service extends Context.Service<Service, Interface>()("@opencode/Model") {}
type Data = {
models: Map<Provider.ID, Map<ID, MutableInfo>>
models: Map<Provider.ID, ReadonlyMap<ID, Info>>
defaultModel?: { providerID: Provider.ID; modelID: ID }
}
@@ -82,59 +82,73 @@ const layer = Layer.effect(
const state: State.Interface<Data, Editor> = State.create<Data, Editor>({
name: "model",
initial: () => ({
models: new Map(
(input?.available ?? []).map((record) => [
record.provider.id,
new Map(
Array.from(record.models, ([id, model]) => [
id,
{
...structuredClone(model),
id,
providerID: record.provider.id,
} as MutableInfo,
]),
),
]),
),
models: new Map((input?.available ?? []).map((record) => [record.provider.id, record.models])),
}),
editor: (data) => ({
list: (providerID) =>
providerID === undefined
? Array.from(data.models.values()).flatMap((models) => Array.from(models.values()))
: Array.from(data.models.get(providerID)?.values() ?? []),
get: (providerID, modelID) => data.models.get(providerID)?.get(modelID),
update: (providerID, modelID, update) => {
// Model edits cannot create/enable a provider or bypass its availability decision.
const models = data.models.get(providerID)
if (!models) return
const model = models.get(modelID) ?? (Info.default(providerID, modelID) as MutableInfo)
update(model)
model.id = modelID
model.providerID = providerID
const provider = input?.records.get(providerID)?.provider
AISDKNative.rewrite(model, {
specifier: model.package ?? provider?.package,
providerID,
canonical: model.canonical ?? provider?.canonical,
modelID: model.modelID ?? modelID,
})
models.set(modelID, model)
},
remove: (providerID, modelID) => {
data.models.get(providerID)?.delete(modelID)
},
default: {
get: () => data.defaultModel,
set: (providerID, modelID) => {
data.defaultModel = { providerID, modelID }
editor: (data) => {
// Definitions are shared across Locations; a provider's map and a model are copied before their first edit.
const owned = new WeakSet<ReadonlyMap<ID, Info>>()
const drafts = new WeakSet<Info>()
const writable = (providerID: Provider.ID) => {
const current = data.models.get(providerID)
if (!current) return undefined
if (owned.has(current)) return current as Map<ID, Info>
const copy = new Map(current)
owned.add(copy)
data.models.set(providerID, copy)
return copy
}
const draft = (providerID: Provider.ID, modelID: ID) => {
const models = writable(providerID)
if (!models) return undefined
const current = models.get(modelID)
if (!current) return undefined
if (drafts.has(current)) return current as MutableInfo
const copy = structuredClone(current) as MutableInfo
drafts.add(copy)
models.set(modelID, copy)
return copy
}
return {
list: (providerID) => {
const ids = providerID === undefined ? Array.from(data.models.keys()) : [providerID]
return ids.flatMap((id) =>
Array.from(data.models.get(id)?.keys() ?? []).flatMap((modelID) => draft(id, modelID) ?? []),
)
},
},
provider: {
list: () => Array.from(input?.records.values() ?? []),
get: (providerID) => input?.records.get(providerID),
},
}),
get: draft,
update: (providerID, modelID, update) => {
// Model edits cannot create/enable a provider or bypass its availability decision.
const models = writable(providerID)
if (!models) return
const model = draft(providerID, modelID) ?? (Info.default(providerID, modelID) as MutableInfo)
update(model)
model.id = modelID
model.providerID = providerID
const provider = input?.records.get(providerID)?.provider
AISDKNative.rewrite(model, {
specifier: model.package ?? provider?.package,
providerID,
canonical: model.canonical ?? provider?.canonical,
modelID: model.modelID ?? modelID,
})
drafts.add(model)
models.set(modelID, model)
},
remove: (providerID, modelID) => {
writable(providerID)?.delete(modelID)
},
default: {
get: () => data.defaultModel,
set: (providerID, modelID) => {
data.defaultModel = { providerID, modelID }
},
},
provider: {
list: () => Array.from(input?.records.values() ?? []),
get: (providerID) => input?.records.get(providerID),
},
}
},
// read() also refreshes dependencies changed inside a State.batch before notification.
notify: () => notify,
})
@@ -147,6 +161,8 @@ const layer = Layer.effect(
byProvider: ReadonlyMap<Provider.ID, ReadonlyMap<ID, Info>>
}
| undefined
// An unedited model keeps its shared definition object across rebuilds, so its merged output is reusable.
const merged = new WeakMap<Info, { provider: Provider.Info | undefined; model: Info }>()
const read = Effect.fn("Model.snapshot")(function* () {
while (true) {
const current = yield* providers.snapshot()
@@ -165,9 +181,10 @@ const layer = Layer.effect(
return [
providerID,
new Map(
Array.from(models, ([id, model]) => [
id,
{
Array.from(models, ([id, model]) => {
const reusable = merged.get(model)
if (reusable && reusable.provider === provider) return [id, reusable.model]
const value = {
...model,
...(provider?.canonical === undefined ? {} : { canonical: provider.canonical }),
package: model.package ?? provider?.package,
@@ -176,8 +193,10 @@ const layer = Layer.effect(
settings: Provider.mergeOverlay(provider?.settings, model.settings),
headers: Provider.mergeHeaders(provider?.headers, model.headers),
body: Provider.mergeOverlay(provider?.body, model.body),
} satisfies Info,
]),
} satisfies Info
merged.set(model, { provider, model: value })
return [id, value]
}),
),
]
}),
+22 -13
View File
@@ -228,12 +228,18 @@ export interface Interface extends State.Transformable<Editor> {
export class Service extends Context.Service<Service, Interface>()("@opencode/Provider") {}
// Every location references the same index for a shared immutable definition array.
const definitions = new WeakMap<readonly Model.Info[], ReadonlyMap<Model.ID, Model.Info>>()
function index(models: readonly Model.Info[]) {
const cached = definitions.get(models)
const definitions = new WeakMap<readonly Model.Info[], Map<ID, ReadonlyMap<Model.ID, Model.Info>>>()
function index(providerID: ID, models: readonly Model.Info[]) {
const indexes = definitions.get(models) ?? new Map<ID, ReadonlyMap<Model.ID, Model.Info>>()
const cached = indexes.get(providerID)
if (cached) return cached
const result = freeze(new Map(models.map((model) => [model.id, model])), true)
definitions.set(models, result)
// Model shares these definitions without copying, so a foreign definition takes this provider's identity here.
const result = freeze(
new Map(models.map((model) => [model.id, model.providerID === providerID ? model : { ...model, providerID }])),
true,
)
indexes.set(providerID, result)
definitions.set(models, indexes)
return result
}
@@ -281,7 +287,7 @@ const layer = Layer.effect(
add: (definition) => {
records.set(definition.info.id, {
provider: structuredClone(definition.info) as MutableInfo,
models: index(definition.models),
models: index(definition.info.id, definition.models),
sourceConnection: definition.sourceConnection,
})
},
@@ -300,7 +306,7 @@ const layer = Layer.effect(
},
models: {
set: (id, values) => {
entry(id).models = index(values)
entry(id).models = index(id, values)
},
update: (providerID, modelID, update) => {
const record = entry(providerID)
@@ -338,7 +344,7 @@ const layer = Layer.effect(
// Registrations may outlive a borrowed service layer; their later disposal must
// not query dependencies that have already closed.
yield* Effect.addFinalizer(() => State.shutdown(state.reload()))
let cached: { records: Snapshot["records"]; access: string; value: Snapshot } | undefined
let cached: { records: Snapshot["records"]; value: Snapshot } | undefined
const snapshot = Effect.fn("Provider.snapshot")(function* () {
while (true) {
const revision = integrations.revision()
@@ -347,10 +353,6 @@ const layer = Layer.effect(
const connections = yield* integrations.list()
// Either fold can disable a plugin that also contributed to the other domain.
if (revision !== integrations.revision() || records !== state.get()) continue
const access = JSON.stringify(
connections.map((integration) => [integration.id, integration.connections.map(IntegrationConnection.key)]),
)
if (cached?.records === records && cached.access === access) return cached.value
const byID = new Map(connections.map((integration) => [integration.id, integration]))
const available = Array.from(records.values()).filter((record) => {
if (record.provider.activation === "disabled") return false
@@ -366,8 +368,15 @@ const layer = Layer.effect(
if (integration?.connections.length) return true
return record.provider.integrationID === undefined && !integration
})
// A credential change that leaves the same definitions available is not a catalog change.
if (
cached?.records === records &&
cached.value.available.length === available.length &&
cached.value.available.every((record, index) => record === available[index])
)
return cached.value
const value = freeze({ records, available, providers: available.map((record) => record.provider) }, true)
cached = { records, access, value }
cached = { records, value }
return value
}
})
+18 -6
View File
@@ -154,6 +154,7 @@ const layer = Layer.effect(
}
})
let catalog: { data: Data; names: string; value: CodeModeCatalog.Inventory } | undefined
const state = State.create<Data, Editor>({
name: "tool",
initial: () => ({
@@ -201,8 +202,9 @@ const layer = Layer.effect(
editor.tools.delete(id)
},
}),
notify: (value) =>
Effect.forEach(
notify: (value) => {
catalog = undefined
return Effect.forEach(
value.errors,
({ kind, name, namespace, error }) =>
Effect.logError(`Skipping invalid ${kind} registration`, {
@@ -211,7 +213,8 @@ const layer = Layer.effect(
error: error.message,
}),
{ discard: true },
),
)
},
})
return Service.of({
@@ -219,15 +222,16 @@ const layer = Layer.effect(
reload: state.reload,
snapshot: Effect.fn("Tool.snapshot")((permissions) =>
Effect.sync(() => {
const data = state.get()
const active = new Map<string, Tool.Info>()
const rules = permissions ?? []
for (const [name, tool] of state.get().tools) {
for (const [name, tool] of data.tools) {
if (whollyDisabled(tool.options?.permission ?? name, rules)) continue
active.set(name, tool)
}
const direct = new Map(Array.from(active).filter(([, tool]) => tool.options?.codemode === false))
const codeModeTools = new Map(Array.from(active).filter(([, tool]) => tool.options?.codemode !== false))
const namespaces = state.get().namespaces
const namespaces = data.namespaces
const codeModeInventory = { tools: codeModeTools, namespaces }
const codeModeEnabled = !whollyDisabled("execute", rules)
const codeModeTool = codeModeEnabled
@@ -237,7 +241,15 @@ const layer = Layer.effect(
),
)
: undefined
const codeModeCatalog = codeModeEnabled ? CodeModeTool.catalog(codeModeInventory) : undefined
const names = Array.from(codeModeTools.keys()).join("\0")
// Discovery is immutable for a registry revision and visible tool set. Keep request
// definitions/executors fresh, but share the much larger rendered catalog across steps.
const codeModeCatalog = !codeModeEnabled
? undefined
: catalog?.data === data && catalog.names === names
? catalog.value
: CodeModeTool.catalog(codeModeInventory)
if (codeModeCatalog) catalog = { data, names, value: codeModeCatalog }
return {
...(codeModeCatalog === undefined ? {} : { codeModeCatalog }),
definitions: [
+160 -1
View File
@@ -1,7 +1,7 @@
import { describe, expect } from "bun:test"
import { LanguageModel } from "@opencode/ai"
import { OpenAIChat } from "@opencode/ai/protocols"
import { Effect, Fiber, Layer, Stream } from "effect"
import { Effect, Fiber, Layer, Ref, Stream } from "effect"
import { Integration } from "@opencode/core/integration"
import { Credential } from "@opencode/core/credential"
import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder"
@@ -148,6 +148,156 @@ describe("Provider and Model", () => {
}).pipe(Effect.scoped, Effect.provide(localProviderLayer))
})
it.effect("reuses the model catalog across credential switches", () =>
Effect.gen(function* () {
const providers = yield* Provider.Service
const models = yield* Model.Service
const integrations = yield* Integration.Service
const credentials = yield* Credential.Service
const bus = yield* Bus.Service
const providerID = Provider.ID.make("switchable")
const integrationID = Integration.ID.make(providerID)
yield* integrations.transform((editor) => editor.update(integrationID, () => {}))
yield* providers.transform((editor) =>
editor.add({
info: Provider.Info.empty(providerID),
models: [Model.Info.default(providerID, Model.ID.make("chat"))],
}),
)
expect(yield* models.available()).toEqual([])
const log = yield* Ref.make<string[]>([])
yield* bus.subscribe().pipe(
Stream.runForEach((event) => Ref.update(log, (types) => [...types, event.type])),
Effect.forkScoped({ startImmediately: true }),
)
yield* Effect.yieldNow
const updates = Ref.get(log).pipe(
Effect.map((types) => types.filter((type) => type === Model.Event.Updated.type).length),
)
const first = yield* credentials.create({
integrationID,
value: Credential.Key.make({ type: "key", key: "first" }),
})
const materialized = yield* models.available()
expect(materialized).toHaveLength(1)
// Credential events reach Model on other fibers; let the connect land before switching.
yield* settle(updates.pipe(Effect.map((count) => count >= 1)))
const second = yield* credentials.create({
integrationID,
value: Credential.Key.make({ type: "key", key: "second" }),
})
expect(yield* models.available()).toBe(materialized)
yield* credentials.activate(first.id)
expect(yield* models.available()).toBe(materialized)
yield* credentials.remove(first.id)
expect(yield* models.available()).toBe(materialized)
// Disconnecting is a real change whose model.updated follows every earlier one in the log,
// so once it has arrived the total shows whether any switch above published as well.
yield* credentials.remove(second.id)
expect(yield* models.available()).toEqual([])
yield* settle(
Ref.get(log).pipe(
Effect.map(
(types) =>
types.lastIndexOf(Model.Event.Updated.type) > types.lastIndexOf(Credential.Event.Updated.type),
),
),
)
expect(yield* updates).toBe(2)
}),
)
it.effect("persists direct edits to models returned by list and get", () =>
Effect.gen(function* () {
const providers = yield* Provider.Service
const models = yield* Model.Service
const providerID = Provider.ID.make("direct")
const listed = Model.ID.make("listed")
const fetched = Model.ID.make("fetched")
const definitions = [Model.Info.default(providerID, listed), Model.Info.default(providerID, fetched)]
yield* providers.transform((editor) =>
editor.add({ info: { ...Provider.Info.empty(providerID), activation: "enabled" }, models: definitions }),
)
yield* models.transform((editor) => {
editor.list(providerID).forEach((model) => {
model.limit.context = 4096
})
required(editor.get(providerID, fetched)).capabilities.input.push("pdf")
})
expect(yield* models.get(providerID, listed)).toMatchObject({
limit: { context: 4096 },
capabilities: { input: ["text", "image"] },
})
expect(yield* models.get(providerID, fetched)).toMatchObject({
limit: { context: 4096 },
capabilities: { input: ["text", "image", "pdf"] },
})
expect(definitions.map((model) => model.limit.context)).toEqual([200_000, 200_000])
}),
)
it.effect("gives foreign definitions the registering provider's identity", () =>
Effect.gen(function* () {
const providers = yield* Provider.Service
const models = yield* Model.Service
const source = Provider.ID.make("source")
const mirror = Provider.ID.make("mirror")
const modelID = Model.ID.make("chat")
const definitions = [Model.Info.default(source, modelID)]
yield* providers.transform((editor) => {
editor.add({ info: { ...Provider.Info.empty(source), activation: "enabled" }, models: definitions })
editor.add({ info: { ...Provider.Info.empty(mirror), activation: "enabled" }, models: definitions })
})
expect((yield* models.available()).map((model) => model.providerID).toSorted()).toEqual([mirror, source])
expect(yield* models.get(mirror, modelID)).toMatchObject({ id: modelID, providerID: mirror })
expect((yield* providers.snapshot()).records.get(source)?.models.get(modelID)).toBe(definitions[0])
}),
)
it.effect("keeps materialized models when another provider becomes available", () =>
Effect.gen(function* () {
const providers = yield* Provider.Service
const models = yield* Model.Service
const integrations = yield* Integration.Service
const credentials = yield* Credential.Service
const existing = Provider.ID.make("existing")
const added = Provider.ID.make("added")
const edited = Model.ID.make("edited")
const untouched = Model.ID.make("untouched")
yield* integrations.transform((editor) => editor.update(Integration.ID.make(added), () => {}))
yield* providers.transform((editor) => {
editor.add({
info: { ...Provider.Info.empty(existing), activation: "enabled" },
models: [Model.Info.default(existing, edited), Model.Info.default(existing, untouched)],
})
editor.add({ info: Provider.Info.empty(added), models: [Model.Info.default(added, Model.ID.make("chat"))] })
})
yield* models.transform((editor) =>
editor.update(existing, edited, (model) => {
model.limit.context = 1
}),
)
const before = required(yield* models.get(existing, untouched))
yield* credentials.create({
integrationID: Integration.ID.make(added),
value: Credential.Key.make({ type: "key", key: "secret" }),
})
expect((yield* models.available()).map((model) => model.providerID).toSorted()).toEqual([
added,
existing,
existing,
])
expect(yield* models.get(existing, untouched)).toBe(before)
expect(yield* models.get(existing, edited)).toMatchObject({ limit: { context: 1 } })
}),
)
it.effect("derives availability from a provider's integration", () => {
const integrationID = Integration.ID.make("gateway")
const providerID = Provider.ID.make("remote")
@@ -511,3 +661,12 @@ describe("Provider and Model", () => {
}),
)
})
// Bus subscribers run on their own fibers, so give them turns until the condition holds.
const settle = Effect.fnUntraced(function* (condition: Effect.Effect<boolean>) {
for (let attempt = 0; attempt < 100; attempt++) {
if (yield* condition) return
yield* Effect.yieldNow
}
return yield* Effect.die("Timed out waiting for catalog events")
})
+38
View File
@@ -324,6 +324,44 @@ describe("MCP OAuth", () => {
expect(probes.some((probe) => probe.includes("codemode"))).toBe(false)
})
test("keeps the configured URL as the resource when metadata echoes the dialed query", async () => {
const tokenRequests: URLSearchParams[] = []
const server = Bun.serve({
port: 0,
async fetch(request) {
const url = new URL(request.url)
if (url.pathname === "/.well-known/oauth-authorization-server")
return Response.json({
issuer: url.origin,
authorization_endpoint: `${url.origin}/authorize`,
token_endpoint: `${url.origin}/token`,
response_types_supported: ["code"],
})
if (url.pathname === "/.well-known/oauth-protected-resource/mcp")
return Response.json({ resource: `${url.origin}/mcp${url.search}`, authorization_servers: [url.origin] })
if (request.method === "POST" && url.pathname === "/token") {
tokenRequests.push(new URLSearchParams(await request.text()))
return Response.json({ access_token: "next", token_type: "Bearer" })
}
return new Response(null, { status: 404 })
},
})
const url = `${server.url.origin}/mcp`
const oauthProvider = await connectProvider(
remote(url),
memoryCredentials([credential({ access: "expired", refresh: "refresh", url })]),
)
// The transport dials with ?codemode=false and follows the 401 challenge to metadata that echoes it.
await auth(oauthProvider, {
serverUrl: `${url}?codemode=false`,
resourceMetadataUrl: new URL(`${server.url.origin}/.well-known/oauth-protected-resource/mcp?codemode=false`),
}).finally(() => server.stop(true))
expect(tokenRequests[0]?.get("grant_type")).toBe("refresh_token")
expect(tokenRequests[0]?.get("resource")).toBe(url)
})
test("finds resource metadata through the 401 header when the well-known path is not served", async () => {
const server = Bun.serve({
port: 0,
+2 -2
View File
@@ -22,8 +22,8 @@ const createCodeMode = (tools: ReadonlyMap<string, Info>) =>
test("execute describes invariant Code Mode behavior", () => {
expect(createCodeMode(new Map()).description).toBe(
[
"Run JavaScript in a confined Code Mode runtime 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`.",
"Run JavaScript in a confined Code Mode runtime to script tool calls and HTTP requests and compose their results.",
"`fetch` is available for HTTP requests. Imports, direct filesystem access, and timers are unavailable; all other external access goes through `tools`.",
"Within `{ code }`, the only callable tools are those explicitly listed in the Code Mode catalog instructions or returned by the `search` function. 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.",
+3 -3
View File
@@ -151,13 +151,13 @@ describeHg("Vcs mercurial", () => {
})
const vcs = yield* Vcs.Service
const bus = yield* Bus.Service
expect(yield* vcs.info()).toEqual({ branch: { current: "default", default: "default" } })
expect(yield* vcs.info()).toEqual({ provider: "hg", branch: { current: "default", default: "default" } })
const updated = yield* bus
.subscribe(VcsEvent.BranchUpdated)
.pipe(Stream.take(1), Stream.runHead, Effect.forkScoped({ startImmediately: true }))
yield* Effect.promise(() => hg(directory, "branch", "-q", "feature"))
expect(yield* vcs.info()).toEqual({ branch: { current: "default", default: "default" } })
expect(yield* vcs.info()).toEqual({ provider: "hg", branch: { current: "default", default: "default" } })
yield* bus.publish(FileSystem.Event.Changed, {
file: path.join(directory, ".hg", "branch"),
@@ -167,7 +167,7 @@ describeHg("Vcs mercurial", () => {
_tag: "Some",
value: { location: { directory }, data: { branch: "feature" } },
})
expect(yield* vcs.info()).toEqual({ branch: { current: "feature", default: "default" } })
expect(yield* vcs.info()).toEqual({ provider: "hg", branch: { current: "feature", default: "default" } })
}),
),
)
+10 -10
View File
@@ -126,7 +126,7 @@ describe("Vcs", () => {
editor.default.set("custom")
})
expect(yield* vcs.info()).toEqual({ branch: { current: "feature", default: "main" } })
expect(yield* vcs.info()).toEqual({ provider: "custom", branch: { current: "feature", default: "main" } })
expect(yield* vcs.base()).toBeNull()
expect(yield* vcs.branches()).toEqual(["feature", "main"])
expect(yield* vcs.status()).toEqual([{ file: "file.txt", additions: 1, deletions: 0, status: "added" }])
@@ -146,10 +146,10 @@ describe("Vcs", () => {
Effect.gen(function* () {
const vcs = yield* Vcs.Service
const registration = yield* vcs.transform((editor) => editor.add(provider({ id: "git" })))
expect(yield* vcs.info()).toEqual({ branch: { current: "feature", default: "main" } })
expect(yield* vcs.info()).toEqual({ provider: "git", branch: { current: "feature", default: "main" } })
yield* registration.dispose
expect(yield* vcs.info()).toEqual({ branch: { current: "main", default: undefined } })
expect(yield* vcs.info()).toEqual({ provider: "git", branch: { current: "main", default: undefined } })
}),
),
)
@@ -188,7 +188,7 @@ describe("Vcs", () => {
}),
)
expect(reads).toEqual(["final"])
expect(yield* vcs.info()).toEqual({ branch: { current: "final" } })
expect(yield* vcs.info()).toEqual({ provider: "custom", branch: { current: "final" } })
}),
)
@@ -456,7 +456,7 @@ describe("Vcs", () => {
),
)
expect((yield* vcs.status())[0]?.file).toBe("config.txt")
expect(yield* vcs.info()).toEqual({ branch: { current: "initial" } })
expect(yield* vcs.info()).toEqual({ provider: "git", branch: { current: "initial" } })
yield* Deferred.succeed(accepted, undefined)
}),
).pipe(Effect.forkScoped({ startImmediately: true }))
@@ -466,7 +466,7 @@ describe("Vcs", () => {
yield* Deferred.succeed(release, undefined)
yield* Fiber.join(configured)
expect(Option.getOrUndefined(yield* Fiber.join(updates))?.data.branch).toBe("config")
expect(yield* vcs.info()).toEqual({ branch: { current: "config" } })
expect(yield* vcs.info()).toEqual({ provider: "git", branch: { current: "config" } })
expect(reads).toEqual(["initial", "filesystem", "config"])
}).pipe(Effect.ensuring(Deferred.succeed(release, undefined)))
}),
@@ -502,7 +502,7 @@ describe("Vcs", () => {
})
yield* bus.publish(Done, {})
const events = (yield* Fiber.join(updates)).filter((event) => event.type === VcsEvent.BranchUpdated.type)
expect(yield* vcs.info()).toEqual({ branch: { current: "listener" } })
expect(yield* vcs.info()).toEqual({ provider: "custom", branch: { current: "listener" } })
expect(events.length).toBeGreaterThanOrEqual(2)
expect(events.at(-1)?.data.branch).toBe((yield* vcs.info()).branch.current)
}).pipe(Effect.ensuring(unsubscribe))
@@ -567,7 +567,7 @@ describe("Vcs", () => {
})
const vcs = yield* Vcs.Service
const bus = yield* Bus.Service
expect(yield* vcs.info()).toEqual({ branch: { current: "main", default: undefined } })
expect(yield* vcs.info()).toEqual({ provider: "git", branch: { current: "main", default: undefined } })
const updated = yield* bus
.subscribe(VcsEvent.BranchUpdated)
@@ -575,14 +575,14 @@ describe("Vcs", () => {
yield* Effect.promise(() => $`git checkout -q -b feature`.cwd(directory).quiet())
yield* bus.publish(FileSystem.Event.Changed, { file: path.join(directory, "HEAD"), event: "change" })
expect(yield* vcs.info()).toEqual({ branch: { current: "main", default: undefined } })
expect(yield* vcs.info()).toEqual({ provider: "git", branch: { current: "main", default: undefined } })
yield* bus.publish(FileSystem.Event.Changed, { file: path.join(directory, ".git", "HEAD"), event: "change" })
expect(yield* Fiber.join(updated)).toMatchObject({
_tag: "Some",
value: { location: { directory }, data: { branch: "feature" } },
})
expect(yield* vcs.info()).toEqual({ branch: { current: "feature", default: "main" } })
expect(yield* vcs.info()).toEqual({ provider: "git", branch: { current: "feature", default: "main" } })
}),
),
)
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode/desktop",
"private": true,
"version": "2.0.3",
"version": "2.0.4",
"type": "module",
"license": "MIT",
"homepage": "https://opencode.ai",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/enterprise",
"version": "2.0.3",
"version": "2.0.4",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/function",
"version": "2.0.3",
"version": "2.0.4",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "2.0.3",
"version": "2.0.4",
"name": "@opencode/http-recorder",
"description": "Record and replay Effect HTTP and WebSocket traffic with deterministic cassettes",
"type": "module",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/httpapi-codegen",
"version": "2.0.3",
"version": "2.0.4",
"private": true,
"type": "module",
"exports": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/latex",
"version": "2.0.3",
"version": "2.0.4",
"private": true,
"type": "module",
"exports": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/merman",
"version": "2.0.3",
"version": "2.0.4",
"private": true,
"type": "module",
"exports": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/plugin-browser",
"version": "2.0.3",
"version": "2.0.4",
"description": "OpenCode's desktop browser plugin",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/plugin",
"version": "2.0.3",
"version": "2.0.4",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/protocol",
"version": "2.0.3",
"version": "2.0.4",
"type": "module",
"license": "MIT",
"repository": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/schema",
"version": "2.0.3",
"version": "2.0.4",
"type": "module",
"license": "MIT",
"repository": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "2.0.3",
"version": "2.0.4",
"name": "@opencode/sdk",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/server",
"version": "2.0.3",
"version": "2.0.4",
"type": "module",
"license": "MIT",
"repository": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/session-ui",
"version": "2.0.3",
"version": "2.0.4",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/simulation",
"version": "2.0.3",
"version": "2.0.4",
"type": "module",
"license": "MIT",
"repository": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/stats-app",
"version": "2.0.3",
"version": "2.0.4",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/stats-core",
"version": "2.0.3",
"version": "2.0.4",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/stats-server",
"version": "2.0.3",
"version": "2.0.4",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/theme",
"version": "2.0.3",
"version": "2.0.4",
"type": "module",
"license": "MIT",
"repository": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/tui",
"version": "2.0.3",
"version": "2.0.4",
"private": true,
"type": "module",
"license": "MIT",
+25 -3
View File
@@ -2965,7 +2965,7 @@ function ShellDisplay(props: {
return stripAnsi(props.output?.trim() ?? "")
})
const maxLines = 10
const maxChars = createMemo(() => maxLines * Math.max(20, ctx.width - 6))
const maxChars = createMemo(() => maxLines * Math.max(20, ctx.width - 6 - (isRunning() ? 2 : 0)))
const prefix = createMemo(() => (workdir() && workdir() !== "." ? `cd ${workdir()} && ` : ""))
const input = createMemo(() => (props.command ? `${isRunning() ? "" : "$ "}${prefix()}${props.command}` : ""))
const collapsed = createMemo(() => collapseShellOutput(input(), output(), maxLines, maxChars()))
@@ -2991,8 +2991,30 @@ function ShellDisplay(props: {
)
}
>
<Show when={isRunning()} fallback={<text fg={theme.text.default}>{limitedInput()}</text>}>
<Spinner color={color()}>{limitedInput()}</Spinner>
<Show
when={isRunning()}
fallback={
<text
fg={theme.text.default}
wrapMode={expanded() ? "word" : "char"}
maxHeight={expanded() ? undefined : 2}
>
{limitedInput()}
</text>
}
>
<box flexDirection="row" gap={1}>
<Spinner color={color()} />
<text
fg={color()}
wrapMode={expanded() ? "word" : "char"}
maxHeight={expanded() ? undefined : 2}
flexGrow={1}
minWidth={0}
>
{limitedInput()}
</text>
</box>
</Show>
<Show when={limitedOutput()}>
<text fg={theme.text.subdued}>{limitedOutput()}</text>
+56 -14
View File
@@ -1,3 +1,6 @@
import { Locale } from "./locale"
import { stringWidth } from "./string-width"
export function collapseToolOutput(output: string, maxLines: number, maxChars: number) {
const lines = output.split("\n")
if (lines.length <= maxLines && Array.from(output).length <= maxChars) {
@@ -21,21 +24,60 @@ export function collapseToolOutput(output: string, maxLines: number, maxChars: n
}
export function collapseShellOutput(input: string, output: string, maxLines: number, maxChars: number) {
const content = [input, output].filter(Boolean).join("\n\n")
const collapsed = collapseToolOutput(content, maxLines, maxChars)
if (!collapsed.overflow) return { input, output, overflow: false }
if (!input) return { input, output: collapseTail(output, maxLines, maxChars), overflow: true }
if (!output) return { input: collapsed.output, output, overflow: true }
if (!input) {
const collapsed = collapseToolOutput(output, maxLines, maxChars)
return {
input,
output: collapsed.overflow ? collapseTail(output, maxLines, maxChars) : output,
overflow: collapsed.overflow,
}
}
const reserved = Math.max(1, Math.floor(maxChars / maxLines))
const command = collapseToolOutput(
input,
Math.max(1, maxLines - 2),
Math.max(1, maxChars - reserved - 2),
).output
const lines = Math.max(1, maxLines - command.split("\n").length - 1)
const chars = Math.max(1, maxChars - Array.from(command).length - 2)
return { input: command, output: collapseTail(output, lines, chars), overflow: true }
const commandLines = Math.min(2, maxLines)
const lineChars = Math.max(1, Math.floor(maxChars / Math.max(1, maxLines)))
const command = collapseShellCommand(input, commandLines, lineChars)
if (!output) return { input: command.output, output, overflow: command.overflow }
const lines = Math.max(1, maxLines - command.lines - 1)
const chars = Math.max(1, maxChars - Array.from(command.output).length - 2)
const collapsed = collapseToolOutput(output, lines, chars)
return {
input: command.output,
output: collapsed.overflow ? collapseTail(output, lines, chars) : output,
overflow: command.overflow || collapsed.overflow,
}
}
function collapseShellCommand(input: string, maxLines: number, lineWidth: number) {
const visible: string[] = []
let lines = 1
let width = 0
const overflow = Locale.graphemes(input).some((segment) => {
if (segment === "\n") {
if (lines >= maxLines) return true
visible.push(segment)
lines++
width = 0
return false
}
const next = stringWidth(segment)
if (width + next > lineWidth) {
if (lines >= maxLines) return true
lines++
width = 0
}
visible.push(segment)
width += next
return false
})
if (!overflow) return { output: input, overflow, lines }
if (width >= lineWidth) {
const removed = visible.pop()
if (removed !== undefined && removed !== "\n") width -= stringWidth(removed)
}
return { output: visible.join("") + "…", overflow, lines }
}
function collapseTail(output: string, maxLines: number, maxChars: number) {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/ui",
"version": "2.0.3",
"version": "2.0.4",
"type": "module",
"license": "MIT",
"repository": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/util",
"version": "2.0.3",
"version": "2.0.4",
"type": "module",
"license": "MIT",
"repository": {
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "@opencode/web",
"type": "module",
"license": "MIT",
"version": "2.0.3",
"version": "2.0.4",
"scripts": {
"dev": "astro dev",
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "opencode",
"displayName": "opencode",
"description": "opencode for VS Code",
"version": "2.0.3",
"version": "2.0.4",
"publisher": "sst-dev",
"repository": {
"type": "git",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/update",
"version": "2.0.3",
"version": "2.0.4",
"private": true,
"type": "module",
"scripts": {