Compare commits

...
Author SHA1 Message Date
usrnk1 3c8301e155 feat(desktop): adapt composer suggestion menu 2026-09-16 15:47:36 +02:00
Shoubhit Dash cda2bc5100 test(core): match the one minute Console config refresh interval (#49327) 2026-09-16 17:37:14 +05:30
Daniel Polito ba753b383f fix(core): retry acknowledged websocket read failures (#49183) 2026-09-16 16:15:19 +05:30
Simon Klee eb052f70b4 fix: mini reconnect (#49301)
A stalled catalog refresh kept reconnection from completing and prevented
new prompts. Allow the stream to reconnect independently of catalog state.
2026-09-16 08:05:23 +00:00
Victor Navarro 5eaed68c2b fix(core): refresh Console config every minute (#49233) 2026-09-16 09:28:29 +02:00
Aiden Cline 7d7962b2fa feat(core): let subagents pick a model and expose models tool (#49194) 2026-09-16 01:16:55 -05:00
Aiden Cline 0537fd47c7 feat(tui): open execute call details dialog on click (#49259) 2026-09-16 01:10:08 -05:00
Aiden Cline 9add9384be refactor(core): list which extension calls show under execute (#49281) 2026-09-16 00:30:54 -05:00
Luke Parker 005522786f fix(app): enable debug tools toggle on local channel (#49278) 2026-09-16 04:58:54 +00:00
Dax 5379a2dfe0 fix(codemode): reuse tool discovery catalogs
from OpenCode
2026-09-16 00:52:00 -04:00
Aiden Cline 6ee156e0b9 fix(core): keep the configured MCP URL as the OAuth resource (#49241) 2026-09-15 23:50:36 -05:00
opencode 7c3c50bac2 sync release versions for v2.0.4 2026-09-16 04:41:12 +00:00
Aiden Cline 194a006932 feat(core): expose fetch to code mode scripts (#49235) 2026-09-15 23:29:22 -05:00
Dax Raad 3c1f87d2d4 fix(tui): limit shell command previews 2026-09-16 00:21:05 -04:00
Aiden Cline c57733a647 refactor(codemode): before/after hooks for tool and extension calls (#49242) 2026-09-15 23:05:06 -05:00
Dax 9bf5faa978 fix(core): reuse model catalog across credential changes (#49255) 2026-09-15 23:10:29 -04:00
Dax a062f4e803 test(core): expect the vcs provider in info results (#49264) 2026-09-15 23:01:16 -04:00
84 changed files with 1872 additions and 624 deletions
+35 -35
View File
@@ -32,7 +32,7 @@
},
"packages/ai": {
"name": "@opencode/ai",
"version": "2.0.3",
"version": "2.0.4",
"dependencies": {
"@aws-sdk/credential-providers": "3.1057.0",
"@opencode/schema": "workspace:*",
@@ -54,7 +54,7 @@
},
"packages/app": {
"name": "@opencode/app",
"version": "2.0.3",
"version": "2.0.4",
"dependencies": {
"@corvu/drawer": "catalog:",
"@dnd-kit/abstract": "0.5.0",
@@ -113,7 +113,7 @@
},
"packages/cli": {
"name": "@opencode/cli",
"version": "2.0.3",
"version": "2.0.4",
"bin": {
"opencode2": "./bin/opencode2.cjs",
},
@@ -177,7 +177,7 @@
},
"packages/client": {
"name": "@opencode/client",
"version": "2.0.3",
"version": "2.0.4",
"dependencies": {
"@opencode/protocol": "workspace:*",
"@opencode/schema": "workspace:*",
@@ -203,7 +203,7 @@
},
"packages/codemode": {
"name": "@opencode/codemode",
"version": "2.0.3",
"version": "2.0.4",
"dependencies": {
"acorn": "8.15.0",
"effect": "catalog:",
@@ -217,7 +217,7 @@
},
"packages/console/app": {
"name": "@opencode/console-app",
"version": "2.0.3",
"version": "2.0.4",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -253,7 +253,7 @@
},
"packages/console/core": {
"name": "@opencode/console-core",
"version": "2.0.3",
"version": "2.0.4",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -280,7 +280,7 @@
},
"packages/console/function": {
"name": "@opencode/console-function",
"version": "2.0.3",
"version": "2.0.4",
"dependencies": {
"@openauthjs/openauth": "0.0.0-20250322224806",
"@opencode/console-core": "workspace:*",
@@ -297,7 +297,7 @@
},
"packages/console/mail": {
"name": "@opencode/console-mail",
"version": "2.0.3",
"version": "2.0.4",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -321,7 +321,7 @@
},
"packages/console/support": {
"name": "@opencode/console-support",
"version": "2.0.3",
"version": "2.0.4",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@opencode/console-core": "workspace:*",
@@ -341,7 +341,7 @@
},
"packages/core": {
"name": "@opencode/core",
"version": "2.0.3",
"version": "2.0.4",
"dependencies": {
"@ai-sdk/cohere": "3.0.27",
"@ai-sdk/gateway": "3.0.104",
@@ -409,7 +409,7 @@
},
"packages/desktop": {
"name": "@opencode/desktop",
"version": "2.0.3",
"version": "2.0.4",
"dependencies": {
"@zip.js/zip.js": "2.7.62",
"electron-context-menu": "4.1.2",
@@ -461,7 +461,7 @@
},
"packages/enterprise": {
"name": "@opencode/enterprise",
"version": "2.0.3",
"version": "2.0.4",
"dependencies": {
"@hono/standard-validator": "catalog:",
"@opencode-ai/sdk": "1.18.21",
@@ -498,7 +498,7 @@
},
"packages/function": {
"name": "@opencode/function",
"version": "2.0.3",
"version": "2.0.4",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -514,7 +514,7 @@
},
"packages/http-recorder": {
"name": "@opencode/http-recorder",
"version": "2.0.3",
"version": "2.0.4",
"dependencies": {
"@effect/platform-node-shared": "4.0.0-rc.112",
},
@@ -533,7 +533,7 @@
},
"packages/httpapi-codegen": {
"name": "@opencode/httpapi-codegen",
"version": "2.0.3",
"version": "2.0.4",
"dependencies": {
"effect": "catalog:",
"prettier": "3.6.2",
@@ -546,7 +546,7 @@
},
"packages/latex": {
"name": "@opencode/latex",
"version": "2.0.3",
"version": "2.0.4",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opentui/core": "catalog:",
@@ -560,7 +560,7 @@
},
"packages/merman": {
"name": "@opencode/merman",
"version": "2.0.3",
"version": "2.0.4",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opentui/core": "catalog:",
@@ -575,7 +575,7 @@
},
"packages/plugin": {
"name": "@opencode/plugin",
"version": "2.0.3",
"version": "2.0.4",
"dependencies": {
"@ai-sdk/provider": "3.0.8",
"@opencode/ai": "workspace:*",
@@ -614,7 +614,7 @@
},
"packages/plugin-browser": {
"name": "@opencode/plugin-browser",
"version": "2.0.3",
"version": "2.0.4",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opencode/schema": "workspace:*",
@@ -644,7 +644,7 @@
},
"packages/protocol": {
"name": "@opencode/protocol",
"version": "2.0.3",
"version": "2.0.4",
"dependencies": {
"@opencode/schema": "workspace:*",
"effect": "catalog:",
@@ -659,7 +659,7 @@
},
"packages/schema": {
"name": "@opencode/schema",
"version": "2.0.3",
"version": "2.0.4",
"dependencies": {
"@standard-schema/spec": "catalog:",
"effect": "catalog:",
@@ -683,7 +683,7 @@
},
"packages/sdk": {
"name": "@opencode/sdk",
"version": "2.0.3",
"version": "2.0.4",
"dependencies": {
"@opencode/client": "workspace:*",
"@opencode/core": "workspace:*",
@@ -704,7 +704,7 @@
},
"packages/server": {
"name": "@opencode/server",
"version": "2.0.3",
"version": "2.0.4",
"dependencies": {
"@effect/platform-node": "catalog:",
"@effect/platform-node-shared": "catalog:",
@@ -726,7 +726,7 @@
},
"packages/session-ui": {
"name": "@opencode/session-ui",
"version": "2.0.3",
"version": "2.0.4",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode/client": "workspace:*",
@@ -761,7 +761,7 @@
},
"packages/simulation": {
"name": "@opencode/simulation",
"version": "2.0.3",
"version": "2.0.4",
"dependencies": {
"@opencode/ai": "workspace:*",
"@opencode/core": "workspace:*",
@@ -781,7 +781,7 @@
},
"packages/stats/app": {
"name": "@opencode/stats-app",
"version": "2.0.3",
"version": "2.0.4",
"dependencies": {
"@ibm/plex": "6.4.1",
"@kobalte/core": "catalog:",
@@ -815,7 +815,7 @@
},
"packages/stats/core": {
"name": "@opencode/stats-core",
"version": "2.0.3",
"version": "2.0.4",
"dependencies": {
"@aws-sdk/client-athena": "3.933.0",
"@planetscale/database": "1.19.0",
@@ -834,7 +834,7 @@
},
"packages/stats/server": {
"name": "@opencode/stats-server",
"version": "2.0.3",
"version": "2.0.4",
"dependencies": {
"@aws-sdk/client-firehose": "3.933.0",
"@effect/platform-node": "catalog:",
@@ -880,7 +880,7 @@
},
"packages/theme": {
"name": "@opencode/theme",
"version": "2.0.3",
"version": "2.0.4",
"dependencies": {
"@opentui/core": "catalog:",
"effect": "catalog:",
@@ -894,7 +894,7 @@
},
"packages/tui": {
"name": "@opencode/tui",
"version": "2.0.3",
"version": "2.0.4",
"dependencies": {
"@opencode/client": "workspace:*",
"@opencode/core": "workspace:*",
@@ -929,7 +929,7 @@
},
"packages/ui": {
"name": "@opencode/ui",
"version": "2.0.3",
"version": "2.0.4",
"dependencies": {
"@kobalte/core": "catalog:",
"@pierre/diffs": "catalog:",
@@ -964,7 +964,7 @@
},
"packages/util": {
"name": "@opencode/util",
"version": "2.0.3",
"version": "2.0.4",
"dependencies": {
"@effect/opentelemetry": "catalog:",
"@effect/platform-node": "catalog:",
@@ -997,7 +997,7 @@
},
"packages/web": {
"name": "@opencode/web",
"version": "2.0.3",
"version": "2.0.4",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -1038,7 +1038,7 @@
},
"services/update": {
"name": "@opencode/update",
"version": "2.0.3",
"version": "2.0.4",
"dependencies": {
"jose": "6.0.11",
"semver": "catalog:",
+1 -1
View File
@@ -2,7 +2,7 @@
"$schema": "https://json.schemastore.org/package.json",
"name": "opencode",
"description": "AI-powered development tool",
"version": "2.0.3",
"version": "2.0.4",
"private": true,
"type": "module",
"packageManager": "bun@1.4.2",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "2.0.3",
"version": "2.0.4",
"name": "@opencode/ai",
"type": "module",
"license": "MIT",
@@ -122,6 +122,35 @@ story("does not mask or pad controls when they fit", async ({ mount }) => {
await expect(controls).toHaveCSS("padding-inline-end", "0px")
})
story("grows suggestions while preserving visible timeline context", async ({ mount }) => {
const component = await mount("opencode-composer-flow--constrained-command-suggestions")
const boundary = component.locator('[data-slot="composer-suggestion-boundary-story"]')
const suggestions = component.locator('[data-component="composer-suggestions"]')
await expect(suggestions).toHaveCSS("max-height", "166px")
await expect(suggestions).toHaveCSS("scroll-padding-bottom", "18px")
await expect.poll(() => suggestions.evaluate((element) => element.scrollHeight > element.clientHeight)).toBe(true)
await expect
.poll(async () => {
const menu = await suggestions.boundingBox()
const items = await suggestions.locator("[data-suggestion-id]").evaluateAll((elements) =>
elements.map((element) => {
const rect = element.getBoundingClientRect()
return { top: rect.top, bottom: rect.bottom }
}),
)
if (!menu) return false
const bottom = menu.y + menu.height
return items.some((item) => item.top < bottom && item.bottom > bottom)
})
.toBe(true)
await boundary.evaluate((element) => {
element.style.height = "400px"
})
await expect(suggestions).toHaveCSS("max-height", "306px")
})
// ThemeProvider writes resolved token values into a <style> block, so toggling data-color-scheme by hand
// leaves every --v2-* variable at its previous value. Switch themes through the Storybook global instead.
for (const [theme, background] of [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/app",
"version": "2.0.3",
"version": "2.0.4",
"description": "",
"type": "module",
"exports": {
+24 -1
View File
@@ -57,6 +57,8 @@ function ComposerStory(props: {
continueOnStop?: boolean
longLabels?: boolean
alternate?: "queue" | "steer"
manySuggestions?: boolean
suggestionBoundary?: () => HTMLElement | undefined
}) {
const [draft, setDraft] = createStore<ComposerPersistedState>({
prompt: props.prompt ?? [{ type: "text", content: "", start: 0, end: 0 }],
@@ -93,6 +95,15 @@ function ComposerStory(props: {
const commands: ComposerSuggestion[] = [
{ id: "command.test", kind: "command", label: "/test", trigger: "test", title: "Run tests" },
{ id: "command.review", kind: "command", label: "/review", trigger: "review", title: "Review changes" },
...(props.manySuggestions
? Array.from({ length: 12 }, (_, index) => ({
id: `command.example-${index}`,
kind: "command" as const,
label: `/example-${index}`,
trigger: `example-${index}`,
title: `Run example ${index}`,
}))
: []),
]
const context: ComposerSuggestion[] = [
{
@@ -206,7 +217,7 @@ function ComposerStory(props: {
<output class="text-12-regular text-text-weak" aria-live="polite">
{story.activity}
</output>
<Composer model={model} borderUnderlay />
<Composer model={model} borderUnderlay suggestionBoundary={props.suggestionBoundary} />
</div>
)
}
@@ -273,6 +284,18 @@ export const SlashSuggestions = { render: () => <ComposerStory suggestions="comm
export const ContextSuggestions = { render: () => <ComposerStory suggestions="context" /> }
function ConstrainedCommandSuggestionsStory() {
let boundary: HTMLDivElement | undefined
return (
<div class="mx-auto w-full max-w-200">
<div ref={boundary} data-slot="composer-suggestion-boundary-story" class="h-60" />
<ComposerStory suggestions="command" manySuggestions suggestionBoundary={() => boundary} />
</div>
)
}
export const ConstrainedCommandSuggestions = { render: () => <ConstrainedCommandSuggestionsStory /> }
export const RunningAndStopping = { render: () => <ComposerStory working stopping label="Session is running" /> }
export const SteeringFollowUp = {
+7 -1
View File
@@ -12,7 +12,12 @@ import { formatKeybind, useCommand } from "@/shell/commands/command"
import { useLanguage } from "@/runtime/i18n/language"
import type { ComposerModel } from "./model"
export function Composer(props: { class?: string; model: ComposerModel; borderUnderlay?: boolean }) {
export function Composer(props: {
class?: string
model: ComposerModel
borderUnderlay?: boolean
suggestionBoundary?: () => HTMLElement | undefined
}) {
const dialog = useDialog()
const command = useCommand()
const language = useLanguage()
@@ -28,6 +33,7 @@ export function Composer(props: { class?: string; model: ComposerModel; borderUn
attachShortcut={command.keybind("file.attach")}
alternateKeybind={[formatKeybind("mod", language.t), "↵"]}
exitShellKeybind={[formatKeybind("esc", language.t)]}
suggestionBoundary={props.suggestionBoundary}
modelControl={
<ComposerModelControl
loading={props.model.model.loading}
+36 -3
View File
@@ -1,5 +1,6 @@
import { createEffect, createMemo, createSignal, For, onCleanup, onMount, Show, type JSX } from "solid-js"
import { createStore } from "solid-js/store"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { FileIcon } from "@opencode/ui/file-icon"
import { Icon } from "@opencode/ui/icon"
import { IconButton } from "@opencode/ui/icon-button"
@@ -36,6 +37,12 @@ export type {
} from "../types"
export type ComposerMode = "normal" | "shell"
const COMPOSER_SUGGESTION_MAX_HEIGHT = 320
const COMPOSER_SUGGESTION_ROW_HEIGHT = 28
const COMPOSER_SUGGESTION_ROW_PEEK = 18
const COMPOSER_SUGGESTION_TOP_PADDING = 8
const COMPOSER_SUGGESTION_SEARCH_HEIGHT = 28
const COMPOSER_SUGGESTION_CONTEXT_RESERVE = 80
export type ComposerEditorProps = {
controller: ComposerEditorModel
@@ -49,6 +56,7 @@ export type ComposerEditorProps = {
attachShortcut?: string
alternateKeybind?: string[]
exitShellKeybind?: string[]
suggestionBoundary?: () => HTMLElement | undefined
}
export function ComposerEditor(props: ComposerEditorProps) {
@@ -114,6 +122,7 @@ export function ComposerEditor(props: ComposerEditorProps) {
<ComposerEditorPopover
emptyLabel={i18n.t("ui.promptInput.noMatchingItems")}
items={props.controller.suggestions()}
boundary={props.suggestionBoundary}
activeID={state.popover.type === "closed" ? undefined : state.popover.activeID}
search={
state.popover.type === "command-menu"
@@ -717,18 +726,29 @@ export function ComposerEditorPopover(props: {
onValueChange: (value: string) => void
onKeyDown: (event: KeyboardEvent) => void
}
boundary?: () => HTMLElement | undefined
onActiveChange: (item: ComposerSuggestion) => void
onSelect: (item: ComposerSuggestion) => void
}) {
const [store, setStore] = createStore({ maxHeight: COMPOSER_SUGGESTION_MAX_HEIGHT })
const resize = (height: number) =>
setStore("maxHeight", composerSuggestionMaxHeight(height, props.search !== undefined))
createEffect(() => resize(props.boundary?.()?.clientHeight ?? COMPOSER_SUGGESTION_MAX_HEIGHT * 2))
createResizeObserver(
props.boundary ?? (() => undefined),
(rect) => resize(rect.height),
)
return (
<div
data-component="composer-suggestions"
class="absolute inset-x-0 -top-2 z-40 flex max-h-80 -translate-y-full flex-col overflow-auto rounded-xl bg-v2-background-bg-base p-2 shadow-[var(--v2-elevation-raised)] no-scrollbar"
class="absolute inset-x-0 -top-2 z-40 flex -translate-y-full scroll-pb-[18px] flex-col overflow-auto rounded-xl bg-v2-background-bg-base p-2 shadow-[var(--v2-elevation-raised)] no-scrollbar"
style={{ "max-height": `${store.maxHeight}px` }}
onMouseDown={(event) => event.preventDefault()}
>
<Show when={props.search}>
{(search) => (
<div class="px-2 py-1">
<div class="shrink-0 px-2 py-1">
<input
ref={(element) => requestAnimationFrame(() => element.focus())}
value={search().value}
@@ -752,7 +772,7 @@ export function ComposerEditorPopover(props: {
type="button"
data-suggestion-id={item.id}
data-active={props.activeID === item.id ? "" : undefined}
class="flex w-full items-center gap-2 rounded-md px-2 py-1 text-start hover:bg-v2-overlay-simple-overlay-hover"
class="flex h-7 w-full shrink-0 items-center gap-2 rounded-md px-2 py-1 text-start hover:bg-v2-overlay-simple-overlay-hover"
classList={{ "bg-v2-overlay-simple-overlay-hover": props.activeID === item.id }}
onPointerMove={() => props.onActiveChange(item)}
onClick={() => props.onSelect(item)}
@@ -777,6 +797,19 @@ export function ComposerEditorPopover(props: {
)
}
function composerSuggestionMaxHeight(boundaryHeight: number, search: boolean) {
const reserve = Math.min(COMPOSER_SUGGESTION_CONTEXT_RESERVE, boundaryHeight / 4)
const limit = Math.min(COMPOSER_SUGGESTION_MAX_HEIGHT, boundaryHeight - reserve)
const chrome = COMPOSER_SUGGESTION_TOP_PADDING + (search ? COMPOSER_SUGGESTION_SEARCH_HEIGHT : 0)
if (limit < chrome + COMPOSER_SUGGESTION_ROW_HEIGHT + COMPOSER_SUGGESTION_ROW_PEEK) return limit
return (
chrome +
Math.floor((limit - chrome - COMPOSER_SUGGESTION_ROW_PEEK) / COMPOSER_SUGGESTION_ROW_HEIGHT) *
COMPOSER_SUGGESTION_ROW_HEIGHT +
COMPOSER_SUGGESTION_ROW_PEEK
)
}
// "Steer ⌘⏎" / "Queue ⌘⏎" hint next to the submit button: submits with the
// delivery opposite to what plain Enter does. Visible only while the queue
// exposes an alternate (turn running and composer holding a value), so it
+5 -2
View File
@@ -213,7 +213,10 @@ export function createActiveSessionRegion(input: {
export type ActiveSessionRegionModel = ReturnType<typeof createActiveSessionRegion>
export function ActiveSessionComposerRegion(props: { model: SessionComposerController }) {
export function ActiveSessionComposerRegion(props: {
model: SessionComposerController
suggestionBoundary: () => HTMLElement | undefined
}) {
return (
<SessionComposerRegion
controller={props.model.region}
@@ -221,7 +224,7 @@ export function ActiveSessionComposerRegion(props: { model: SessionComposerContr
<div class="relative">
<SessionQueuePanel queue={props.model.queue} />
<div class="relative z-10">
<Composer model={props.model.composer} borderUnderlay />
<Composer model={props.model.composer} borderUnderlay suggestionBoundary={props.suggestionBoundary} />
</div>
</div>
}
+1 -1
View File
@@ -325,7 +325,7 @@ export function SessionScreen(props: { session: SessionModel }) {
</div>
<Show when={composer.active()} keyed>
{(model) => <ActiveSessionComposerRegion model={model} />}
{(model) => <ActiveSessionComposerRegion model={model} suggestionBoundary={timeline.scroller} />}
</Show>
</>
)
+10 -5
View File
@@ -33,6 +33,15 @@ export default function Layout(props: ParentProps) {
},
install: installUpdate,
}
// A plain object avoids the compiler's conditional-prop memo, which leaks when read from event handlers.
const debugTools = import.meta.env.DEV
? {
get visible() {
return state.debugTools
},
toggle: () => setState("debugTools", (value) => !value),
}
: undefined
return (
<TitlebarRightProvider>
@@ -53,11 +62,7 @@ export default function Layout(props: ParentProps) {
<Titlebar
update={update}
verticalTabs={verticalTabs() ? { mount: state.tabsMount } : undefined}
debugTools={
import.meta.env.DEV
? { visible: state.debugTools, toggle: () => setState("debugTools", (value) => !value) }
: undefined
}
debugTools={debugTools}
/>
<div class="flex flex-1 min-h-0 min-w-0 flex-row">
<Show when={verticalTabs()}>
+1 -1
View File
@@ -792,7 +792,7 @@ function ChannelIndicator(props: {
if (!channel || channel === "prod") return null
const label = () => language.t(`titlebar.channel.${channel}`)
const debug = () => (channel === "dev" ? props.debugTools : undefined)
const debug = () => (channel === "dev" || channel === "local" ? props.debugTools : undefined)
return (
<Tooltip
placement={props.sidebar ? "right" : "bottom"}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/cli",
"version": "2.0.3",
"version": "2.0.4",
"type": "module",
"license": "MIT",
"bin": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/client",
"version": "2.0.3",
"version": "2.0.4",
"type": "module",
"license": "MIT",
"repository": {
+5 -2
View File
@@ -88,8 +88,11 @@ runtime.catalog() // structured tool descriptions
runtime.execute(source) // Effect<CodeMode.Result, never, ToolServices>
```
The Effect environment is inferred from the supplied tools. `onToolCallStart` observes admitted calls with decoded
input; `onToolCallEnd` observes settled outcomes and duration. Both hooks return Effects and must not fail.
The Effect environment is inferred from the supplied tools. `hooks` surround every call the program makes into the
host: `tool.before`/`tool.after` receive `{ name, input }` with decoded input, `extension.before`/`extension.after`
receive `{ extension, name, args }`. An `after` hook also receives how the call ended (`success` with its value,
`failure` with its error, or `interrupted`). A failing `before` hook denies the call, and the program catches the
failure as a thrown error.
### `Values`
+2
View File
@@ -467,6 +467,8 @@ Nothing is exposed unless a host provides it; extension calls are not tool calls
- [x] A host function inside a result becomes a program function whose calls cross the same way, so a result can
carry methods (`res.json()`) whose host closures keep the host state. Diagnostics name it by its path
(`fetch.json`). Like any program function it vanishes at the data boundary.
- [x] Each call to an extension global runs inside the host's `extension.before`/`extension.after` hooks as
`{ extension, name, args }`, with the host's own error on failure; calls to functions inside results do not.
- [x] A host `Promise` becomes a program promise. Whatever host code returns, resolves, throws, or rejects with
crosses the same way, so `catch (e)` receives a copy of the thrown value (an `Error` of the matching type, or
plain data).
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/codemode",
"version": "2.0.3",
"version": "2.0.4",
"description": "Effect-native confined code execution over schema-described tools",
"type": "module",
"license": "MIT",
+16 -4
View File
@@ -7,7 +7,14 @@ import { type Services, type ToolDescription, ToolRuntime } from "./tool-runtime
import type { Tools } from "./tools.js"
/** A tool call admitted during an execution. */
export type { ToolCall, ToolCallEnded, ToolCallHooks, ToolCallStarted, ToolDescription } from "./tool-runtime.js"
export type {
CallResult,
ExtensionInvocation,
Hooks,
ToolCall,
ToolDescription,
ToolInvocation,
} from "./tool-runtime.js"
/** Signature-construction helpers for host-owned catalog instructions. */
export { searchSignature, toolExpression } from "./tool-runtime.js"
@@ -34,9 +41,11 @@ export type ResolvedExecutionLimits = {
}
/** Configuration shared by `CodeMode.make` and `CodeMode.execute`. */
export type Options<Provided extends Record<string, unknown> = {}> = ToolRuntime.ToolCallHooks<Services<Provided>> & {
export type Options<Provided extends Record<string, unknown> = {}> = {
/** Explicit tools exposed to the program as `tools`. */
tools?: Provided & Tools<Services<Provided>>
/** Hooks around every tool and extension call the program makes; see `Hooks`. */
hooks?: ToolRuntime.Hooks<Services<Provided>>
/** Host functions exposed as globals; see `Extension.make`. */
extensions?: ReadonlyArray<Extension>
/** Resource limits enforced on each execution. */
@@ -147,7 +156,10 @@ export const make = <const Provided extends Record<string, unknown> = {}>(
}
}
return {
catalog: prepared.catalog,
execute: (code) => executeProgram(code, prepared, limits, options, (ctx) => extensionGlobals(ctx, extensions)),
get catalog() {
return prepared.catalog
},
execute: (code) =>
executeProgram(code, prepared, limits, options.hooks ?? {}, (ctx) => extensionGlobals(ctx, extensions)),
}
}
+1 -1
View File
@@ -16,7 +16,7 @@ export const executeProgram = <R>(
code: string,
prepared: ToolRuntime.Prepared<R>,
limits: ResolvedExecutionLimits,
hooks: ToolRuntime.ToolCallHooks<R>,
hooks: ToolRuntime.Hooks<R>,
globals?: (ctx: Interpreter<R>) => ReadonlyArray<readonly [string, unknown]>,
): Effect.Effect<Result, never, R> => {
if (code.trim().length === 0) {
+22 -13
View File
@@ -1,6 +1,7 @@
import { Effect } from "effect"
import type { Extension } from "../extension.js"
import { coerceToString } from "../stdlib/value.js"
import { type ExtensionInvocation, hooked } from "../tool-runtime.js"
import type { Interpreter } from "./interpreter.js"
import { createErrorValue, isErrorType } from "./intrinsics.js"
import { MAX_VALUE_DEPTH } from "./limits.js"
@@ -11,7 +12,6 @@ import {
define,
entries,
get,
type Native,
Arr,
Bytes,
DateObj,
@@ -128,26 +128,35 @@ export const extensionGlobals = <R>(
throw typeError(`${label} produced ${describeHost(value)}, which the program cannot hold.`)
}
// A host function as a program function: arguments cross in, and whatever it returns, resolves, throws, or
// rejects with crosses out, so the program catches what the author threw.
const wrap = (value: Function, label: string): Native<R> =>
// A host function as a program function. Arguments cross in; a global's call runs inside the host's extension
// hooks, which see the host's own error on failure; then whatever came back, or was thrown, crosses out so the
// program catches a copy. Functions inside results are part of a value's API and skip the hooks.
const wrap = (value: Function, label: string, describe?: (args: ReadonlyArray<unknown>) => ExtensionInvocation) =>
fn<R>(builtins, value.name, value.length, (_, values) => {
const converted = values.map((item, index) => toHost(item, `Argument ${index + 1} to ${label}`))
const thrown = (reason: unknown) => new Throw(fromHost(reason, label))
const args = values.map((item, index) => toHost(item, `Argument ${index + 1} to ${label}`))
const hooks = ctx.tools.hooks
const settle = (run: Effect.Effect<unknown, unknown, R>) =>
(describe === undefined
? run
: hooked(describe(args), hooks["extension.before"], hooks["extension.after"], run)
).pipe(
Effect.mapError((reason) => new Throw(fromHost(reason, label))),
Effect.map((settled) => fromHost(settled, label)),
)
let result: unknown
try {
result = value.apply(undefined, converted)
result = value.apply(undefined, args)
} catch (reason) {
return Effect.fail(thrown(reason))
return settle(Effect.fail(reason))
}
if (!(result instanceof Promise)) return fromHost(result, label)
return ctx.pending.create(
Effect.map(Effect.tryPromise({ try: () => result, catch: thrown }), (settled) => fromHost(settled, label)),
)
if (!(result instanceof Promise)) return settle(Effect.succeed(result))
return ctx.pending.create(settle(Effect.tryPromise({ try: () => result, catch: (reason) => reason })))
})
return extensions.flatMap((extension) =>
Object.entries(extension.globals).map(([name, value]) => [name, wrap(value, name)] as const),
Object.entries(extension.globals).map(
([name, value]) => [name, wrap(value, name, (args) => ({ extension: extension.name, name, args }))] as const,
),
)
}
+76 -55
View File
@@ -36,26 +36,48 @@ export type ToolCall = {
readonly name: string
}
export type ToolCallStarted = {
readonly index: number
/** A tool call the program is making, with its decoded input. */
export type ToolInvocation = { readonly name: string; readonly input: unknown }
/** A call the program is making to an extension global, with its arguments. */
export type ExtensionInvocation = {
readonly extension: string
readonly name: string
readonly input: unknown
readonly args: ReadonlyArray<unknown>
}
export type ToolCallEnded = {
readonly index: number
readonly name: string
readonly input: unknown
readonly durationMs: number
readonly outcome: "success" | "failure" | "interrupted"
readonly message?: string
/** How a call ended; `after` hooks observe it and cannot change it. */
export type CallResult =
| { readonly status: "success"; readonly value: unknown }
| { readonly status: "failure"; readonly error: unknown }
| { readonly status: "interrupted" }
/** Hooks around every call the program makes into the host. A failing `before` denies the call. */
export type Hooks<R = never> = {
readonly "tool.before"?: ((call: ToolInvocation) => Effect.Effect<void, unknown, R>) | undefined
readonly "tool.after"?: ((call: ToolInvocation, result: CallResult) => Effect.Effect<void, never, R>) | undefined
readonly "extension.before"?: ((call: ExtensionInvocation) => Effect.Effect<void, unknown, R>) | undefined
readonly "extension.after"?:
| ((call: ExtensionInvocation, result: CallResult) => Effect.Effect<void, never, R>)
| undefined
}
export type ToolCallHooks<R = never> = {
/** Observes decoded tool input immediately before tool execution. */
readonly onToolCallStart?: ((call: ToolCallStarted) => Effect.Effect<void, never, R>) | undefined
/** Observes each admitted tool call as it succeeds, fails, or is interrupted. */
readonly onToolCallEnd?: ((call: ToolCallEnded) => Effect.Effect<void, never, R>) | undefined
/** Runs `before`, then `run`, then `after` with how it ended, including when interrupted. */
export const hooked = <Call, A, R>(
call: Call,
before: ((call: Call) => Effect.Effect<void, unknown, R>) | undefined,
after: ((call: Call, result: CallResult) => Effect.Effect<void, never, R>) | undefined,
run: Effect.Effect<A, unknown, R>,
): Effect.Effect<A, unknown, R> => {
const observed =
after === undefined
? run
: Effect.onExit(run, (exit) => {
if (Exit.isSuccess(exit)) return after(call, { status: "success", value: exit.value })
if (Cause.hasInterruptsOnly(exit.cause)) return after(call, { status: "interrupted" })
return after(call, { status: "failure", error: Cause.squash(exit.cause) })
})
return before === undefined ? observed : Effect.andThen(before(call), observed)
}
export type ToolDescription = {
@@ -144,15 +166,24 @@ const flattenTools = <R>(
]
}
const describeTool = <R>(visible: VisibleTool<R>): ToolDescription => ({
path: visible.path,
description: visible.tool.description,
signature: isEmptyInput(visible.tool)
? `${toolExpression(visible.path)}(): Promise<${outputTypeScript(visible.tool, true)}>`
: `${toolExpression(visible.path)}(input: ${inputTypeScript(visible.tool, true)}): Promise<${outputTypeScript(visible.tool, true)}>`,
})
const describeTool = <R>(visible: VisibleTool<R>): ToolDescription => {
let signature: string | undefined
return {
path: visible.path,
description: visible.tool.description,
get signature() {
// Search ranks paths and descriptions first; only returned matches need their schemas rendered.
// Joining the final fragments avoids retaining the rendering's intermediate string ropes in JSC.
return (signature ??= [
toolExpression(visible.path),
isEmptyInput(visible.tool) ? "()" : `(input: ${inputTypeScript(visible.tool, true)})`,
`: Promise<${outputTypeScript(visible.tool, true)}>`,
].join(""))
},
}
}
/** Tools indexed once per runtime: the lookup trie plus the model-facing catalog and search index. */
/** Tools indexed once per runtime, with discovery materialized on demand. */
export type Prepared<R = never> = {
readonly root: ToolNode<R>
readonly catalog: ReadonlyArray<ToolDescription>
@@ -264,12 +295,20 @@ const toSearchEntry = <R>(visible: VisibleTool<R>): SearchEntry => ({
export const prepare = <R>(tools: Tools<R>): Prepared<R> => {
const root = toolTrie(tools)
// Discovery bytes are durable instructions, so order only after canonical-path collisions settle.
const visible = flattenTools(root).sort((left, right) => compareText(left.path, right.path))
let searchIndex: ReadonlyArray<SearchEntry> | undefined
let catalog: ReadonlyArray<ToolDescription> | undefined
return {
root,
catalog: visible.map(describeTool),
searchIndex: visible.map(toSearchEntry),
get catalog() {
return (catalog ??= this.searchIndex.map((entry) => entry.description))
},
get searchIndex() {
// Executing known tools only needs the trie. Render discovery when it is actually read,
// ordering after canonical-path collisions settle so instruction bytes stay deterministic.
return (searchIndex ??= flattenTools(root)
.sort((left, right) => compareText(left.path, right.path))
.map(toSearchEntry))
},
}
}
@@ -316,6 +355,7 @@ export class ToolRuntimeError extends Error {
/** The tool bridge of one execution. Arguments arrive and results leave as JSON; program values never enter. */
export type ToolRuntime<R = never> = {
readonly calls: Array<ToolCall>
readonly hooks: Hooks<R>
readonly execute: (
path: ReadonlyArray<string>,
args: Array<Json | undefined>,
@@ -328,27 +368,10 @@ export type ToolRuntime<R = never> = {
export const make = <R>(
prepared: Prepared<R>,
maxToolCalls: number | undefined,
hooks?: ToolCallHooks<R>,
hooks: Hooks<R> = {},
): ToolRuntime<R> => {
const calls: Array<ToolCall> = []
const root = prepared.root
const searchTool = makeSearchTool(prepared.searchIndex)
const observeEnd = <A, E>(effect: Effect.Effect<A, E, R>, call: ToolCallStarted): Effect.Effect<A, E, R> => {
const onEnd = hooks?.onToolCallEnd
if (onEnd === undefined) return effect
const startedAt = Date.now()
return effect.pipe(
Effect.onExit((exit) => {
const durationMs = Date.now() - startedAt
if (Exit.isSuccess(exit)) return onEnd({ ...call, durationMs, outcome: "success" })
if (Cause.hasInterruptsOnly(exit.cause)) return onEnd({ ...call, durationMs, outcome: "interrupted" })
const error = Cause.squash(exit.cause)
const message = error instanceof Error ? error.message : Cause.pretty(exit.cause)
return onEnd({ ...call, durationMs, outcome: "failure", message })
}),
)
}
const recordCall = (call: ToolCall): void => {
if (maxToolCalls !== undefined && calls.length >= maxToolCalls) {
@@ -371,14 +394,12 @@ export const make = <R>(
name === "search" ? [] : ["The signature may have changed. Use search to get the current signature."],
),
})
const index = yield* Effect.sync(() => {
recordCall({ name })
return calls.length - 1
})
const call = { index, name, input }
return yield* observeEnd(
yield* Effect.sync(() => recordCall({ name }))
return yield* hooked(
{ name, input },
hooks["tool.before"],
hooks["tool.after"],
Effect.gen(function* () {
if (hooks?.onToolCallStart !== undefined) yield* hooks.onToolCallStart(call)
const raw = yield* Effect.suspend(() => tool.execute(input)).pipe(
Effect.catchCause((cause) => {
if (Cause.hasInterruptsOnly(cause)) return Effect.interrupt
@@ -400,20 +421,20 @@ export const make = <R>(
catch: (cause) => new ToolRuntimeError("InvalidToolOutput", `Invalid output from tool '${name}': ${cause}`),
})
}),
call,
)
})
return {
calls,
hooks,
keys: (path) => namespaceKeys(root, path),
search: (args) => Effect.suspend(() => executeTool("search", searchTool, args)),
search: (args) => Effect.suspend(() => executeTool("search", makeSearchTool(prepared.searchIndex), args)),
execute: (path, args) =>
Effect.suspend(() => {
const segments = canonicalSegments(path)
// Models often write `tools.search(...)` for the bare `search(...)`; honor it unless a tool owns that path.
if (segments.length === 1 && segments[0] === "search" && lookup(root, segments) === undefined)
return executeTool("search", searchTool, args)
return executeTool("search", makeSearchTool(prepared.searchIndex), args)
return executeTool(segments.join("."), resolve(root, path), args)
}),
}
+152 -106
View File
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import { Cause, Effect, Schema } from "effect"
import { CodeMode, Tool, toolError } from "../src/index.js"
import { CodeMode, Extension, Tool, toolError } from "../src/index.js"
const run = (tool: Tool.Tool<never>) =>
Effect.runPromise(CodeMode.make({ tools: { host: { call: tool } } }).execute("return await tools.host.call({})"))
@@ -160,77 +160,154 @@ describe("CodeMode host failure boundary", () => {
})
})
describe("CodeMode tool-call observation", () => {
test("reports the tools actually invoked with decoded input", async () => {
const calls: Array<unknown> = []
describe("CodeMode call hooks", () => {
const ended = (result: CodeMode.CallResult) => {
if (result.status !== "failure") return result.status
return `failure:${result.error instanceof Error ? result.error.message : String(result.error)}`
}
// Every hook logs its event, so the order across tools and extensions is visible.
const observe = (events: Array<string>): CodeMode.Hooks => ({
"tool.before": (call) => Effect.sync(() => void events.push(`tool.before ${JSON.stringify(call)}`)),
"tool.after": (call, result) => Effect.sync(() => void events.push(`tool.after ${call.name} ${ended(result)}`)),
"extension.before": (call) => Effect.sync(() => void events.push(`extension.before ${JSON.stringify(call)}`)),
"extension.after": (call, result) =>
Effect.sync(() => void events.push(`extension.after ${call.name} ${ended(result)}`)),
})
test("surround the tools actually invoked, with decoded input", async () => {
const events: Array<string> = []
const lookup = Tool.make({
description: "Look up a value",
input: Schema.Struct({ query: Schema.String }),
output: Schema.String,
execute: ({ query }) => Effect.succeed(query),
execute: ({ query }) => (query === "boom" ? Effect.fail(toolError("Lookup refused")) : Effect.succeed(query)),
})
const runtime = CodeMode.make({ tools: { context: { lookup } }, hooks: observe(events) })
const result = await Effect.runPromise(
CodeMode.make({
tools: { context: { lookup } },
onToolCallStart: (call) => Effect.sync(() => calls.push(call)),
}).execute(`
const success = await Effect.runPromise(
runtime.execute(`
if (false) await tools.context.lookup({ query: "not called" })
return await tools.context.lookup({ query: "deployment failure" })
`),
)
expect(result.ok).toBe(true)
expect(calls).toStrictEqual([{ index: 0, name: "context.lookup", input: { query: "deployment failure" } }])
})
test("observes settled calls with outcome and duration", async () => {
const events: Array<{ phase: string; index: number; name: string; outcome?: string; message?: string }> = []
const lookup = Tool.make({
description: "Look up a value",
input: Schema.Struct({ query: Schema.String }),
output: Schema.String,
execute: ({ query }) =>
query === "boom"
? Effect.fail(toolError("Lookup refused"))
: query === "defect"
? Effect.die("broken")
: Effect.succeed(query),
})
const runtime = CodeMode.make({
tools: { context: { lookup } },
onToolCallStart: (call) =>
Effect.sync(() => {
events.push({ phase: "start", index: call.index, name: call.name })
}),
onToolCallEnd: (call) =>
Effect.sync(() => {
expect(call.durationMs).toBeGreaterThanOrEqual(0)
events.push({
phase: "end",
index: call.index,
name: call.name,
outcome: call.outcome,
...(call.message === undefined ? {} : { message: call.message }),
})
}),
})
const success = await Effect.runPromise(runtime.execute(`return await tools.context.lookup({ query: "ok" })`))
expect(success.ok).toBe(true)
expect(success).toMatchObject({ ok: true, value: "deployment failure" })
const failure = await Effect.runPromise(runtime.execute(`return await tools.context.lookup({ query: "boom" })`))
expect(failure.ok).toBe(false)
const defect = await Effect.runPromise(runtime.execute(`return await tools.context.lookup({ query: "defect" })`))
expect(defect.ok).toBe(false)
expect(events).toEqual([
'tool.before {"name":"context.lookup","input":{"query":"deployment failure"}}',
"tool.after context.lookup success",
'tool.before {"name":"context.lookup","input":{"query":"boom"}}',
"tool.after context.lookup failure:Lookup refused",
])
})
expect(events).toStrictEqual([
{ phase: "start", index: 0, name: "context.lookup" },
{ phase: "end", index: 0, name: "context.lookup", outcome: "success" },
{ phase: "start", index: 0, name: "context.lookup" },
{ phase: "end", index: 0, name: "context.lookup", outcome: "failure", message: "Lookup refused" },
{ phase: "start", index: 0, name: "context.lookup" },
{ phase: "end", index: 0, name: "context.lookup", outcome: "failure", message: "broken" },
test("surround each call to an extension global, with its arguments, but not functions inside results", async () => {
const events: Array<string> = []
const runtime = CodeMode.make({
extensions: [
Extension.make({
name: "web",
globals: {
fetch: async (url: string, init?: { method?: string }) => ({
url,
method: init?.method ?? "GET",
json: () => 1,
}),
fail: () => {
throw new RangeError("nope")
},
},
}),
],
hooks: observe(events),
})
const result = await Effect.runPromise(
runtime.execute(`
const res = await fetch("https://a.test/", { method: "POST" })
res.json()
try { fail() } catch {}
return res.url
`),
)
expect(result).toMatchObject({ ok: true, value: "https://a.test/", toolCalls: [] })
expect(events).toEqual([
'extension.before {"extension":"web","name":"fetch","args":["https://a.test/",{"method":"POST"}]}',
"extension.after fetch success",
'extension.before {"extension":"web","name":"fail","args":[]}',
"extension.after fail failure:nope",
])
})
test("a failing before hook denies the call; the program catches the host's error", async () => {
const runtime = CodeMode.make({
tools: {
lookup: Tool.make({
description: "Look up",
input: Schema.Struct({}),
output: Schema.String,
execute: () => Effect.succeed("ok"),
}),
},
extensions: [Extension.make({ name: "web", globals: { fetch: async (url: string) => url } })],
hooks: { "extension.before": (call) => Effect.fail(new Error(`${call.name} is not allowed`)) },
})
expect(
await Effect.runPromise(
runtime.execute(`
const denied = await fetch("https://a.test/").catch((e) => [e instanceof Error, e.message])
return [denied, await tools.lookup({})]
`),
),
).toMatchObject({ ok: true, value: [[true, "fetch is not allowed"], "ok"] })
expect(await Effect.runPromise(runtime.execute(`await fetch("https://a.test/")`))).toMatchObject({
ok: false,
error: { kind: "ExecutionFailure", message: "Error: fetch is not allowed" },
})
const refused = CodeMode.make({
tools: {
lookup: Tool.make({
description: "Look up",
input: Schema.Struct({}),
output: Schema.String,
execute: () => Effect.succeed("ok"),
}),
},
hooks: { "tool.before": () => Effect.fail(toolError("lookup is not allowed")) },
})
expect(
await Effect.runPromise(
refused.execute(`try { await tools.lookup({}) } catch (e) { return [e.name, e.message] }`),
),
).toMatchObject({ ok: true, value: ["Error", "lookup is not allowed"], toolCalls: [{ name: "lookup" }] })
expect(await Effect.runPromise(refused.execute(`await tools.lookup({})`))).toMatchObject({
ok: false,
error: { kind: "ToolFailure", message: "lookup is not allowed" },
})
})
test("see a tool defect as the failure the program gets, and an interrupted extension call", async () => {
const events: Array<string> = []
const broken = Tool.make({
description: "Broken",
input: Schema.Struct({}),
output: Schema.String,
execute: () => Effect.die("broken"),
})
const runtime = CodeMode.make({
tools: { broken },
extensions: [Extension.make({ name: "slow", globals: { forever: () => new Promise(() => {}) } })],
hooks: observe(events),
})
expect(await Effect.runPromise(runtime.execute(`await tools.broken({})`))).toMatchObject({ ok: false })
expect(await Effect.runPromise(runtime.execute(`forever(); return "done"`))).toMatchObject({
ok: true,
value: "done",
})
expect(events).toEqual([
'tool.before {"name":"broken","input":{}}',
"tool.after broken failure:broken",
'extension.before {"extension":"slow","name":"forever","args":[]}',
"extension.after forever interrupted",
])
})
@@ -243,15 +320,11 @@ describe("CodeMode tool-call observation", () => {
execute: () => Effect.interrupt,
})
const exit = await Effect.runPromiseExit(
CodeMode.make({
tools: { host: { call } },
onToolCallStart: () => Effect.sync(() => events.push("start")),
onToolCallEnd: (call) => Effect.sync(() => events.push(`end:${call.outcome}`)),
}).execute("return await tools.host.call({})"),
CodeMode.make({ tools: { host: { call } }, hooks: observe(events) }).execute("return await tools.host.call({})"),
)
expect(exit._tag).toBe("Failure")
expect(events).toEqual(["start", "end:interrupted"])
expect(events).toEqual(['tool.before {"name":"host.call","input":{}}', "tool.after host.call interrupted"])
})
test("observes running calls interrupted during completion", async () => {
@@ -263,39 +336,17 @@ describe("CodeMode tool-call observation", () => {
execute: () => Effect.never,
})
const result = await Effect.runPromise(
CodeMode.make({
tools: { host: { call } },
onToolCallStart: () => Effect.sync(() => events.push("start")),
onToolCallEnd: (call) => Effect.sync(() => events.push(`end:${call.outcome}`)),
}).execute('tools.host.call({}); return "done"'),
CodeMode.make({ tools: { host: { call } }, hooks: observe(events) }).execute(
'tools.host.call({}); return "done"',
),
)
expect(result).toMatchObject({ ok: true, value: "done" })
expect(events).toEqual(["start", "end:interrupted"])
})
test("ends calls interrupted during start observation", async () => {
const events: Array<string> = []
const call = Tool.make({
description: "Unused",
input: Schema.Struct({}),
output: Schema.String,
execute: () => Effect.succeed("unused"),
})
const exit = await Effect.runPromiseExit(
CodeMode.make({
tools: { host: { call } },
onToolCallStart: () => Effect.interrupt,
onToolCallEnd: (call) => Effect.sync(() => events.push(call.outcome)),
}).execute("return await tools.host.call({})"),
)
expect(exit._tag).toBe("Failure")
expect(events).toEqual(["interrupted"])
expect(events).toEqual(['tool.before {"name":"host.call","input":{}}', "tool.after host.call interrupted"])
})
test("observes calls interrupted by the execution timeout", async () => {
const outcomes: Array<string> = []
const events: Array<string> = []
const call = Tool.make({
description: "Pending",
input: Schema.Struct({}),
@@ -303,15 +354,13 @@ describe("CodeMode tool-call observation", () => {
execute: () => Effect.never,
})
const result = await Effect.runPromise(
CodeMode.make({
tools: { host: { call } },
limits: { timeoutMs: 10 },
onToolCallEnd: (call) => Effect.sync(() => outcomes.push(call.outcome)),
}).execute("return await tools.host.call({})"),
CodeMode.make({ tools: { host: { call } }, limits: { timeoutMs: 10 }, hooks: observe(events) }).execute(
"return await tools.host.call({})",
),
)
expect(result).toMatchObject({ ok: false, error: { kind: "TimeoutExceeded" } })
expect(outcomes).toEqual(["interrupted"])
expect(events).toEqual(['tool.before {"name":"host.call","input":{}}', "tool.after host.call interrupted"])
})
})
@@ -840,20 +889,17 @@ describe("CodeMode public contract", () => {
}
})
test("search is a counted tool call: it burns maxToolCalls and fires the hooks", async () => {
const started: Array<string> = []
const ended: Array<string> = []
test("search is a counted tool call: it burns maxToolCalls and is observed", async () => {
const observed: Array<CodeMode.ToolInvocation> = []
const limited = CodeMode.make({
tools,
limits: { maxToolCalls: 1 },
onToolCallStart: (call) => Effect.sync(() => void started.push(call.name)),
onToolCallEnd: (call) => Effect.sync(() => void ended.push(`${call.name}:${call.outcome}`)),
hooks: { "tool.before": (call) => Effect.sync(() => void observed.push(call)) },
})
const result = await Effect.runPromise(limited.execute(`search({}); return search({})`))
expect(result.ok).toBe(false)
if (!result.ok) expect(result.error.kind).toBe("ToolCallLimitExceeded")
expect(started).toEqual(["search"])
expect(ended).toEqual(["search:success"])
expect(observed).toEqual([{ name: "search", input: {} }])
})
test("search is an opaque, shadowable global like other built-ins", async () => {
@@ -1089,7 +1135,7 @@ describe("CodeMode public contract", () => {
})
const runtime = CodeMode.make({
tools: { math: { double: transformed } },
onToolCallStart: (call) => Effect.sync(() => observed.push(call.input)),
hooks: { "tool.before": (call) => Effect.sync(() => void observed.push(call.input)) },
})
const success = await Effect.runPromise(runtime.execute(`return await tools.math.double({ value: "21" })`))
+10
View File
@@ -192,6 +192,16 @@ describe("values are converted at the boundary, never shared", () => {
expect((await failure(`big()`, target)).message).toContain("big produced a bigint")
})
test("a value that cannot come out carries a location like any built-in error, sync or async", async () => {
const target = CodeMode.make({
extensions: [
Extension.make({ name: "odd", globals: { sym: () => Symbol("s"), later: async () => Symbol("s") } }),
],
})
expect((await failure(`sym()`, target)).location).toEqual((await failure(`JSON.parse("{")`, target)).location)
expect((await failure(`await later()`, target)).location).toBeDefined()
})
test("a class instance cannot come out", async () => {
class Other {}
const target = CodeMode.make({
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/console-app",
"version": "2.0.3",
"version": "2.0.4",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/console-core",
"version": "2.0.3",
"version": "2.0.4",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/console-function",
"version": "2.0.3",
"version": "2.0.4",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/console-mail",
"version": "2.0.3",
"version": "2.0.4",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/console-support",
"version": "2.0.3",
"version": "2.0.4",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "2.0.3",
"version": "2.0.4",
"name": "@opencode/core",
"type": "module",
"license": "MIT",
+44 -27
View File
@@ -13,6 +13,7 @@ import type {
import { Effect, Ref, Schema, Semaphore } from "effect"
import { definition, normalizedName } from "../tool/runtime.js"
import { CodeModeCatalog } from "./catalog.js"
import { CodeModeWeb } from "./web.js"
const ExecuteFile = Schema.Struct({
data: Schema.String,
@@ -59,8 +60,8 @@ export type Inventory = {
// Invariant model-facing guidance; the changing tool catalog is delivered through Instructions.
const description = [
"Run JavaScript in a confined Code Mode runtime to orchestrate tool calls and compose their results.",
"Imports, direct filesystem access, and timers are unavailable. Do not use `fetch`; all external access goes through `tools`.",
"Run JavaScript in a confined Code Mode runtime to script tool calls and HTTP requests and compose their results.",
"`fetch` is available for HTTP requests. Imports, direct filesystem access, and timers are unavailable; all other external access goes through `tools`.",
"Within `{ code }`, the only callable tools are those explicitly listed in the Code Mode catalog instructions or returned by the `search` function. Inside `{ code }`, ignore tools shown outside the Code Mode catalog. They are not available in the Code Mode runtime.",
'Call tools through `tools` using only exact paths and signatures from the catalog. Do not infer or normalize tool names; preserve bracket notation such as `tools.<namespace>["tool-name"](input)`.',
"Prefer an explicit `return`; if omitted, the final top-level expression becomes the result.",
@@ -82,9 +83,9 @@ export const create = (
const files = yield* Ref.make<Array<CollectedFiles>>([])
const calls = yield* Ref.make<Array<ExecuteCall>>([])
const lock = Semaphore.makeUnsafe(1)
const updateCalls = (update: (items: Array<ExecuteCall>) => Array<ExecuteCall>) =>
const record = (update: (items: Array<ExecuteCall>) => Array<ExecuteCall>) =>
lock.withPermit(
Ref.updateAndGet(calls, update).pipe(Effect.flatMap((toolCalls) => context.progress({ toolCalls }))),
Ref.updateAndGet(calls, update).pipe(Effect.tap((toolCalls) => context.progress({ toolCalls }))),
)
const result = yield* runtime(
inventory,
@@ -103,27 +104,7 @@ export const create = (
const text = content.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n")
return text === "" ? null : text
}),
{
onToolCallStart: ({ index, name, input }) => {
const shown = displayInput(input)
return updateCalls((items) => {
const next = [...items]
next[index] = { tool: name, status: "running", ...(shown ? { input: shown } : {}) }
return next
})
},
onToolCallEnd: ({ index, name, input, outcome }) => {
const shown = displayInput(input)
return updateCalls((items) => {
const next = [...items]
next[index] = {
...(items[index] ?? { tool: name, ...(shown ? { input: shown } : {}) }),
status: outcome === "success" ? "completed" : "error",
}
return next
})
},
},
progressHooks(record),
).execute(code)
const toolCalls = yield* Ref.get(calls)
const collected = (yield* Ref.get(files))
@@ -158,6 +139,42 @@ export const create = (
} satisfies Info
}
// Rows appear in start order; the same call object arrives at both hooks, so a call finds its row again.
function progressHooks(record: (update: (items: Array<ExecuteCall>) => Array<ExecuteCall>) => Effect.Effect<unknown>) {
const rows = new WeakMap<object, number>()
const start = (call: object, entry: ExecuteCall) =>
record((items) => {
rows.set(call, items.length)
return [...items, entry]
})
const settle = (call: object, result: CodeMode.CallResult) => {
const index = rows.get(call)
if (index === undefined) return Effect.void
return record((items) => {
const next = [...items]
next[index] = { ...items[index], status: result.status === "success" ? "completed" : "error" }
return next
})
}
return {
"tool.before": (call) => {
const shown = displayInput(call.input)
return start(call, { tool: call.name, status: "running", ...(shown ? { input: shown } : {}) })
},
"tool.after": settle,
// Only listed extension functions get a row; anything else stays out of the TUI.
"extension.before": (call) => {
switch (call.name) {
case "fetch":
return start(call, { tool: call.name, status: "running", input: CodeModeWeb.display(call.args) })
default:
return Effect.void
}
},
"extension.after": settle,
} satisfies CodeMode.Hooks
}
export const catalog = (inventory: Inventory) => {
const pinned = new Set(
Array.from(inventory.tools.values())
@@ -204,7 +221,7 @@ function renderCatalog(root: CatalogNode): ReadonlyArray<CodeModeCatalog.Tool |
function runtime(
inventory: Inventory,
executeTool: (name: string, tool: Info, input: unknown) => Effect.Effect<unknown, unknown>,
hooks?: CodeMode.ToolCallHooks,
hooks?: CodeMode.Hooks,
) {
// A path may carry namespace metadata, a callable tool, child tools, or all three.
const root: ToolNode = { children: new Map() }
@@ -219,7 +236,7 @@ function runtime(
})
}
const tools = renderTools(root)
return CodeMode.make<typeof tools>({ tools, ...hooks })
return CodeMode.make<typeof tools>({ tools, extensions: [CodeModeWeb.extension], hooks })
}
function getNode<T>(root: Node<T>, path: string) {
+46
View File
@@ -0,0 +1,46 @@
export * as CodeModeWeb from "./web.js"
import { Extension } from "@opencode/codemode"
const TIMEOUT_MS = 30_000
type Init = {
readonly method?: string
readonly headers?: Record<string, string> | Array<[string, string]>
readonly body?: string | Uint8Array<ArrayBuffer> | URLSearchParams
}
const fetch = async (input: string | URL, init: Init = {}) => {
const response = await globalThis.fetch(input, {
method: init.method,
headers: init.headers,
body: init.body,
signal: AbortSignal.timeout(TIMEOUT_MS),
})
const bytes = await response.bytes()
const headers = Object.fromEntries(response.headers)
const text = () => new TextDecoder().decode(bytes)
return {
url: response.url,
status: response.status,
statusText: response.statusText,
ok: response.ok,
redirected: response.redirected,
headers: {
get: (name: string) => headers[name.toLowerCase()] ?? null,
has: (name: string) => name.toLowerCase() in headers,
entries: () => Object.entries(headers),
},
text: async () => text(),
json: async () => JSON.parse(text()) as unknown,
bytes: async () => bytes,
}
}
export const extension = Extension.make({ name: "web", globals: { fetch } })
/** What to show for a fetch call: its method and URL. */
export const display = (args: ReadonlyArray<unknown>) => {
const [input, init] = args as [string | URL, Init | undefined]
return { method: init?.method?.toUpperCase() ?? "GET", url: String(input) }
}
+6 -1
View File
@@ -168,7 +168,12 @@ export const provider = (options: Options): OAuthClientProvider => {
if (!resource) return identity
if (!checkResourceAllowed({ requestedResource: identity, configuredResource: resource }))
throw new Error(`Protected resource ${resource} does not cover ${identity}`)
return new URL(resource)
const canonical = new URL(resource)
// The transport dials the configured URL with extra query parameters and some servers echo
// that back as the resource. Query is transport detail, not identity: the token stays bound
// to the configured URL so a later refresh names the same resource the login did.
if (canonical.origin === identity.origin && canonical.pathname === identity.pathname) return identity
return canonical
},
...(options.clientMetadataUrl ? { clientMetadataUrl: options.clientMetadataUrl } : {}),
...(redirect ? { state: () => redirect.state } : {}),
+76 -57
View File
@@ -68,7 +68,7 @@ export interface Interface extends State.Transformable<Editor> {
export class Service extends Context.Service<Service, Interface>()("@opencode/Model") {}
type Data = {
models: Map<Provider.ID, Map<ID, MutableInfo>>
models: Map<Provider.ID, ReadonlyMap<ID, Info>>
defaultModel?: { providerID: Provider.ID; modelID: ID }
}
@@ -82,59 +82,73 @@ const layer = Layer.effect(
const state: State.Interface<Data, Editor> = State.create<Data, Editor>({
name: "model",
initial: () => ({
models: new Map(
(input?.available ?? []).map((record) => [
record.provider.id,
new Map(
Array.from(record.models, ([id, model]) => [
id,
{
...structuredClone(model),
id,
providerID: record.provider.id,
} as MutableInfo,
]),
),
]),
),
models: new Map((input?.available ?? []).map((record) => [record.provider.id, record.models])),
}),
editor: (data) => ({
list: (providerID) =>
providerID === undefined
? Array.from(data.models.values()).flatMap((models) => Array.from(models.values()))
: Array.from(data.models.get(providerID)?.values() ?? []),
get: (providerID, modelID) => data.models.get(providerID)?.get(modelID),
update: (providerID, modelID, update) => {
// Model edits cannot create/enable a provider or bypass its availability decision.
const models = data.models.get(providerID)
if (!models) return
const model = models.get(modelID) ?? (Info.default(providerID, modelID) as MutableInfo)
update(model)
model.id = modelID
model.providerID = providerID
const provider = input?.records.get(providerID)?.provider
AISDKNative.rewrite(model, {
specifier: model.package ?? provider?.package,
providerID,
canonical: model.canonical ?? provider?.canonical,
modelID: model.modelID ?? modelID,
})
models.set(modelID, model)
},
remove: (providerID, modelID) => {
data.models.get(providerID)?.delete(modelID)
},
default: {
get: () => data.defaultModel,
set: (providerID, modelID) => {
data.defaultModel = { providerID, modelID }
editor: (data) => {
// Definitions are shared across Locations; a provider's map and a model are copied before their first edit.
const owned = new WeakSet<ReadonlyMap<ID, Info>>()
const drafts = new WeakSet<Info>()
const writable = (providerID: Provider.ID) => {
const current = data.models.get(providerID)
if (!current) return undefined
if (owned.has(current)) return current as Map<ID, Info>
const copy = new Map(current)
owned.add(copy)
data.models.set(providerID, copy)
return copy
}
const draft = (providerID: Provider.ID, modelID: ID) => {
const models = writable(providerID)
if (!models) return undefined
const current = models.get(modelID)
if (!current) return undefined
if (drafts.has(current)) return current as MutableInfo
const copy = structuredClone(current) as MutableInfo
drafts.add(copy)
models.set(modelID, copy)
return copy
}
return {
list: (providerID) => {
const ids = providerID === undefined ? Array.from(data.models.keys()) : [providerID]
return ids.flatMap((id) =>
Array.from(data.models.get(id)?.keys() ?? []).flatMap((modelID) => draft(id, modelID) ?? []),
)
},
},
provider: {
list: () => Array.from(input?.records.values() ?? []),
get: (providerID) => input?.records.get(providerID),
},
}),
get: draft,
update: (providerID, modelID, update) => {
// Model edits cannot create/enable a provider or bypass its availability decision.
const models = writable(providerID)
if (!models) return
const model = draft(providerID, modelID) ?? (Info.default(providerID, modelID) as MutableInfo)
update(model)
model.id = modelID
model.providerID = providerID
const provider = input?.records.get(providerID)?.provider
AISDKNative.rewrite(model, {
specifier: model.package ?? provider?.package,
providerID,
canonical: model.canonical ?? provider?.canonical,
modelID: model.modelID ?? modelID,
})
drafts.add(model)
models.set(modelID, model)
},
remove: (providerID, modelID) => {
writable(providerID)?.delete(modelID)
},
default: {
get: () => data.defaultModel,
set: (providerID, modelID) => {
data.defaultModel = { providerID, modelID }
},
},
provider: {
list: () => Array.from(input?.records.values() ?? []),
get: (providerID) => input?.records.get(providerID),
},
}
},
// read() also refreshes dependencies changed inside a State.batch before notification.
notify: () => notify,
})
@@ -147,6 +161,8 @@ const layer = Layer.effect(
byProvider: ReadonlyMap<Provider.ID, ReadonlyMap<ID, Info>>
}
| undefined
// An unedited model keeps its shared definition object across rebuilds, so its merged output is reusable.
const merged = new WeakMap<Info, { provider: Provider.Info | undefined; model: Info }>()
const read = Effect.fn("Model.snapshot")(function* () {
while (true) {
const current = yield* providers.snapshot()
@@ -165,9 +181,10 @@ const layer = Layer.effect(
return [
providerID,
new Map(
Array.from(models, ([id, model]) => [
id,
{
Array.from(models, ([id, model]) => {
const reusable = merged.get(model)
if (reusable && reusable.provider === provider) return [id, reusable.model]
const value = {
...model,
...(provider?.canonical === undefined ? {} : { canonical: provider.canonical }),
package: model.package ?? provider?.package,
@@ -176,8 +193,10 @@ const layer = Layer.effect(
settings: Provider.mergeOverlay(provider?.settings, model.settings),
headers: Provider.mergeHeaders(provider?.headers, model.headers),
body: Provider.mergeOverlay(provider?.body, model.body),
} satisfies Info,
]),
} satisfies Info
merged.set(model, { provider, model: value })
return [id, value]
}),
),
]
}),
+4 -11
View File
@@ -6,12 +6,12 @@ import type { SessionHooks } from "@opencode/plugin/effect/session"
import { Model } from "@opencode/schema/model"
import { Effect } from "effect"
export function identity(model: { readonly provider: string; readonly name: string; readonly ref: Model.Ref }) {
export function identity(model: { readonly name: string; readonly ref: Model.Ref }) {
return [
"# Your Model",
`- Provider: ${model.provider}`,
`- Name: ${model.name}`,
`- ID: ${model.ref.providerID}/${model.ref.id}`,
`- Provider ID: ${model.ref.providerID}`,
`- Model ID: ${model.ref.id}`,
].join("\n")
}
@@ -24,14 +24,7 @@ export const Plugin = define({
(yield* ctx.model.list()).data.find(
(model) => model.providerID === event.model.providerID && model.id === event.model.id,
) ?? Model.Info.default(event.model.providerID, event.model.id)
const provider = (yield* ctx.provider.list()).data.find((provider) => provider.id === event.model.providerID)
event.system.splice(
1,
0,
SystemPart.make(
identity({ provider: provider?.name ?? event.model.providerID, name: model.name, ref: event.model }),
),
)
event.system.splice(1, 0, SystemPart.make(identity({ name: model.name, ref: event.model })))
}).pipe(Effect.catch(() => Effect.void))
yield* ctx.session.hook("context", hook)
yield* ctx.session.hook("compaction", hook)
@@ -311,7 +311,7 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Scope
// Console config can change independently of local credential activity, so re-fetch
// periodically and only rebuild the catalog and search providers when the snapshot differs.
yield* Effect.sleep(Duration.minutes(10)).pipe(
yield* Effect.sleep(Duration.minutes(1)).pipe(
Effect.andThen(
loading.withPermit(
load().pipe(Effect.flatMap((next) => (Equal.equals(snapshot, next) ? Effect.void : apply(next)))),
+22 -13
View File
@@ -228,12 +228,18 @@ export interface Interface extends State.Transformable<Editor> {
export class Service extends Context.Service<Service, Interface>()("@opencode/Provider") {}
// Every location references the same index for a shared immutable definition array.
const definitions = new WeakMap<readonly Model.Info[], ReadonlyMap<Model.ID, Model.Info>>()
function index(models: readonly Model.Info[]) {
const cached = definitions.get(models)
const definitions = new WeakMap<readonly Model.Info[], Map<ID, ReadonlyMap<Model.ID, Model.Info>>>()
function index(providerID: ID, models: readonly Model.Info[]) {
const indexes = definitions.get(models) ?? new Map<ID, ReadonlyMap<Model.ID, Model.Info>>()
const cached = indexes.get(providerID)
if (cached) return cached
const result = freeze(new Map(models.map((model) => [model.id, model])), true)
definitions.set(models, result)
// Model shares these definitions without copying, so a foreign definition takes this provider's identity here.
const result = freeze(
new Map(models.map((model) => [model.id, model.providerID === providerID ? model : { ...model, providerID }])),
true,
)
indexes.set(providerID, result)
definitions.set(models, indexes)
return result
}
@@ -281,7 +287,7 @@ const layer = Layer.effect(
add: (definition) => {
records.set(definition.info.id, {
provider: structuredClone(definition.info) as MutableInfo,
models: index(definition.models),
models: index(definition.info.id, definition.models),
sourceConnection: definition.sourceConnection,
})
},
@@ -300,7 +306,7 @@ const layer = Layer.effect(
},
models: {
set: (id, values) => {
entry(id).models = index(values)
entry(id).models = index(id, values)
},
update: (providerID, modelID, update) => {
const record = entry(providerID)
@@ -338,7 +344,7 @@ const layer = Layer.effect(
// Registrations may outlive a borrowed service layer; their later disposal must
// not query dependencies that have already closed.
yield* Effect.addFinalizer(() => State.shutdown(state.reload()))
let cached: { records: Snapshot["records"]; access: string; value: Snapshot } | undefined
let cached: { records: Snapshot["records"]; value: Snapshot } | undefined
const snapshot = Effect.fn("Provider.snapshot")(function* () {
while (true) {
const revision = integrations.revision()
@@ -347,10 +353,6 @@ const layer = Layer.effect(
const connections = yield* integrations.list()
// Either fold can disable a plugin that also contributed to the other domain.
if (revision !== integrations.revision() || records !== state.get()) continue
const access = JSON.stringify(
connections.map((integration) => [integration.id, integration.connections.map(IntegrationConnection.key)]),
)
if (cached?.records === records && cached.access === access) return cached.value
const byID = new Map(connections.map((integration) => [integration.id, integration]))
const available = Array.from(records.values()).filter((record) => {
if (record.provider.activation === "disabled") return false
@@ -366,8 +368,15 @@ const layer = Layer.effect(
if (integration?.connections.length) return true
return record.provider.integrationID === undefined && !integration
})
// A credential change that leaves the same definitions available is not a catalog change.
if (
cached?.records === records &&
cached.value.available.length === available.length &&
cached.value.available.every((record, index) => record === available[index])
)
return cached.value
const value = freeze({ records, available, providers: available.map((record) => record.provider) }, true)
cached = { records, access, value }
cached = { records, value }
return value
}
})
+6 -3
View File
@@ -35,10 +35,13 @@ export function isRetryable(error: AIError) {
case "RateLimit":
case "ProviderInternal":
return true
// HTTP transport errors carry no delivery and always retry. WebSocket marks accepted and rejected
// requests as final; not-sent and ambiguous (no frame observed) are still pre-output.
// A WebSocket acknowledgment marks delivery accepted before model output may exist.
// Read failures can still recover; the Step chooses retry versus continuation from durable output.
case "Transport":
return error.reason.delivery !== "accepted" && error.reason.delivery !== "rejected"
return (
error.reason.delivery !== "rejected" &&
(error.reason.delivery !== "accepted" || error.reason.operation === "read")
)
case "InvalidProviderOutput":
return error.reason.classification === "incomplete-stream"
// Unrecognized failures retry: classification records affirmative
+18 -6
View File
@@ -154,6 +154,7 @@ const layer = Layer.effect(
}
})
let catalog: { data: Data; names: string; value: CodeModeCatalog.Inventory } | undefined
const state = State.create<Data, Editor>({
name: "tool",
initial: () => ({
@@ -201,8 +202,9 @@ const layer = Layer.effect(
editor.tools.delete(id)
},
}),
notify: (value) =>
Effect.forEach(
notify: (value) => {
catalog = undefined
return Effect.forEach(
value.errors,
({ kind, name, namespace, error }) =>
Effect.logError(`Skipping invalid ${kind} registration`, {
@@ -211,7 +213,8 @@ const layer = Layer.effect(
error: error.message,
}),
{ discard: true },
),
)
},
})
return Service.of({
@@ -219,15 +222,16 @@ const layer = Layer.effect(
reload: state.reload,
snapshot: Effect.fn("Tool.snapshot")((permissions) =>
Effect.sync(() => {
const data = state.get()
const active = new Map<string, Tool.Info>()
const rules = permissions ?? []
for (const [name, tool] of state.get().tools) {
for (const [name, tool] of data.tools) {
if (whollyDisabled(tool.options?.permission ?? name, rules)) continue
active.set(name, tool)
}
const direct = new Map(Array.from(active).filter(([, tool]) => tool.options?.codemode === false))
const codeModeTools = new Map(Array.from(active).filter(([, tool]) => tool.options?.codemode !== false))
const namespaces = state.get().namespaces
const namespaces = data.namespaces
const codeModeInventory = { tools: codeModeTools, namespaces }
const codeModeEnabled = !whollyDisabled("execute", rules)
const codeModeTool = codeModeEnabled
@@ -237,7 +241,15 @@ const layer = Layer.effect(
),
)
: undefined
const codeModeCatalog = codeModeEnabled ? CodeModeTool.catalog(codeModeInventory) : undefined
const names = Array.from(codeModeTools.keys()).join("\0")
// Discovery is immutable for a registry revision and visible tool set. Keep request
// definitions/executors fresh, but share the much larger rendered catalog across steps.
const codeModeCatalog = !codeModeEnabled
? undefined
: catalog?.data === data && catalog.names === names
? catalog.value
: CodeModeTool.catalog(codeModeInventory)
if (codeModeCatalog) catalog = { data, names, value: codeModeCatalog }
return {
...(codeModeCatalog === undefined ? {} : { codeModeCatalog }),
definitions: [
+111 -1
View File
@@ -3,6 +3,7 @@ export * as OpenCodeTools from "./opencode.js"
import { SystemPart, ToolFailure } from "@opencode/ai"
import type { Context } from "@opencode/plugin/effect/plugin"
import type { SessionHooks } from "@opencode/plugin/effect/session"
import { Model } from "@opencode/schema/model"
import { AbsolutePath } from "@opencode/schema/schema"
import { Session } from "@opencode/schema/session"
import { Effect, Schema } from "effect"
@@ -23,6 +24,47 @@ export const MoveInput = Schema.Struct({
const MoveOutput = Schema.Struct({ sessionID: Session.ID, directory: AbsolutePath })
export const ModelsInput = Schema.Struct({
query: Schema.optionalKey(Schema.String).annotate({
description: "Text to search for in model names and IDs.",
}),
provider: Schema.optionalKey(Schema.String).annotate({
description: "Provider ID or name to filter by. Try your own provider first.",
}),
all: Schema.optionalKey(Schema.Boolean).annotate({
description: "Include older versions of each model family. By default only the newest version is listed.",
}),
limit: Schema.optionalKey(Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 100 }))).annotate({
description: "Maximum number of models to return. Defaults to 20.",
}),
offset: Schema.optionalKey(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))).annotate({
description: "Number of models to skip, for paging through results.",
}),
})
const ModelEntry = Schema.Struct({
id: Schema.String.annotate({ description: "providerID/modelID" }),
name: Schema.String,
released: Model.Info.fields.time.fields.released.annotate({
description: "Release date as a Unix timestamp in milliseconds, or 0 when unknown.",
}),
variants: Schema.Array(Model.VariantID),
cost: Model.Info.fields.cost.annotate({ description: "Pricing in USD per million tokens." }),
status: Model.Info.fields.status,
})
const ModelsOutput = Schema.Struct({
providers: Schema.Array(
Schema.Struct({
id: Schema.String,
name: Schema.String,
models: Schema.Array(ModelEntry).annotate({ description: "Newest first." }),
}),
).annotate({ description: "Matching models grouped by provider. Your own provider comes first." }),
total: Schema.Int.annotate({ description: "Number of matching models across all pages." }),
next: Schema.NullOr(Schema.Int).annotate({ description: "Offset of the next page, or null on the last page." }),
})
export const Plugin = {
id: "opencode.tools",
effect: Effect.fn("OpenCodeTools.Plugin")(function* (ctx: Context) {
@@ -39,7 +81,11 @@ export const Plugin = {
yield* ctx.session.hook("generate", hook)
yield* ctx.tool
.transform((draft) => {
draft.namespace({ name: "opencode", description: "OpenCode session and runtime tools." })
draft.namespace({
name: "opencode",
description:
"Tools for managing OpenCode itself, such as working with sessions and searching the available models.",
})
draft.add({
name: "session_rename",
description:
@@ -85,6 +131,70 @@ export const Plugin = {
),
),
})
draft.add({
name: "models",
description:
"Search the models available to use. Use this to turn a model name the user mentions into an exact reference before running a subagent on it. Check your own provider first.",
input: ModelsInput,
output: ModelsOutput,
options: { namespace: "opencode", codemode: true },
execute: (input, context) =>
Effect.gen(function* () {
const offset = input.offset ?? 0
const limit = input.limit ?? 20
const own = (yield* ctx.session.get({ sessionID: context.sessionID })).model?.providerID
const terms = input.query?.toLowerCase().split(/\s+/).filter(Boolean) ?? []
const names = new Map((yield* ctx.provider.list()).data.map((provider) => [provider.id, provider.name]))
const provider = input.provider?.toLowerCase()
const matching = (yield* ctx.model.list()).data
.filter(
(model) =>
provider === undefined ||
model.providerID.toLowerCase() === provider ||
names.get(model.providerID)?.toLowerCase() === provider,
)
.filter((model) => {
const text = `${model.providerID}/${model.id} ${model.name}`.toLowerCase()
return terms.every((term) => text.includes(term))
})
.toSorted(
(left, right) =>
Number(right.providerID === own) - Number(left.providerID === own) ||
left.providerID.localeCompare(right.providerID) ||
right.time.released - left.time.released,
)
.filter((model, index, sorted) => {
if (input.all || model.family === undefined) return true
return (
sorted.findIndex(
(other) => other.providerID === model.providerID && other.family === model.family,
) === index
)
})
const page = matching.slice(offset, offset + limit)
const providers = Array.from(new Set(page.map((model) => model.providerID))).map((id) => ({
id,
name: names.get(id) ?? id,
models: page
.filter((model) => model.providerID === id)
.map((model) => ({
id: `${model.providerID}/${model.id}`,
name: model.name,
released: model.time.released,
variants: model.variants.map((variant) => variant.id),
cost: model.cost,
status: model.status,
})),
}))
return {
output: {
providers,
total: matching.length,
next: offset + limit < matching.length ? offset + limit : null,
},
}
}).pipe(Effect.mapError((error) => new ToolFailure({ message: "Unable to list models", error }))),
})
})
.pipe(Effect.orDie)
}),
+46 -13
View File
@@ -7,6 +7,7 @@ import { Effect, Schema } from "effect"
import { Agent } from "../../agent.js"
import { Config } from "../../config.js"
import { Job } from "../../job.js"
import { Model } from "../../model.js"
import { Permission } from "../../permission.js"
import { Session } from "../../session.js"
import { SessionSchema } from "../../session/schema.js"
@@ -26,9 +27,16 @@ const backgroundResult = (sessionID: SessionSchema.ID) => ({
})
export const Input = Schema.Struct({
agent: Schema.String.annotate({ description: "The type of specialized agent to use for this task" }),
agent: Schema.String.annotate({
description:
"The type of specialized agent to use for this task. If the user asks for a subagent by a name that is not one of the available subagents, they most likely mean a model: pick a suitable agent and pass the name through the model parameter instead.",
}),
description: Schema.String.annotate({ description: "A short 3-5 word label for the task, displayed to the user" }),
prompt: Schema.String.annotate({ description: "The task for the subagent to perform" }),
model: Schema.optionalKey(Schema.String).annotate({
description:
'NEVER set this unless the user explicitly asks for a particular model or variant. The value is written as "providerID/modelID", or "providerID/modelID#variant" to include a variant. Do not guess the ID: look the model up with the models tool, filtering to your own provider first.',
}),
sessionID: Schema.optionalKey(SessionSchema.ID).annotate({
description:
"Continue a specific previous subagent conversation by passing its sessionID. Calls without a sessionID start a new conversation.",
@@ -61,8 +69,34 @@ export const Plugin = {
const agents = yield* Agent.Service
const config = yield* Config.Service
const permission = yield* Permission.Service
const models = yield* Model.Service
const subagents = yield* SubagentJob.make
const resolveModel = Effect.fn("SubagentTool.resolveModel")(function* (input: string) {
const ref = yield* Effect.try({
try: () => Model.Ref.parse(input),
catch: () =>
new ToolFailure({
message: `Invalid model "${input}". Use "providerID/modelID" or "providerID/modelID#variant".`,
}),
})
const model = (yield* models.available()).find(
(model) => model.providerID === ref.providerID && model.id === ref.id,
)
if (model === undefined)
return yield* new ToolFailure({
message: `Model "${ref.providerID}/${ref.id}" is not available. Use the models tool to see what is available.`,
})
if (ref.variant !== undefined && !model.variants.some((variant) => variant.id === ref.variant))
return yield* new ToolFailure({
message:
model.variants.length === 0
? `Model "${ref.providerID}/${ref.id}" has no variants. Omit the variant.`
: `Variant "${ref.variant}" is not available for "${ref.providerID}/${ref.id}". Available: ${model.variants.map((variant) => variant.id).join(", ")}.`,
})
return ref
})
yield* ctx.tool
.transform((editor) =>
editor.add({
@@ -131,24 +165,23 @@ export const Plugin = {
return yield* new ToolFailure({
message: `Session ${existing.id} is not a child of the current session`,
})
const override = input.model === undefined ? undefined : yield* resolveModel(input.model)
// Continuing with a different agent switches the child, mirroring create semantics
// where the agent's configured model wins over the inherited one.
if (existing !== undefined && existing.agent !== agent.id) {
yield* sessions.switchAgent({ sessionID: existing.id, agent: agent.id }).pipe(
Effect.andThen(
agent.model === undefined
? Effect.void
: sessions.switchModel({ sessionID: existing.id, model: agent.model }),
),
// where an explicit model wins over the agent's configured model, which wins over the inherited one.
if (existing !== undefined) {
const switched = existing.agent !== agent.id
const model = override ?? (switched ? agent.model : undefined)
yield* Effect.all([
switched ? sessions.switchAgent({ sessionID: existing.id, agent: agent.id }) : Effect.void,
model === undefined ? Effect.void : sessions.switchModel({ sessionID: existing.id, model }),
]).pipe(
Effect.mapError(
(error) =>
new ToolFailure({ message: `Failed to switch subagent session agent: ${existing.id}`, error }),
(error) => new ToolFailure({ message: `Failed to switch subagent session: ${existing.id}`, error }),
),
)
}
// Model selection is policy/config/session state, not an LLM-facing tool argument.
const model = agent.model ?? parent.model
const model = override ?? agent.model ?? parent.model
const child =
existing ??
(yield* sessions
+160 -1
View File
@@ -1,7 +1,7 @@
import { describe, expect } from "bun:test"
import { LanguageModel } from "@opencode/ai"
import { OpenAIChat } from "@opencode/ai/protocols"
import { Effect, Fiber, Layer, Stream } from "effect"
import { Effect, Fiber, Layer, Ref, Stream } from "effect"
import { Integration } from "@opencode/core/integration"
import { Credential } from "@opencode/core/credential"
import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder"
@@ -148,6 +148,156 @@ describe("Provider and Model", () => {
}).pipe(Effect.scoped, Effect.provide(localProviderLayer))
})
it.effect("reuses the model catalog across credential switches", () =>
Effect.gen(function* () {
const providers = yield* Provider.Service
const models = yield* Model.Service
const integrations = yield* Integration.Service
const credentials = yield* Credential.Service
const bus = yield* Bus.Service
const providerID = Provider.ID.make("switchable")
const integrationID = Integration.ID.make(providerID)
yield* integrations.transform((editor) => editor.update(integrationID, () => {}))
yield* providers.transform((editor) =>
editor.add({
info: Provider.Info.empty(providerID),
models: [Model.Info.default(providerID, Model.ID.make("chat"))],
}),
)
expect(yield* models.available()).toEqual([])
const log = yield* Ref.make<string[]>([])
yield* bus.subscribe().pipe(
Stream.runForEach((event) => Ref.update(log, (types) => [...types, event.type])),
Effect.forkScoped({ startImmediately: true }),
)
yield* Effect.yieldNow
const updates = Ref.get(log).pipe(
Effect.map((types) => types.filter((type) => type === Model.Event.Updated.type).length),
)
const first = yield* credentials.create({
integrationID,
value: Credential.Key.make({ type: "key", key: "first" }),
})
const materialized = yield* models.available()
expect(materialized).toHaveLength(1)
// Credential events reach Model on other fibers; let the connect land before switching.
yield* settle(updates.pipe(Effect.map((count) => count >= 1)))
const second = yield* credentials.create({
integrationID,
value: Credential.Key.make({ type: "key", key: "second" }),
})
expect(yield* models.available()).toBe(materialized)
yield* credentials.activate(first.id)
expect(yield* models.available()).toBe(materialized)
yield* credentials.remove(first.id)
expect(yield* models.available()).toBe(materialized)
// Disconnecting is a real change whose model.updated follows every earlier one in the log,
// so once it has arrived the total shows whether any switch above published as well.
yield* credentials.remove(second.id)
expect(yield* models.available()).toEqual([])
yield* settle(
Ref.get(log).pipe(
Effect.map(
(types) =>
types.lastIndexOf(Model.Event.Updated.type) > types.lastIndexOf(Credential.Event.Updated.type),
),
),
)
expect(yield* updates).toBe(2)
}),
)
it.effect("persists direct edits to models returned by list and get", () =>
Effect.gen(function* () {
const providers = yield* Provider.Service
const models = yield* Model.Service
const providerID = Provider.ID.make("direct")
const listed = Model.ID.make("listed")
const fetched = Model.ID.make("fetched")
const definitions = [Model.Info.default(providerID, listed), Model.Info.default(providerID, fetched)]
yield* providers.transform((editor) =>
editor.add({ info: { ...Provider.Info.empty(providerID), activation: "enabled" }, models: definitions }),
)
yield* models.transform((editor) => {
editor.list(providerID).forEach((model) => {
model.limit.context = 4096
})
required(editor.get(providerID, fetched)).capabilities.input.push("pdf")
})
expect(yield* models.get(providerID, listed)).toMatchObject({
limit: { context: 4096 },
capabilities: { input: ["text", "image"] },
})
expect(yield* models.get(providerID, fetched)).toMatchObject({
limit: { context: 4096 },
capabilities: { input: ["text", "image", "pdf"] },
})
expect(definitions.map((model) => model.limit.context)).toEqual([200_000, 200_000])
}),
)
it.effect("gives foreign definitions the registering provider's identity", () =>
Effect.gen(function* () {
const providers = yield* Provider.Service
const models = yield* Model.Service
const source = Provider.ID.make("source")
const mirror = Provider.ID.make("mirror")
const modelID = Model.ID.make("chat")
const definitions = [Model.Info.default(source, modelID)]
yield* providers.transform((editor) => {
editor.add({ info: { ...Provider.Info.empty(source), activation: "enabled" }, models: definitions })
editor.add({ info: { ...Provider.Info.empty(mirror), activation: "enabled" }, models: definitions })
})
expect((yield* models.available()).map((model) => model.providerID).toSorted()).toEqual([mirror, source])
expect(yield* models.get(mirror, modelID)).toMatchObject({ id: modelID, providerID: mirror })
expect((yield* providers.snapshot()).records.get(source)?.models.get(modelID)).toBe(definitions[0])
}),
)
it.effect("keeps materialized models when another provider becomes available", () =>
Effect.gen(function* () {
const providers = yield* Provider.Service
const models = yield* Model.Service
const integrations = yield* Integration.Service
const credentials = yield* Credential.Service
const existing = Provider.ID.make("existing")
const added = Provider.ID.make("added")
const edited = Model.ID.make("edited")
const untouched = Model.ID.make("untouched")
yield* integrations.transform((editor) => editor.update(Integration.ID.make(added), () => {}))
yield* providers.transform((editor) => {
editor.add({
info: { ...Provider.Info.empty(existing), activation: "enabled" },
models: [Model.Info.default(existing, edited), Model.Info.default(existing, untouched)],
})
editor.add({ info: Provider.Info.empty(added), models: [Model.Info.default(added, Model.ID.make("chat"))] })
})
yield* models.transform((editor) =>
editor.update(existing, edited, (model) => {
model.limit.context = 1
}),
)
const before = required(yield* models.get(existing, untouched))
yield* credentials.create({
integrationID: Integration.ID.make(added),
value: Credential.Key.make({ type: "key", key: "secret" }),
})
expect((yield* models.available()).map((model) => model.providerID).toSorted()).toEqual([
added,
existing,
existing,
])
expect(yield* models.get(existing, untouched)).toBe(before)
expect(yield* models.get(existing, edited)).toMatchObject({ limit: { context: 1 } })
}),
)
it.effect("derives availability from a provider's integration", () => {
const integrationID = Integration.ID.make("gateway")
const providerID = Provider.ID.make("remote")
@@ -511,3 +661,12 @@ describe("Provider and Model", () => {
}),
)
})
// Bus subscribers run on their own fibers, so give them turns until the condition holds.
const settle = Effect.fnUntraced(function* (condition: Effect.Effect<boolean>) {
for (let attempt = 0; attempt < 100; attempt++) {
if (yield* condition) return
yield* Effect.yieldNow
}
return yield* Effect.die("Timed out waiting for catalog events")
})
@@ -13,7 +13,7 @@
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"gpt-4o-mini\",\"messages\":[{\"role\":\"system\",\"content\":\"You are an AI agent running in OpenCode, a coding agent harness. Help the user accomplish their goals using the tools you have available.\\n\\n# Harness\\n- Responses are rendered as GitHub-flavored Markdown.\\n- `<system-reminder>` blocks are harness instructions, not user-authored content. Read and follow them.\\n- Prefer parallelizing independent tool calls.\\n\\n\\n# Communication\\n\\nUse clear file paths when referring to files. Keep responses clear and concise, and avoid unnecessary technical jargon.\\n\\n## Intermediate Commentary\\n\\nAs you work, you send messages to the commentary channel. These are how you collaborate with the user while you work: stating assumptions and providing updates. Keep them concise and quickly scannable, and send them only when they add real information, such as a discovery, a tradeoff, or a blocker. Do not narrate routine reads, searches, or edits.\\n\\nBy default, treat new messages received during ongoing work as steering the active task rather than replacing it. Incorporate corrections and constraints, and answer questions briefly in commentary before continuing. Replace the task only when the user clearly cancels it or requests an incompatible objective.\\n\\nDo not put a final response, such as a blocking or clarifying question, in the commentary channel. The final answer must always be fully self-contained.\\n\\n## Final Answer\\n\\nIn the final answer, lead with the outcome, not the steps you took to reach it. Cover the most important information, use only as much structure as the answer needs, and avoid long-winded explanations unless necessary. Include technical detail only where it helps.\\n\\n# Working in codebases\\n- Keep changes consistent with the structure, naming, style, and patterns of the surrounding code.\\n- Treat unfamiliar files or changes as potential user work and investigate before deleting or overwriting them.\\n\\n# Delegation\\n\\nDo not spawn subagents unless the user or applicable AGENTS.md/skill instructions explicitly ask for subagents, delegation, or parallel agent work.\\n\\n# Destructive actions\\n\\nDo not revert, reset, or discard changes you did not make. Never run destructive commands such as `git reset --hard`, `git checkout --`, or recursive deletes on broad paths unless the user clearly asked for that operation; if the target or scope is unclear, ask first. Prefer non-interactive git commands.\\n\\n# Autonomy\\n\\nDo not infer authorization for work beyond the user's request. Assumptions that help you make progress are fine as long as they stay within the user's intent and the scope of the task.\\n\\n# Your Model\\n- Provider: openai\\n- Name: gpt-4o-mini\\n- ID: openai/gpt-4o-mini\"},{\"role\":\"user\",\"content\":\"Say hello in one short sentence.\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"store\":false,\"prompt_cache_key\":\"ses_runner_recorded\",\"max_completion_tokens\":20,\"temperature\":0}"
"body": "{\"model\":\"gpt-4o-mini\",\"messages\":[{\"role\":\"system\",\"content\":\"You are an AI agent running in OpenCode, a coding agent harness. Help the user accomplish their goals using the tools you have available.\\n\\n# Harness\\n- Responses are rendered as GitHub-flavored Markdown.\\n- `<system-reminder>` blocks are harness instructions, not user-authored content. Read and follow them.\\n- Prefer parallelizing independent tool calls.\\n\\n\\n# Communication\\n\\nUse clear file paths when referring to files. Keep responses clear and concise, and avoid unnecessary technical jargon.\\n\\n## Intermediate Commentary\\n\\nAs you work, you send messages to the commentary channel. These are how you collaborate with the user while you work: stating assumptions and providing updates. Keep them concise and quickly scannable, and send them only when they add real information, such as a discovery, a tradeoff, or a blocker. Do not narrate routine reads, searches, or edits.\\n\\nBy default, treat new messages received during ongoing work as steering the active task rather than replacing it. Incorporate corrections and constraints, and answer questions briefly in commentary before continuing. Replace the task only when the user clearly cancels it or requests an incompatible objective.\\n\\nDo not put a final response, such as a blocking or clarifying question, in the commentary channel. The final answer must always be fully self-contained.\\n\\n## Final Answer\\n\\nIn the final answer, lead with the outcome, not the steps you took to reach it. Cover the most important information, use only as much structure as the answer needs, and avoid long-winded explanations unless necessary. Include technical detail only where it helps.\\n\\n# Working in codebases\\n- Keep changes consistent with the structure, naming, style, and patterns of the surrounding code.\\n- Treat unfamiliar files or changes as potential user work and investigate before deleting or overwriting them.\\n\\n# Delegation\\n\\nDo not spawn subagents unless the user or applicable AGENTS.md/skill instructions explicitly ask for subagents, delegation, or parallel agent work.\\n\\n# Destructive actions\\n\\nDo not revert, reset, or discard changes you did not make. Never run destructive commands such as `git reset --hard`, `git checkout --`, or recursive deletes on broad paths unless the user clearly asked for that operation; if the target or scope is unclear, ask first. Prefer non-interactive git commands.\\n\\n# Autonomy\\n\\nDo not infer authorization for work beyond the user's request. Assumptions that help you make progress are fine as long as they stay within the user's intent and the scope of the task.\\n\\n# Your Model\\n- Name: gpt-4o-mini\\n- Provider ID: openai\\n- Model ID: gpt-4o-mini\"},{\"role\":\"user\",\"content\":\"Say hello in one short sentence.\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"store\":false,\"prompt_cache_key\":\"ses_runner_recorded\",\"max_completion_tokens\":20,\"temperature\":0}"
},
"response": {
"status": 200,
+38
View File
@@ -324,6 +324,44 @@ describe("MCP OAuth", () => {
expect(probes.some((probe) => probe.includes("codemode"))).toBe(false)
})
test("keeps the configured URL as the resource when metadata echoes the dialed query", async () => {
const tokenRequests: URLSearchParams[] = []
const server = Bun.serve({
port: 0,
async fetch(request) {
const url = new URL(request.url)
if (url.pathname === "/.well-known/oauth-authorization-server")
return Response.json({
issuer: url.origin,
authorization_endpoint: `${url.origin}/authorize`,
token_endpoint: `${url.origin}/token`,
response_types_supported: ["code"],
})
if (url.pathname === "/.well-known/oauth-protected-resource/mcp")
return Response.json({ resource: `${url.origin}/mcp${url.search}`, authorization_servers: [url.origin] })
if (request.method === "POST" && url.pathname === "/token") {
tokenRequests.push(new URLSearchParams(await request.text()))
return Response.json({ access_token: "next", token_type: "Bearer" })
}
return new Response(null, { status: 404 })
},
})
const url = `${server.url.origin}/mcp`
const oauthProvider = await connectProvider(
remote(url),
memoryCredentials([credential({ access: "expired", refresh: "refresh", url })]),
)
// The transport dials with ?codemode=false and follows the 401 challenge to metadata that echoes it.
await auth(oauthProvider, {
serverUrl: `${url}?codemode=false`,
resourceMetadataUrl: new URL(`${server.url.origin}/.well-known/oauth-protected-resource/mcp?codemode=false`),
}).finally(() => server.stop(true))
expect(tokenRequests[0]?.get("grant_type")).toBe("refresh_token")
expect(tokenRequests[0]?.get("resource")).toBe(url)
})
test("finds resource metadata through the 401 header when the well-known path is not served", async () => {
const server = Bun.serve({
port: 0,
+5 -9
View File
@@ -18,15 +18,14 @@ const it = testEffect(PluginTestLayer)
test("formats the model identity part", () => {
expect(
IdentityPlugin.identity({
provider: "OpenAI",
name: "GPT-4o mini",
ref: Model.Ref.make({ providerID: Provider.ID.make("openai"), id: Model.ID.make("gpt-4o-mini") }),
}),
).toBe(["# Your Model", "- Provider: OpenAI", "- Name: GPT-4o mini", "- ID: openai/gpt-4o-mini"].join("\n"))
).toBe(["# Your Model", "- Name: GPT-4o mini", "- Provider ID: openai", "- Model ID: gpt-4o-mini"].join("\n"))
})
const identity = (provider: string, name: string, id: string) =>
["# Your Model", `- Provider: ${provider}`, `- Name: ${name}`, `- ID: test/${id}`].join("\n")
const identity = (name: string, id: string) =>
["# Your Model", `- Name: ${name}`, "- Provider ID: test", `- Model ID: ${id}`].join("\n")
const context = (id: string): SessionHooks["context"] => ({
sessionID: Session.ID.make("ses_model_identity"),
@@ -45,9 +44,6 @@ it.effect("inserts the structured model block after the agent prompt", () =>
const plugins = yield* Plugin.Service
const pluginHost = yield* PluginHost.make(plugins)
yield* catalog.transform((editor) => {
editor.update(Provider.ID.make("test"), (provider) => {
provider.name = "Test Provider"
})
editor.models.update(Provider.ID.make("test"), Model.ID.make("meta/muse-spark-1.1"), (model) => {
model.name = "Muse Spark"
})
@@ -58,7 +54,7 @@ it.effect("inserts the structured model block after the agent prompt", () =>
yield* hooks.trigger("session", "context", named)
expect(named.system.map((part) => part.text)).toEqual([
"Agent prompt",
identity("Test Provider", "Muse Spark", "meta/muse-spark-1.1"),
identity("Muse Spark", "meta/muse-spark-1.1"),
"Initial context",
])
@@ -66,7 +62,7 @@ it.effect("inserts the structured model block after the agent prompt", () =>
yield* hooks.trigger("session", "context", fallback)
expect(fallback.system.map((part) => part.text)).toEqual([
"Agent prompt",
identity("Test Provider", "unknown-model", "unknown-model"),
identity("unknown-model", "unknown-model"),
"Initial context",
])
}),
@@ -586,26 +586,26 @@ describe("OpencodePlugin", () => {
expect(yield* websearch.default()).toBeUndefined()
state.advertised = true
yield* TestClock.adjust("9 minutes")
yield* TestClock.adjust("50 seconds")
yield* drain
expect(state.requests).toBe(1)
expect(rebuilds).toEqual(initial)
expect(yield* websearch.default()).toBeUndefined()
yield* TestClock.adjust("1 minute")
yield* TestClock.adjust("10 seconds")
yield* drain
expect(state.requests).toBe(2)
expect(rebuilds).toEqual({ provider: initial.provider + 1, websearch: initial.websearch + 1 })
expect(yield* websearch.default()).toEqual({ id: WebSearch.ID.make("opencode"), name: "OpenCode Web Search" })
yield* TestClock.adjust("10 minutes")
yield* TestClock.adjust("1 minute")
yield* drain
expect(state.requests).toBe(3)
expect(rebuilds).toEqual({ provider: initial.provider + 1, websearch: initial.websearch + 1 })
expect(yield* websearch.default()).toEqual({ id: WebSearch.ID.make("opencode"), name: "OpenCode Web Search" })
state.advertised = false
yield* TestClock.adjust("10 minutes")
yield* TestClock.adjust("1 minute")
yield* drain
expect(state.requests).toBe(4)
expect(rebuilds).toEqual({ provider: initial.provider + 2, websearch: initial.websearch + 2 })
+13 -4
View File
@@ -192,7 +192,7 @@ describe("toSessionError", () => {
expect(ineligible.map(SessionRunnerRetry.isRetryable)).toEqual([false, false, false, false, false, false, false])
})
test("retries transport failures unless the provider accepted or rejected the request", () => {
test("retries accepted transport reads but not accepted writes or rejected requests", () => {
const retryable = [
llm(new TransportError({ message: "http transport", transport: "http", operation: "request" })),
llm(
@@ -213,8 +213,6 @@ describe("toSessionError", () => {
phase: "send",
}),
),
]
const ineligible = [
llm(
new TransportError({
message: "response interrupted",
@@ -224,6 +222,17 @@ describe("toSessionError", () => {
phase: "receive",
}),
),
]
const ineligible = [
llm(
new TransportError({
message: "accepted write failed",
transport: "websocket",
operation: "write",
delivery: "accepted",
phase: "send",
}),
),
llm(
new TransportError({
message: "continuation rejected",
@@ -236,7 +245,7 @@ describe("toSessionError", () => {
),
]
expect(retryable.map(SessionRunnerRetry.isRetryable)).toEqual([true, true, true])
expect(retryable.map(SessionRunnerRetry.isRetryable)).toEqual([true, true, true, true])
expect(ineligible.map(SessionRunnerRetry.isRetryable)).toEqual([false, false])
})
+101 -73
View File
@@ -63,12 +63,7 @@ import { Document, Info } from "@opencode/schema/config"
import { ConfigCompaction } from "@opencode/schema/config/compaction"
import { Tool } from "@opencode/core/tool"
import type { Info as ToolInfo } from "@opencode/schema/tool"
import {
InstructionStateTable,
SessionInboxTable,
SessionMessageTable,
SessionTable,
} from "@opencode/core/session/sql"
import { InstructionStateTable, SessionInboxTable, SessionMessageTable, SessionTable } from "@opencode/core/session/sql"
import { InstructionEntry } from "@opencode/core/session/instruction-entry"
import { SessionStore } from "@opencode/core/session/store"
import { Instructions } from "@opencode/core/instructions/index"
@@ -121,7 +116,7 @@ const testModel = (id: string, limit: ModelLimit = defaultModelLimit) => {
const model = testModel("fake-model")
const defaultSystem = SessionSystemPrompt.make([])
const identity = (providerID: string, id: string) =>
["# Your Model", `- Provider: ${providerID}`, `- Name: ${id}`, `- ID: ${providerID}/${id}`].join("\n")
["# Your Model", `- Name: ${id}`, `- Provider ID: ${providerID}`, `- Model ID: ${id}`].join("\n")
const fakeIdentity = identity("fake", "fake-model")
const replacementIdentity = identity("fake", "replacement")
const gptIdentity = identity("openai", "gpt-5")
@@ -627,6 +622,18 @@ const continuationRejected = (recovery: "retry-full" | "rotate-and-retry-full")
}),
})
const websocketDisconnected = () =>
new AIError({
reason: new TransportError({
message: "WebSocket closed with code 1006",
transport: "websocket",
operation: "read",
phase: "close",
code: "1006",
delivery: "accepted",
}),
})
const incompleteStream = () =>
new AIError({
reason: new InvalidProviderOutputError({
@@ -1740,7 +1747,11 @@ describe("SessionRunnerLLM", () => {
yield* s.llm.push(TestLLM.text("Done", "text-build"))
yield* s.resume
expect(s.requests.at(-1)?.system.map((part) => part.text)).toEqual(["Build agent instructions", fakeIdentity, "Initial context"])
expect(s.requests.at(-1)?.system.map((part) => part.text)).toEqual([
"Build agent instructions",
fakeIdentity,
"Initial context",
])
})
scenario("uses the configured default agent system for omitted-agent sessions", function* (s) {
@@ -1761,7 +1772,11 @@ describe("SessionRunnerLLM", () => {
yield* s.llm.push(TestLLM.text("Done", "text-reviewer"))
yield* s.resume
expect(s.requests.at(-1)?.system.map((part) => part.text)).toEqual(["Reviewer instructions", fakeIdentity, "Initial context"])
expect(s.requests.at(-1)?.system.map((part) => part.text)).toEqual([
"Reviewer instructions",
fakeIdentity,
"Initial context",
])
expect((yield* s.messages)[0]).toMatchObject({ type: "assistant", agent: "reviewer" })
})
@@ -1784,7 +1799,11 @@ describe("SessionRunnerLLM", () => {
yield* s.llm.push(TestLLM.text("Done", "text-selected"))
yield* s.resume
expect(s.requests.at(-1)?.system.map((part) => part.text)).toEqual(["Reviewer instructions", fakeIdentity, "Initial context"])
expect(s.requests.at(-1)?.system.map((part) => part.text)).toEqual([
"Reviewer instructions",
fakeIdentity,
"Initial context",
])
expect((yield* s.messages)[0]).toMatchObject({ type: "assistant", agent: "reviewer" })
})
@@ -3026,7 +3045,11 @@ describe("SessionRunnerLLM", () => {
expect(resolutions).toBe(2)
expect(s.requests).toHaveLength(3)
expect(s.requests[2]?.model).toBe(replacementModel)
expect(s.requests[2]?.system.map((part) => part.text)).toEqual([defaultSystem, replacementIdentity, "Initial context"])
expect(s.requests[2]?.system.map((part) => part.text)).toEqual([
defaultSystem,
replacementIdentity,
"Initial context",
])
expect(systemTexts(s.requests[2])).toContain("Changed during compaction")
expect(userTexts(s.requests[2])[0]).toContain("<summary>\n## Objective\n- Overflow summary\n</summary>")
expect(userTexts(s.requests[2]).join("\n")).not.toContain("Queued during compaction")
@@ -5000,30 +5023,33 @@ describe("SessionRunnerLLM", () => {
])
})
scenario("bounds jittered exponential backoff for eligible pre-output failures", function* (s) {
yield* s.admit("Retry transport")
yield* s.llm.push(Stream.fail(providerUnavailable()))
yield* s.llm.push(TestLLM.text("Recovered", "retry-success"))
for (const failure of [providerUnavailable, websocketDisconnected]) {
scenario(`bounds jittered exponential backoff before output for ${failure.name}`, function* (s) {
yield* s.admit("Retry transport")
yield* s.llm.push(TestLLM.failAfter(failure(), LLMEvent.stepStart({ index: 0 })))
yield* s.llm.push(TestLLM.text("Recovered", "retry-success"))
const scheduled = yield* subscribeRetries(s)
const run = yield* s.resume.pipe(Effect.forkChild)
yield* Queue.take(scheduled)
yield* TestClock.adjust("1599 millis")
expect(s.requests).toHaveLength(1)
yield* TestClock.adjust("801 millis")
yield* Fiber.join(run)
const scheduled = yield* subscribeRetries(s)
const run = yield* s.resume.pipe(Effect.forkChild)
yield* Queue.take(scheduled)
yield* TestClock.adjust("1599 millis")
expect(s.requests).toHaveLength(1)
yield* TestClock.adjust("801 millis")
yield* Fiber.join(run)
expect(s.requests).toHaveLength(2)
const eventTypes = yield* recordedEventTypes(sessionID)
expect(eventTypes).toContain("session.retry.scheduled.1")
expect(eventTypes.filter((type) => type === "session.step.started.1")).toHaveLength(2)
expect(yield* s.context).toMatchObject([
{ type: "user" },
Expected.assistant({ finish: "stop" }, [Expected.text("Recovered")]),
])
yield* replaySessionProjection(sessionID)
expect((yield* s.context).filter((message) => message.type === "assistant")).toHaveLength(1)
})
expect(s.requests).toHaveLength(2)
expect(s.requests[1]?.messages).toEqual(s.requests[0]?.messages)
const eventTypes = yield* recordedEventTypes(sessionID)
expect(eventTypes).toContain("session.retry.scheduled.1")
expect(eventTypes.filter((type) => type === "session.step.started.1")).toHaveLength(2)
expect(yield* s.context).toMatchObject([
{ type: "user" },
Expected.assistant({ finish: "stop" }, [Expected.text("Recovered")]),
])
yield* replaySessionProjection(sessionID)
expect((yield* s.context).filter((message) => message.type === "assistant")).toHaveLength(1)
})
}
scenario("does not start another physical attempt after interruption during retry backoff", function* (s) {
yield* s.admit("Interrupt retry backoff")
@@ -5327,47 +5353,49 @@ describe("SessionRunnerLLM", () => {
])
})
scenario("continues after a transport read failure with durable reasoning state", function* (s) {
yield* s.admit("Recover disconnected reasoning")
yield* s.llm.push(
TestLLM.failAfter(
streamDisconnected(),
LLMEvent.stepStart({ index: 0 }),
LLMEvent.reasoningStart({
id: "disconnected-reasoning",
providerMetadata: {
openai: { itemId: "rs_disconnected", reasoningEncryptedContent: "encrypted-state" },
for (const failure of [streamDisconnected, websocketDisconnected]) {
scenario(`continues after ${failure.name} with durable reasoning state`, function* (s) {
yield* s.admit("Recover disconnected reasoning")
yield* s.llm.push(
TestLLM.failAfter(
failure(),
LLMEvent.stepStart({ index: 0 }),
LLMEvent.reasoningStart({
id: "disconnected-reasoning",
providerMetadata: {
openai: { itemId: "rs_disconnected", reasoningEncryptedContent: "encrypted-state" },
},
}),
),
)
yield* s.llm.push(TestLLM.text("Recovered", "reasoning-transport-recovery"))
const scheduled = yield* subscribeRetries(s)
const run = yield* s.resume.pipe(Effect.forkChild)
yield* Queue.take(scheduled)
yield* TestClock.adjust("2400 millis")
yield* Fiber.join(run)
expect(s.requests).toHaveLength(2)
expect(yield* recordedEventTypes(sessionID)).toContain("session.retry.scheduled.1")
expect(s.requests[1]?.messages.slice(-2)).toMatchObject([
{ role: "user", content: [{ type: "text", text: "Recover disconnected reasoning" }] },
{ role: "user", content: [{ type: "text", text: INCOMPLETE_STREAM_CONTINUATION }] },
])
expect(yield* s.context).toMatchObject([
{ type: "user" },
Expected.assistant({ finish: "error" }, [
{
type: "reasoning",
text: "",
state: { itemId: "rs_disconnected", reasoningEncryptedContent: "encrypted-state" },
},
}),
),
)
yield* s.llm.push(TestLLM.text("Recovered", "reasoning-transport-recovery"))
const scheduled = yield* subscribeRetries(s)
const run = yield* s.resume.pipe(Effect.forkChild)
yield* Queue.take(scheduled)
yield* TestClock.adjust("2400 millis")
yield* Fiber.join(run)
expect(s.requests).toHaveLength(2)
expect(yield* recordedEventTypes(sessionID)).toContain("session.retry.scheduled.1")
expect(s.requests[1]?.messages.slice(-2)).toMatchObject([
{ role: "user", content: [{ type: "text", text: "Recover disconnected reasoning" }] },
{ role: "user", content: [{ type: "text", text: INCOMPLETE_STREAM_CONTINUATION }] },
])
expect(yield* s.context).toMatchObject([
{ type: "user" },
Expected.assistant({ finish: "error" }, [
{
type: "reasoning",
text: "",
state: { itemId: "rs_disconnected", reasoningEncryptedContent: "encrypted-state" },
},
]),
{ type: "synthetic", text: INCOMPLETE_STREAM_CONTINUATION },
Expected.assistant({ finish: "stop" }, [Expected.text("Recovered")]),
])
})
]),
{ type: "synthetic", text: INCOMPLETE_STREAM_CONTINUATION },
Expected.assistant({ finish: "stop" }, [Expected.text("Recovered")]),
])
})
}
scenario("continues an incomplete stream after settling a local tool", function* (s) {
yield* s.admit("Continue after tool")
+2 -2
View File
@@ -22,8 +22,8 @@ const createCodeMode = (tools: ReadonlyMap<string, Info>) =>
test("execute describes invariant Code Mode behavior", () => {
expect(createCodeMode(new Map()).description).toBe(
[
"Run JavaScript in a confined Code Mode runtime to orchestrate tool calls and compose their results.",
"Imports, direct filesystem access, and timers are unavailable. Do not use `fetch`; all external access goes through `tools`.",
"Run JavaScript in a confined Code Mode runtime to script tool calls and HTTP requests and compose their results.",
"`fetch` is available for HTTP requests. Imports, direct filesystem access, and timers are unavailable; all other external access goes through `tools`.",
"Within `{ code }`, the only callable tools are those explicitly listed in the Code Mode catalog instructions or returned by the `search` function. Inside `{ code }`, ignore tools shown outside the Code Mode catalog. They are not available in the Code Mode runtime.",
'Call tools through `tools` using only exact paths and signatures from the catalog. Do not infer or normalize tool names; preserve bracket notation such as `tools.<namespace>["tool-name"](input)`.',
"Prefer an explicit `return`; if omitted, the final top-level expression becomes the result.",
+134
View File
@@ -0,0 +1,134 @@
import { expect } from "bun:test"
import { Location } from "@opencode/core/location"
import { Plugin } from "@opencode/core/plugin"
import { PluginHost } from "@opencode/core/plugin/host"
import { Provider } from "@opencode/core/provider"
import { Session } from "@opencode/core/session"
import { Tool } from "@opencode/core/tool"
import { OpenCodeTools } from "@opencode/core/tool/plugin/opencode"
import { Model } from "@opencode/schema/model"
import { Effect } from "effect"
import { testEffect } from "./lib/effect"
import { executeTool, toolIdentity } from "./lib/tool"
import { PluginTestLayer } from "./plugin/fixture"
const it = testEffect(PluginTestLayer)
const alpha = { id: "test/alpha", name: "Alpha", released: 300, variants: ["fast"], cost: [], status: "beta" }
const beta = { id: "other/beta", name: "Beta", released: 200, variants: [], cost: [], status: "active" }
const gamma = { id: "other/gamma", name: "Gamma Flash", released: 100, variants: [], cost: [], status: "active" }
const gammaOld = {
id: "other/gamma-old",
name: "Gamma Flash Old",
released: 50,
variants: [],
cost: [],
status: "active",
}
it.effect("groups available models by provider with paging", () =>
Effect.gen(function* () {
const catalog = yield* Provider.Service
const plugins = yield* Plugin.Service
const sessions = yield* Session.Service
const location = yield* Location.Service
const pluginHost = yield* PluginHost.make(plugins)
yield* catalog.transform((editor) => {
editor.update(Provider.ID.make("other"), (provider) => {
provider.name = "Other Provider"
})
editor.models.update(Provider.ID.make("test"), Model.ID.make("alpha"), (model) => {
model.name = "Alpha"
model.time.released = 300
model.variants = [{ id: Model.VariantID.make("fast") }]
model.status = "beta"
})
editor.models.update(Provider.ID.make("other"), Model.ID.make("beta"), (model) => {
model.name = "Beta"
model.time.released = 200
})
editor.models.update(Provider.ID.make("other"), Model.ID.make("gamma"), (model) => {
model.name = "Gamma Flash"
model.time.released = 100
model.family = Model.Family.make("gamma")
})
editor.models.update(Provider.ID.make("other"), Model.ID.make("gamma-old"), (model) => {
model.name = "Gamma Flash Old"
model.time.released = 50
model.family = Model.Family.make("gamma")
})
editor.models.update(Provider.ID.make("other"), Model.ID.make("disabled"), (model) => {
model.time.released = 400
model.enabled = false
})
})
yield* OpenCodeTools.Plugin.effect(pluginHost)
// The caller runs on `test`, which sorts first despite `other` coming earlier alphabetically.
const session = yield* sessions.create({
location: Location.Ref.make({ directory: location.directory }),
model: Model.Ref.make({ providerID: Provider.ID.make("test"), id: Model.ID.make("alpha") }),
})
const registry = yield* Tool.Service
const run = (input: Record<string, unknown>) =>
executeTool(registry, {
sessionID: session.id,
...toolIdentity,
call: {
type: "tool-call",
id: `call-${JSON.stringify(input)}`,
name: "execute",
input: { code: `return await tools.opencode.models(${JSON.stringify(input)})` },
},
}).pipe(Effect.map((result) => JSON.parse(result.content?.[0]?.type === "text" ? result.content[0].text : "")))
// Grouped by provider, newest first within each, disabled models excluded.
expect(yield* run({})).toEqual({
providers: [
{ id: "test", name: "test", models: [alpha] },
{ id: "other", name: "Other Provider", models: [beta, gamma] },
],
total: 3,
next: null,
})
// Paging slices the ordered list, so a page can end inside a provider group.
expect(yield* run({ limit: 2 })).toEqual({
providers: [
{ id: "test", name: "test", models: [alpha] },
{ id: "other", name: "Other Provider", models: [beta] },
],
total: 3,
next: 2,
})
expect(yield* run({ limit: 2, offset: 2 })).toEqual({
providers: [{ id: "other", name: "Other Provider", models: [gamma] }],
total: 3,
next: null,
})
expect(yield* run({ provider: "other provider" })).toMatchObject({ total: 2, providers: [{ id: "other" }] })
expect(yield* run({ provider: "test" })).toEqual({
providers: [{ id: "test", name: "test", models: [alpha] }],
total: 1,
next: null,
})
// Every word of the query must appear somewhere in the reference or display name, ignoring case.
expect(yield* run({ query: "GAMMA" })).toEqual({
providers: [{ id: "other", name: "Other Provider", models: [gamma] }],
total: 1,
next: null,
})
expect(yield* run({ query: "test/" })).toMatchObject({ total: 1, providers: [{ id: "test" }] })
expect(yield* run({ query: "other flash" })).toMatchObject({ total: 1, providers: [{ models: [gamma] }] })
expect(yield* run({ query: "gamma beta" })).toEqual({ providers: [], total: 0, next: null })
// Only the newest model of each family is listed unless `all` is set; the query is applied first.
expect(yield* run({ all: true })).toMatchObject({
total: 4,
providers: [{ id: "test" }, { id: "other", models: [beta, gamma, gammaOld] }],
})
expect(yield* run({ query: "old" })).toMatchObject({ total: 1, providers: [{ models: [gammaOld] }] })
expect(yield* run({ provider: "other", query: "alpha" })).toEqual({ providers: [], total: 0, next: null })
}),
)
+71 -1
View File
@@ -45,6 +45,11 @@ const completedOutput = (sessionID: Session.ID) =>
`<subagent sessionID="${sessionID}" state="completed">\n${childText}\n</subagent>`
const childModel = Model.Ref.make({ id: Model.ID.make("child"), providerID: Provider.ID.make("test") })
const parentModel = Model.Ref.make({ id: Model.ID.make("parent"), providerID: Provider.ID.make("test") })
const overrideModel = Model.Ref.make({
id: Model.ID.make("override"),
providerID: Provider.ID.make("test"),
variant: Model.VariantID.make("fast"),
})
const tokens = { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }
const outputSessionID = (value: unknown) =>
@@ -107,7 +112,7 @@ const executionNode = makeGlobalNode({
const subagentPluginSupervisor = makeLocationNode({
name: "test/subagent-plugins",
layer: Layer.effectDiscard(registerToolPlugin(SubagentTool.Plugin)),
deps: [Agent.node, Config.node, Permission.node, Session.node, Job.node, Tool.node],
deps: [Agent.node, Config.node, Model.node, Permission.node, Session.node, Job.node, Tool.node],
})
const nodes = LayerNode.group([
@@ -155,6 +160,14 @@ const withSubagent = (location: Location.Ref) =>
Effect.gen(function* () {
const locations = yield* LocationServiceMap.Service
yield* Plugin.Service.use((plugins) => plugins.awaitActivation).pipe(Effect.provide(locations.get(location)))
yield* Provider.Service.use((providers) =>
providers.transform((editor) => {
editor.models.update(overrideModel.providerID, overrideModel.id, (model) => {
model.variants = [{ id: Model.VariantID.make("fast") }]
})
editor.models.update(Provider.ID.make("test"), Model.ID.make("plain"), () => {})
}),
).pipe(Effect.provide(locations.get(location)))
yield* Agent.Service.use((agents) =>
agents.transform((editor) => {
// The caller identity used by executeTool; subagent permission asserts against it.
@@ -615,6 +628,63 @@ describe("SubagentTool", () => {
),
)
it.live("runs the child on an explicitly requested model", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((dir) =>
Effect.gen(function* () {
const location = Location.Ref.make({ directory: AbsolutePath.make(dir.path) })
const sessions = yield* Session.Service
const parent = yield* sessions.create({ location, model: parentModel })
yield* withSubagent(parent.location)
const locations = yield* LocationServiceMap.Service
const registry = yield* Tool.Service.pipe(Effect.provide(locations.get(parent.location)))
const call = (id: string, input: Record<string, unknown>) =>
executeTool(registry, {
sessionID: parent.id,
...toolIdentity,
call: {
type: "tool-call" as const,
id,
name: SubagentTool.name,
input: { agent: "reviewer", description: "review", prompt: "review this", ...input },
},
})
// The requested model beats the agent's configured model.
const spawned = yield* call("call-override", { model: "test/override#fast" })
expect(spawned).toMatchObject({ status: "completed", metadata: { status: "completed" } })
const child = yield* sessions.get(outputSessionID(spawned.metadata))
expect(child).toMatchObject({ agent: "reviewer", model: overrideModel })
// Continuing with a model switches the existing child even when the agent is unchanged.
const continued = yield* call("call-override-continue", { sessionID: child.id, model: "test/override" })
expect(continued).toMatchObject({ status: "completed", metadata: { sessionID: child.id } })
expect((yield* sessions.get(child.id)).model).toEqual({
id: overrideModel.id,
providerID: overrideModel.providerID,
variant: Model.VariantID.make("default"),
})
const failures = [
["not-a-ref", 'Invalid model "not-a-ref". Use "providerID/modelID" or "providerID/modelID#variant".'],
["test/missing", 'Model "test/missing" is not available. Use the models tool to see what is available.'],
["test/override#slow", 'Variant "slow" is not available for "test/override". Available: fast.'],
["test/plain#high", 'Model "test/plain" has no variants. Omit the variant.'],
] as const
for (const [model, message] of failures) {
expect(yield* call(`call-${model}`, { model })).toEqual({
status: "error",
error: { type: "tool.execution", message },
})
}
}),
),
),
)
it.live("returns child runner failures as tool errors", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
+3 -3
View File
@@ -151,13 +151,13 @@ describeHg("Vcs mercurial", () => {
})
const vcs = yield* Vcs.Service
const bus = yield* Bus.Service
expect(yield* vcs.info()).toEqual({ branch: { current: "default", default: "default" } })
expect(yield* vcs.info()).toEqual({ provider: "hg", branch: { current: "default", default: "default" } })
const updated = yield* bus
.subscribe(VcsEvent.BranchUpdated)
.pipe(Stream.take(1), Stream.runHead, Effect.forkScoped({ startImmediately: true }))
yield* Effect.promise(() => hg(directory, "branch", "-q", "feature"))
expect(yield* vcs.info()).toEqual({ branch: { current: "default", default: "default" } })
expect(yield* vcs.info()).toEqual({ provider: "hg", branch: { current: "default", default: "default" } })
yield* bus.publish(FileSystem.Event.Changed, {
file: path.join(directory, ".hg", "branch"),
@@ -167,7 +167,7 @@ describeHg("Vcs mercurial", () => {
_tag: "Some",
value: { location: { directory }, data: { branch: "feature" } },
})
expect(yield* vcs.info()).toEqual({ branch: { current: "feature", default: "default" } })
expect(yield* vcs.info()).toEqual({ provider: "hg", branch: { current: "feature", default: "default" } })
}),
),
)
+10 -10
View File
@@ -126,7 +126,7 @@ describe("Vcs", () => {
editor.default.set("custom")
})
expect(yield* vcs.info()).toEqual({ branch: { current: "feature", default: "main" } })
expect(yield* vcs.info()).toEqual({ provider: "custom", branch: { current: "feature", default: "main" } })
expect(yield* vcs.base()).toBeNull()
expect(yield* vcs.branches()).toEqual(["feature", "main"])
expect(yield* vcs.status()).toEqual([{ file: "file.txt", additions: 1, deletions: 0, status: "added" }])
@@ -146,10 +146,10 @@ describe("Vcs", () => {
Effect.gen(function* () {
const vcs = yield* Vcs.Service
const registration = yield* vcs.transform((editor) => editor.add(provider({ id: "git" })))
expect(yield* vcs.info()).toEqual({ branch: { current: "feature", default: "main" } })
expect(yield* vcs.info()).toEqual({ provider: "git", branch: { current: "feature", default: "main" } })
yield* registration.dispose
expect(yield* vcs.info()).toEqual({ branch: { current: "main", default: undefined } })
expect(yield* vcs.info()).toEqual({ provider: "git", branch: { current: "main", default: undefined } })
}),
),
)
@@ -188,7 +188,7 @@ describe("Vcs", () => {
}),
)
expect(reads).toEqual(["final"])
expect(yield* vcs.info()).toEqual({ branch: { current: "final" } })
expect(yield* vcs.info()).toEqual({ provider: "custom", branch: { current: "final" } })
}),
)
@@ -456,7 +456,7 @@ describe("Vcs", () => {
),
)
expect((yield* vcs.status())[0]?.file).toBe("config.txt")
expect(yield* vcs.info()).toEqual({ branch: { current: "initial" } })
expect(yield* vcs.info()).toEqual({ provider: "git", branch: { current: "initial" } })
yield* Deferred.succeed(accepted, undefined)
}),
).pipe(Effect.forkScoped({ startImmediately: true }))
@@ -466,7 +466,7 @@ describe("Vcs", () => {
yield* Deferred.succeed(release, undefined)
yield* Fiber.join(configured)
expect(Option.getOrUndefined(yield* Fiber.join(updates))?.data.branch).toBe("config")
expect(yield* vcs.info()).toEqual({ branch: { current: "config" } })
expect(yield* vcs.info()).toEqual({ provider: "git", branch: { current: "config" } })
expect(reads).toEqual(["initial", "filesystem", "config"])
}).pipe(Effect.ensuring(Deferred.succeed(release, undefined)))
}),
@@ -502,7 +502,7 @@ describe("Vcs", () => {
})
yield* bus.publish(Done, {})
const events = (yield* Fiber.join(updates)).filter((event) => event.type === VcsEvent.BranchUpdated.type)
expect(yield* vcs.info()).toEqual({ branch: { current: "listener" } })
expect(yield* vcs.info()).toEqual({ provider: "custom", branch: { current: "listener" } })
expect(events.length).toBeGreaterThanOrEqual(2)
expect(events.at(-1)?.data.branch).toBe((yield* vcs.info()).branch.current)
}).pipe(Effect.ensuring(unsubscribe))
@@ -567,7 +567,7 @@ describe("Vcs", () => {
})
const vcs = yield* Vcs.Service
const bus = yield* Bus.Service
expect(yield* vcs.info()).toEqual({ branch: { current: "main", default: undefined } })
expect(yield* vcs.info()).toEqual({ provider: "git", branch: { current: "main", default: undefined } })
const updated = yield* bus
.subscribe(VcsEvent.BranchUpdated)
@@ -575,14 +575,14 @@ describe("Vcs", () => {
yield* Effect.promise(() => $`git checkout -q -b feature`.cwd(directory).quiet())
yield* bus.publish(FileSystem.Event.Changed, { file: path.join(directory, "HEAD"), event: "change" })
expect(yield* vcs.info()).toEqual({ branch: { current: "main", default: undefined } })
expect(yield* vcs.info()).toEqual({ provider: "git", branch: { current: "main", default: undefined } })
yield* bus.publish(FileSystem.Event.Changed, { file: path.join(directory, ".git", "HEAD"), event: "change" })
expect(yield* Fiber.join(updated)).toMatchObject({
_tag: "Some",
value: { location: { directory }, data: { branch: "feature" } },
})
expect(yield* vcs.info()).toEqual({ branch: { current: "feature", default: "main" } })
expect(yield* vcs.info()).toEqual({ provider: "git", branch: { current: "feature", default: "main" } })
}),
),
)
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode/desktop",
"private": true,
"version": "2.0.3",
"version": "2.0.4",
"type": "module",
"license": "MIT",
"homepage": "https://opencode.ai",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/enterprise",
"version": "2.0.3",
"version": "2.0.4",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/function",
"version": "2.0.3",
"version": "2.0.4",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "2.0.3",
"version": "2.0.4",
"name": "@opencode/http-recorder",
"description": "Record and replay Effect HTTP and WebSocket traffic with deterministic cassettes",
"type": "module",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/httpapi-codegen",
"version": "2.0.3",
"version": "2.0.4",
"private": true,
"type": "module",
"exports": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/latex",
"version": "2.0.3",
"version": "2.0.4",
"private": true,
"type": "module",
"exports": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/merman",
"version": "2.0.3",
"version": "2.0.4",
"private": true,
"type": "module",
"exports": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/plugin-browser",
"version": "2.0.3",
"version": "2.0.4",
"description": "OpenCode's desktop browser plugin",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/plugin",
"version": "2.0.3",
"version": "2.0.4",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/protocol",
"version": "2.0.3",
"version": "2.0.4",
"type": "module",
"license": "MIT",
"repository": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/schema",
"version": "2.0.3",
"version": "2.0.4",
"type": "module",
"license": "MIT",
"repository": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "2.0.3",
"version": "2.0.4",
"name": "@opencode/sdk",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/server",
"version": "2.0.3",
"version": "2.0.4",
"type": "module",
"license": "MIT",
"repository": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/session-ui",
"version": "2.0.3",
"version": "2.0.4",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/simulation",
"version": "2.0.3",
"version": "2.0.4",
"type": "module",
"license": "MIT",
"repository": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/stats-app",
"version": "2.0.3",
"version": "2.0.4",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/stats-core",
"version": "2.0.3",
"version": "2.0.4",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/stats-server",
"version": "2.0.3",
"version": "2.0.4",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/theme",
"version": "2.0.3",
"version": "2.0.4",
"type": "module",
"license": "MIT",
"repository": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/tui",
"version": "2.0.3",
"version": "2.0.4",
"private": true,
"type": "module",
"license": "MIT",
+1
View File
@@ -411,6 +411,7 @@ export function RunFooterView(props: RunFooterViewProps) {
if (notice()) return notice()
if (!footerDetails()) return shell() ? "Shell" : ""
if (busy()) {
if (stateStatus() === "reconnecting") return "reconnecting"
return interruptLabel() ? `${interruptLabel()} stop` : "Running"
}
return stateStatus() || (shell() ? "Shell" : "")
+14 -17
View File
@@ -520,6 +520,14 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
const abortReady = () => readyReject(new Error("Mini closed before the event stream connected"))
controller.signal.addEventListener("abort", abortReady, { once: true })
const offFooterClose = input.footer.onClose(() => controller.abort())
const waitUntilConnected = async (signal?: AbortSignal) => {
const abort = signal ? AbortSignal.any([signal, controller.signal]) : controller.signal
while (!state.connected) {
if (state.closed || controller.signal.aborted || input.footer.isClosed || signal?.aborted)
throw new Error("Event stream aborted")
await wait(25, abort)
}
}
const current = (attempt: Attempt) =>
!state.closed &&
!controller.signal.aborted &&
@@ -991,7 +999,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
phase: state.rootActive ? "running" : "idle",
status: state.rootActive ? "assistant responding" : blockerStatus(state.view),
})
if (!state.rootActive) await input.footer.idle()
if (!state.rootActive && !next.reconnect) await input.footer.idle()
if (!current(attempt)) return
}
@@ -1462,13 +1470,6 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
})
return task
}
const settleCatalog = async (attempt: Attempt) => {
while (current(attempt)) {
const refreshes = catalogRefreshes.get(attempt.generation)
if (!refreshes || refreshes.size === 0) return
await Promise.all(refreshes)
}
}
const settleCatalogRefreshes = async () => {
while (catalogRefreshes.size > 0)
await Promise.all([...catalogRefreshes.values()].flatMap((refreshes) => [...refreshes]))
@@ -1506,18 +1507,14 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
),
consume,
])
await Promise.race([refreshCatalog(attempt), consume])
if (!current(attempt)) throw new Error("Event stream disconnected")
state.initial = false
do {
for (const event of buffered.splice(0)) apply(attempt, event)
await Promise.race([subagents.ready(), consume])
await Promise.race([settleCatalog(attempt), consume])
} while (buffered.length > 0)
for (const event of buffered.splice(0)) apply(attempt, event)
if (!current(attempt)) throw new Error("Event stream disconnected")
booting = false
state.connected = true
readyResolve()
void refreshCatalog(attempt)
await consume
} finally {
connection.abort()
@@ -1563,7 +1560,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
const runShellTurn = async (next: SessionTurnInput) => {
if (state.wait || state.shellWait) throw new Error("prompt already running")
if (!state.connected) throw new Error("Event stream is reconnecting")
await waitUntilConnected(next.signal)
const client = sdk
const abort = new AbortController()
const onAbort = () => abort.abort()
@@ -1796,7 +1793,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
return {
async admitPromptTurn(next, delivery) {
if (next.prompt.mode === "shell") throw new Error("This prompt cannot be queued")
if (!state.connected) throw new Error("Event stream is reconnecting")
await waitUntilConnected(next.signal)
const client = sdk
if (!next.prompt.command && next.agent)
await client.session.switchAgent({ sessionID: input.sessionID, agent: next.agent }, { signal: next.signal })
@@ -1821,7 +1818,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
return
}
if (state.wait || state.shellWait) throw new Error("prompt already running")
if (!state.connected) throw new Error("Event stream is reconnecting")
await waitUntilConnected(next.signal)
const client = sdk
const messageID = next.prompt.messageID
if (!messageID) throw new Error("Prompt message ID is required")
@@ -0,0 +1,255 @@
import { TextAttributes, type CodeRenderable, type ScrollBoxRenderable } from "@opentui/core"
import { useTerminalDimensions } from "@opentui/solid"
import type { SessionMessageAssistantTool } from "@opencode/client/promise"
import { Option, Schema } from "effect"
import { createMemo, createSignal, Show } from "solid-js"
import stripAnsi from "strip-ansi"
import { useConfig } from "../../config"
import { useClipboard } from "../../context/clipboard"
import { Keymap } from "../../context/keymap"
import { useTheme, useThemes } from "../../context/theme"
import { dialogWidth, useDialog } from "../../ui/dialog"
import { useToast } from "../../ui/toast"
import { Locale } from "../../util/locale"
import { getScrollAcceleration } from "../../util/scroll"
const decodeJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown))
// The part is passed as a live accessor prop so the dialog follows the tool
// while child calls stream and the output arrives.
export function DialogExecute(props: { part: SessionMessageAssistantTool }) {
const dialog = useDialog()
const clipboard = useClipboard()
const toast = useToast()
const theme = useTheme("elevated")
const dimensions = useTerminalDimensions()
const config = useConfig().data
const [copied, setCopied] = createSignal<"code" | "output">()
const maxHeight = createMemo(() => Math.max(3, Math.floor(dimensions().height * 0.7) - 6))
let scroll: ScrollBoxRenderable | undefined
// Unwrapped <code> clips long lines and scrolls them itself. Each block clamps
// to its own width, so drive both from one shared offset or the narrower block
// stops early and the two drift apart on the way back.
const blocks = new Set<CodeRenderable>()
let panX = 0
const pan = (delta: number) => {
const max = Math.max(0, ...[...blocks].map((block) => block.scrollWidth - block.width))
panX = Math.max(0, Math.min(max, panX + delta))
blocks.forEach((block) => (block.scrollX = panX))
}
dialog.setSize("xlarge")
dialog.setCentered(true)
const code = createMemo(() => executeCode(props.part.state.input))
const text = createMemo(() => outputText(props.part.state))
const highlighted = createMemo(() => {
const value = text()
return value ? highlightedOutput(value) : undefined
})
const failed = createMemo(() => {
const state = props.part.state
if (state.status === "error") return true
if (state.status === "streaming") return false
return state.metadata?.error === true
})
// Both blocks share one gutter width so their content starts on the same column.
const digits = createMemo(() => String(Math.max(lineCount(code()), lineCount(highlighted()?.json), 1)).length)
// Code and JSON never wrap, so the content height is known up front. Sizing
// synchronously lets the dialog open complete instead of growing over frames.
const height = createMemo(() => {
const width = Math.max(20, Math.min(dialogWidth(dialog.size), dimensions().width - 2) - 4)
const body = highlighted()
const outputRows = body ? lineCount(body.json) + wrappedRows(body.rest, width) : wrappedRows(text(), width) || 1
return Math.min(maxHeight(), (lineCount(code()) || 1) + outputRows + 3)
})
const status = createMemo(() => {
const state = props.part.state
if (state.status === "streaming") return "Receiving code…"
if (state.status === "running") return "Running"
const duration = props.part.time.completed
? ` · ${Locale.duration(props.part.time.completed - (props.part.time.ran ?? props.part.time.created))}`
: ""
if (failed()) return `Failed${duration}`
return `Completed${duration}`
})
const copy = (kind: "code" | "output") => {
const value = kind === "code" ? code() : text()
if (!value) return
void clipboard
.write(value)
.then(() => setCopied(kind))
.catch(toast.error)
}
Keymap.createLayer(() => ({
mode: "modal",
commands: [
{ bind: "up", title: "Scroll up", group: "Execute", run: () => scroll?.scrollBy(-1) },
{ bind: "down", title: "Scroll down", group: "Execute", run: () => scroll?.scrollBy(1) },
{ bind: "pageup", title: "Previous page", group: "Execute", run: () => scroll?.scrollBy(-maxHeight()) },
{ bind: "pagedown", title: "Next page", group: "Execute", run: () => scroll?.scrollBy(maxHeight()) },
{ bind: "left", title: "Scroll left", group: "Execute", run: () => pan(-8) },
{ bind: "right", title: "Scroll right", group: "Execute", run: () => pan(8) },
{ bind: "home", title: "Scroll to code", group: "Execute", run: () => scroll?.scrollTo(0) },
{ bind: "end", title: "Scroll to output", group: "Execute", run: () => scroll?.scrollTo(Infinity) },
{ bind: "c", title: "Copy code", group: "Execute", run: () => copy("code") },
{ bind: "o", title: "Copy output", group: "Execute", run: () => copy("output") },
],
}))
return (
<box paddingLeft={2} paddingRight={2} paddingBottom={1} gap={1}>
<box flexDirection="row" gap={2}>
<text fg={theme.text.default} attributes={TextAttributes.BOLD} flexGrow={1}>
execute
</text>
<text fg={failed() ? theme.text.feedback.error.default : theme.text.subdued}>{status()}</text>
<text fg={theme.text.subdued} onMouseUp={() => dialog.clear()}>
esc
</text>
</box>
<scrollbox
id="execute-detail-scroll"
ref={(value: ScrollBoxRenderable) => (scroll = value)}
height={height()}
scrollbarOptions={{ visible: false }}
scrollAcceleration={getScrollAcceleration(config)}
>
<box gap={1}>
<box>
<text fg={theme.text.subdued} attributes={TextAttributes.BOLD}>
Code
</text>
<Show when={code()} fallback={<text fg={theme.text.subdued}>Waiting for code</text>}>
{(value) => <GutteredCode content={value()} filetype="typescript" digits={digits()} blocks={blocks} />}
</Show>
</box>
<box>
<text fg={theme.text.subdued} attributes={TextAttributes.BOLD}>
Output
</text>
<Show
when={highlighted()}
fallback={
<text
fg={text() ? (failed() ? theme.text.feedback.error.default : theme.text.default) : theme.text.subdued}
wrapMode="word"
>
{text() ?? (props.part.state.status === "completed" ? "No output" : "Waiting for output…")}
</text>
}
>
{(body) => (
<>
<GutteredCode content={body().json} filetype="json" digits={digits()} blocks={blocks} />
<Show when={body().rest}>
{(rest) => (
<box paddingLeft={digits() + 1}>
<text fg={failed() ? theme.text.feedback.error.default : theme.text.default} wrapMode="word">
{rest()}
</text>
</box>
)}
</Show>
</>
)}
</Show>
</box>
</box>
</scrollbox>
<box flexDirection="row" gap={3} flexWrap="wrap">
<text fg={theme.text.subdued}>/ / scroll</text>
<text onMouseUp={() => copy("code")}>
<span style={{ fg: copied() === "code" ? theme.text.feedback.success.default : theme.text.default }}>
<b>{copied() === "code" ? "✓ copied" : "c"}</b>
</span>
<span style={{ fg: theme.text.subdued }}>{copied() === "code" ? "" : " copy code"}</span>
</text>
<text onMouseUp={() => copy("output")}>
<span style={{ fg: copied() === "output" ? theme.text.feedback.success.default : theme.text.default }}>
<b>{copied() === "output" ? "✓ copied" : "o"}</b>
</span>
<span style={{ fg: theme.text.subdued }}>{copied() === "output" ? "" : " copy output"}</span>
</text>
<text fg={theme.text.subdued}>esc back</text>
</box>
</box>
)
}
function executeCode(input: SessionMessageAssistantTool["state"]["input"]) {
if (typeof input === "string") return
return typeof input.code === "string" && input.code ? input.code : undefined
}
function outputText(state: SessionMessageAssistantTool["state"]) {
if (state.status === "error") return state.error.message || undefined
if (state.status !== "completed") return
const text = stripAnsi(
state.content
.flatMap((item) => (item.type === "text" ? [item.text] : []))
.join("\n")
.trim(),
)
return text || undefined
}
// The tool prints a JSON result, optionally followed by "\n\nWarnings:" and "\n\nLogs:".
function highlightedOutput(text: string) {
if (!text.startsWith("{") && !text.startsWith("[")) return
const end = text.search(/\n\n(Warnings|Logs):\n/)
const json = end === -1 ? text : text.slice(0, end)
if (Option.isNone(decodeJson(json))) return
return { json, rest: text.slice(json.length).trim() || undefined }
}
function lineCount(text: string | undefined) {
return text ? text.split("\n").length : 0
}
function wrappedRows(text: string | undefined, width: number) {
if (!text) return 0
return text.split("\n").reduce((sum, line) => sum + Math.max(1, Math.ceil(line.length / width)), 0)
}
// `<line_number>` right-aligns digits in a gutter sized to that block alone, so
// the column "1" starts on depends on the line count and differs between calls.
// One shared left-aligned gutter keeps the title and every line number on the
// same column, and keeps code and output on one content column.
function GutteredCode(props: {
content: string
filetype: "typescript" | "json"
digits: number
blocks: Set<CodeRenderable>
}) {
const theme = useTheme("elevated")
const syntax = useThemes().currentSyntax
const gutter = createMemo(() =>
props.content
.split("\n")
.map((_, index) => String(index + 1).padEnd(props.digits))
.join("\n"),
)
return (
<box flexDirection="row" gap={1} width="100%">
<text fg={theme.text.subdued} flexShrink={0} width={props.digits}>
{gutter()}
</text>
<box flexGrow={1} flexShrink={1} minWidth={0}>
<code
ref={(block: CodeRenderable) => props.blocks.add(block)}
width="100%"
conceal={false}
wrapMode="none"
fg={theme.text.default}
filetype={props.filetype}
syntaxStyle={syntax()}
content={props.content}
/>
</box>
</box>
)
}
+32 -20
View File
@@ -40,11 +40,15 @@ import { Locale } from "../../util/locale"
import { FilePath } from "../../ui/file-path"
import {
canonicalToolName,
executeCalls,
executeCallSummary,
finiteNumber,
primitiveInputSummary,
toolDisplayContent,
toolDisplayMetadata,
type ExecuteCall,
} from "../../util/tool-display"
import { DialogExecute } from "./dialog-execute"
import { RetryProvider } from "../../component/retry-provider"
import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
import { useClient } from "../../context/client"
@@ -2965,7 +2969,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 +2995,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>
@@ -3178,23 +3204,7 @@ export function isBackgroundSubagent(
return status === "completed" && metadata.status === "running"
}
type ExecuteCall = { tool: string; status: "running" | "completed" | "error"; input?: Record<string, unknown> }
function executeCalls(value: unknown): ExecuteCall[] {
if (!Array.isArray(value)) return []
return value.flatMap((call) => {
const item = recordValue(call)
const tool = stringValue(item?.tool)
const status = stringValue(item?.status)
if (!tool || !status || !["running", "completed", "error"].includes(status)) return []
return [{ tool, status: status as ExecuteCall["status"], input: recordValue(item?.input) }]
})
}
export function executeCallSummary(call: ExecuteCall) {
const args = primitiveInputSummary(call.input ?? {}).replace(/\s+/g, " ")
return `${call.tool}${args ? ` ${args}` : ""}`
}
export { executeCallSummary }
function ExecuteCallView(props: { call: Accessor<ExecuteCall> }) {
const theme = useTheme()
@@ -3252,6 +3262,7 @@ function ExecuteCallView(props: { call: Accessor<ExecuteCall> }) {
function Execute(props: ToolProps) {
const ctx = use()
const theme = useTheme()
const dialog = useDialog()
const isLoading = createMemo(() => props.part.state.status === "streaming" || props.part.state.status === "running")
const calls = createMemo(() => executeCalls(props.metadata.toolCalls))
const output = createMemo(() => stripAnsi(props.output?.trim() ?? ""))
@@ -3268,6 +3279,7 @@ function Execute(props: ToolProps) {
pending="execute"
complete={true}
part={props.part}
onClick={() => dialog.replace(() => <DialogExecute part={props.part} />)}
>
execute
</InlineTool>
+56 -14
View File
@@ -1,3 +1,6 @@
import { Locale } from "./locale"
import { stringWidth } from "./string-width"
export function collapseToolOutput(output: string, maxLines: number, maxChars: number) {
const lines = output.split("\n")
if (lines.length <= maxLines && Array.from(output).length <= maxChars) {
@@ -21,21 +24,60 @@ export function collapseToolOutput(output: string, maxLines: number, maxChars: n
}
export function collapseShellOutput(input: string, output: string, maxLines: number, maxChars: number) {
const content = [input, output].filter(Boolean).join("\n\n")
const collapsed = collapseToolOutput(content, maxLines, maxChars)
if (!collapsed.overflow) return { input, output, overflow: false }
if (!input) return { input, output: collapseTail(output, maxLines, maxChars), overflow: true }
if (!output) return { input: collapsed.output, output, overflow: true }
if (!input) {
const collapsed = collapseToolOutput(output, maxLines, maxChars)
return {
input,
output: collapsed.overflow ? collapseTail(output, maxLines, maxChars) : output,
overflow: collapsed.overflow,
}
}
const reserved = Math.max(1, Math.floor(maxChars / maxLines))
const command = collapseToolOutput(
input,
Math.max(1, maxLines - 2),
Math.max(1, maxChars - reserved - 2),
).output
const lines = Math.max(1, maxLines - command.split("\n").length - 1)
const chars = Math.max(1, maxChars - Array.from(command).length - 2)
return { input: command, output: collapseTail(output, lines, chars), overflow: true }
const commandLines = Math.min(2, maxLines)
const lineChars = Math.max(1, Math.floor(maxChars / Math.max(1, maxLines)))
const command = collapseShellCommand(input, commandLines, lineChars)
if (!output) return { input: command.output, output, overflow: command.overflow }
const lines = Math.max(1, maxLines - command.lines - 1)
const chars = Math.max(1, maxChars - Array.from(command.output).length - 2)
const collapsed = collapseToolOutput(output, lines, chars)
return {
input: command.output,
output: collapsed.overflow ? collapseTail(output, lines, chars) : output,
overflow: command.overflow || collapsed.overflow,
}
}
function collapseShellCommand(input: string, maxLines: number, lineWidth: number) {
const visible: string[] = []
let lines = 1
let width = 0
const overflow = Locale.graphemes(input).some((segment) => {
if (segment === "\n") {
if (lines >= maxLines) return true
visible.push(segment)
lines++
width = 0
return false
}
const next = stringWidth(segment)
if (width + next > lineWidth) {
if (lines >= maxLines) return true
lines++
width = 0
}
visible.push(segment)
width += next
return false
})
if (!overflow) return { output: input, overflow, lines }
if (width >= lineWidth) {
const removed = visible.pop()
if (removed !== undefined && removed !== "\n") width -= stringWidth(removed)
}
return { output: visible.join("") + "…", overflow, lines }
}
function collapseTail(output: string, maxLines: number, maxChars: number) {
+19
View File
@@ -19,6 +19,24 @@ export function primitiveInputSummary(input: Record<string, unknown>, omit: read
return `[${entries.map(([key, value]) => `${key}=${String(value)}`).join(", ")}]`
}
export type ExecuteCall = { tool: string; status: "running" | "completed" | "error"; input?: Record<string, unknown> }
export function executeCalls(value: unknown): ExecuteCall[] {
if (!Array.isArray(value)) return []
return value.flatMap((call) => {
if (!isRecord(call)) return []
const tool = call.tool
const status = call.status
if (typeof tool !== "string" || (status !== "running" && status !== "completed" && status !== "error")) return []
return [{ tool, status, input: isRecord(call.input) ? call.input : undefined }]
})
}
export function executeCallSummary(call: ExecuteCall) {
const args = primitiveInputSummary(call.input ?? {}).replace(/\s+/g, " ")
return `${call.tool}${args ? ` ${args}` : ""}`
}
export function webSearchProviderName(provider: unknown) {
if (typeof provider !== "string" || !provider) return ""
return `${provider[0].toUpperCase()}${provider.slice(1)}`
@@ -48,3 +66,4 @@ export function nonEmptyToolContent<T>(content: ReadonlyArray<T> | undefined): [
return first === undefined ? undefined : [first, ...rest]
}
import type { SessionMessageAssistantTool } from "@opencode/client/promise"
import { isRecord } from "./record"
@@ -1847,70 +1847,6 @@ describe("V2 mini transport", () => {
firstEvents.close()
while (!replacementHydrating) await Bun.sleep(0)
await expect(
transport.runPromptTurn({
agent: undefined,
model: undefined,
variant: undefined,
prompt: { messageID: "msg_blocked", text: "blocked", parts: [] },
files: [],
includeFiles: true,
}),
).rejects.toThrow("Event stream is reconnecting")
secondEvents.push({
id: "evt_buffered_text",
created: 2,
type: "session.text.delta",
data: {
sessionID: "ses_1",
assistantMessageID: "msg_assistant",
ordinal: 0,
delta: " replacement",
},
})
let resized = false
const resize = transport.replayOnResize({
localRows: () => [],
reset: async () => {
resized = true
},
})
releaseHydration()
while (
!ui.events.some(
(event) => event.type === "stream.view" && event.view.type === "form" && event.view.request.id === "frm_child",
)
)
await Bun.sleep(0)
while (refreshes < 2) await Bun.sleep(0)
await resize
expect(resized).toBe(false)
await expect(
transport.runPromptTurn({
agent: undefined,
model: undefined,
variant: undefined,
prompt: { messageID: "msg_catalog_blocked", text: "blocked", parts: [] },
files: [],
includeFiles: true,
}),
).rejects.toThrow("Event stream is reconnecting")
releaseCatalog()
await Bun.sleep(0)
expect(current).toEqual([second])
expect(first.event.subscribe).toHaveBeenCalledTimes(1)
expect(second.event.subscribe).toHaveBeenCalledTimes(1)
expect(second.session.list).toHaveBeenCalled()
expect(second.session.form.list).toHaveBeenCalledWith(
{ sessionID: "ses_child" },
{ signal: expect.any(AbortSignal) },
)
expect(ui.commits.filter((commit) => commit.messageID === "msg_assistant").map((commit) => commit.text)).toEqual([
"partial",
" replacement",
])
const prompt = spyOn(second.session, "prompt").mockImplementation((request) => {
queueMicrotask(() => {
secondEvents.push({
@@ -1930,7 +1866,7 @@ describe("V2 mini transport", () => {
})
return ok({ data: promptAdmission(request) }) as never
})
await transport.runPromptTurn({
const queued = transport.runPromptTurn({
agent: undefined,
model: undefined,
variant: undefined,
@@ -1938,6 +1874,42 @@ describe("V2 mini transport", () => {
files: [],
includeFiles: true,
})
await Bun.sleep(0)
expect(prompt).not.toHaveBeenCalled()
secondEvents.push({
id: "evt_buffered_text",
created: 2,
type: "session.text.delta",
data: {
sessionID: "ses_1",
assistantMessageID: "msg_assistant",
ordinal: 0,
delta: " replacement",
},
})
releaseHydration()
while (
!ui.events.some(
(event) => event.type === "stream.view" && event.view.type === "form" && event.view.request.id === "frm_child",
)
)
await Bun.sleep(0)
await queued
while (refreshes < 2) await Bun.sleep(0)
releaseCatalog()
expect(current).toEqual([second])
expect(first.event.subscribe).toHaveBeenCalledTimes(1)
expect(second.event.subscribe).toHaveBeenCalledTimes(1)
expect(second.session.list).toHaveBeenCalled()
expect(second.session.form.list).toHaveBeenCalledWith(
{ sessionID: "ses_child" },
{ signal: expect.any(AbortSignal) },
)
expect(ui.commits.filter((commit) => commit.messageID === "msg_assistant").map((commit) => commit.text)).toEqual([
"partial",
" replacement",
])
const interrupt = spyOn(second.session, "interrupt").mockImplementation(() => ok({ interrupted: true }))
await transport.interruptActiveTurn()
@@ -1948,6 +1920,73 @@ describe("V2 mini transport", () => {
await transport.close()
})
test("sends a prompt after the event stream reconnects", async () => {
const first = feed()
const second = feed()
first.push(connected("evt_connected_1"))
second.push(connected("evt_connected_2"))
const client = sdk({ streams: [first, second] })
const ui = footer()
const transport = await createSessionTransport({
sdk: client,
sessionID: "ses_1",
thinking: false,
footer: ui.api,
})
first.close()
while (!ui.events.some((event) => event.type === "stream.patch" && event.patch.status === "reconnecting"))
await Bun.sleep(0)
const prompt = spyOn(client.session, "prompt").mockImplementation(
(request) => ok({ data: promptAdmission(request) }) as never,
)
await transport.runPromptTurn({
agent: undefined,
model: undefined,
variant: undefined,
prompt: { messageID: "msg_after_reconnect", text: "hello", parts: [] },
files: [],
includeFiles: true,
})
expect(prompt).toHaveBeenCalled()
expect(client.event.subscribe).toHaveBeenCalledTimes(2)
await transport.close()
})
test("reconnects even when catalog refresh hangs", async () => {
const first = feed()
const second = feed()
first.push(connected("evt_connected_1"))
second.push(connected("evt_connected_2"))
const client = sdk({ streams: [first, second] })
const ui = footer()
const transport = await createSessionTransport({
sdk: client,
sessionID: "ses_1",
thinking: false,
footer: ui.api,
onCatalogRefresh: (signal) =>
new Promise<void>((_resolve, reject) => {
signal?.addEventListener("abort", () => reject(new Error("aborted")), { once: true })
}),
})
first.close()
while (!ui.events.some((event) => event.type === "stream.patch" && event.patch.status === "reconnecting"))
await Bun.sleep(0)
const prompt = spyOn(client.session, "prompt").mockImplementation(
(request) => ok({ data: promptAdmission(request) }) as never,
)
await transport.runPromptTurn({
agent: undefined,
model: undefined,
variant: undefined,
prompt: { messageID: "msg_after_hanging_catalog", text: "hello", parts: [] },
files: [],
includeFiles: true,
})
expect(prompt).toHaveBeenCalled()
await transport.close()
})
test("reconciles buffered deltas already present in a resize snapshot", async () => {
const events = feed()
events.push(connected())
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/ui",
"version": "2.0.3",
"version": "2.0.4",
"type": "module",
"license": "MIT",
"repository": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/util",
"version": "2.0.3",
"version": "2.0.4",
"type": "module",
"license": "MIT",
"repository": {
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "@opencode/web",
"type": "module",
"license": "MIT",
"version": "2.0.3",
"version": "2.0.4",
"scripts": {
"dev": "astro dev",
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "opencode",
"displayName": "opencode",
"description": "opencode for VS Code",
"version": "2.0.3",
"version": "2.0.4",
"publisher": "sst-dev",
"repository": {
"type": "git",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/update",
"version": "2.0.3",
"version": "2.0.4",
"private": true,
"type": "module",
"scripts": {