mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-16 05:46:23 +00:00
Compare commits
11
Commits
subagent-model
...
v2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9add9384be | ||
|
|
005522786f | ||
|
|
5379a2dfe0 | ||
|
|
6ee156e0b9 | ||
|
|
7c3c50bac2 | ||
|
|
194a006932 | ||
|
|
3c1f87d2d4 | ||
|
|
c57733a647 | ||
|
|
9bf5faa978 | ||
|
|
a062f4e803 | ||
|
|
fc49b16584 |
@@ -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:",
|
||||
|
||||
@@ -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
@@ -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,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,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/app",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.4",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -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()}>
|
||||
|
||||
@@ -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,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,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": {
|
||||
|
||||
@@ -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`
|
||||
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}),
|
||||
}
|
||||
|
||||
@@ -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" })`))
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/console-app",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.4",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -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,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,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,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/console-support",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.4",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
@@ -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
@@ -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]
|
||||
}),
|
||||
),
|
||||
]
|
||||
}),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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" } })
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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,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,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/enterprise",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.4",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -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,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,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,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,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,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,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,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,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,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,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,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/session-ui",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.4",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -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,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,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,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,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,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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/ui",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.4",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,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": {
|
||||
|
||||
Reference in New Issue
Block a user