mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-16 13:56:21 +00:00
Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c8301e155 | ||
|
|
cda2bc5100 | ||
|
|
ba753b383f | ||
|
|
eb052f70b4 | ||
|
|
5eaed68c2b | ||
|
|
7d7962b2fa | ||
|
|
0537fd47c7 | ||
|
|
9add9384be | ||
|
|
005522786f | ||
|
|
5379a2dfe0 | ||
|
|
6ee156e0b9 | ||
|
|
7c3c50bac2 | ||
|
|
194a006932 | ||
|
|
3c1f87d2d4 | ||
|
|
c57733a647 | ||
|
|
9bf5faa978 | ||
|
|
a062f4e803 |
@@ -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
@@ -2,7 +2,7 @@
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "opencode",
|
||||
"description": "AI-powered development tool",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.4",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "bun@1.4.2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.4",
|
||||
"name": "@opencode/ai",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/app",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.4",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -33,6 +33,15 @@ export default function Layout(props: ParentProps) {
|
||||
},
|
||||
install: installUpdate,
|
||||
}
|
||||
// A plain object avoids the compiler's conditional-prop memo, which leaks when read from event handlers.
|
||||
const debugTools = import.meta.env.DEV
|
||||
? {
|
||||
get visible() {
|
||||
return state.debugTools
|
||||
},
|
||||
toggle: () => setState("debugTools", (value) => !value),
|
||||
}
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<TitlebarRightProvider>
|
||||
@@ -53,11 +62,7 @@ export default function Layout(props: ParentProps) {
|
||||
<Titlebar
|
||||
update={update}
|
||||
verticalTabs={verticalTabs() ? { mount: state.tabsMount } : undefined}
|
||||
debugTools={
|
||||
import.meta.env.DEV
|
||||
? { visible: state.debugTools, toggle: () => setState("debugTools", (value) => !value) }
|
||||
: undefined
|
||||
}
|
||||
debugTools={debugTools}
|
||||
/>
|
||||
<div class="flex flex-1 min-h-0 min-w-0 flex-row">
|
||||
<Show when={verticalTabs()}>
|
||||
|
||||
@@ -792,7 +792,7 @@ function ChannelIndicator(props: {
|
||||
if (!channel || channel === "prod") return null
|
||||
|
||||
const label = () => language.t(`titlebar.channel.${channel}`)
|
||||
const debug = () => (channel === "dev" ? props.debugTools : undefined)
|
||||
const debug = () => (channel === "dev" || channel === "local" ? props.debugTools : undefined)
|
||||
return (
|
||||
<Tooltip
|
||||
placement={props.sidebar ? "right" : "bottom"}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/cli",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.4",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/client",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.4",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -88,8 +88,11 @@ runtime.catalog() // structured tool descriptions
|
||||
runtime.execute(source) // Effect<CodeMode.Result, never, ToolServices>
|
||||
```
|
||||
|
||||
The Effect environment is inferred from the supplied tools. `onToolCallStart` observes admitted calls with decoded
|
||||
input; `onToolCallEnd` observes settled outcomes and duration. Both hooks return Effects and must not fail.
|
||||
The Effect environment is inferred from the supplied tools. `hooks` surround every call the program makes into the
|
||||
host: `tool.before`/`tool.after` receive `{ name, input }` with decoded input, `extension.before`/`extension.after`
|
||||
receive `{ extension, name, args }`. An `after` hook also receives how the call ended (`success` with its value,
|
||||
`failure` with its error, or `interrupted`). A failing `before` hook denies the call, and the program catches the
|
||||
failure as a thrown error.
|
||||
|
||||
### `Values`
|
||||
|
||||
|
||||
@@ -467,6 +467,8 @@ Nothing is exposed unless a host provides it; extension calls are not tool calls
|
||||
- [x] A host function inside a result becomes a program function whose calls cross the same way, so a result can
|
||||
carry methods (`res.json()`) whose host closures keep the host state. Diagnostics name it by its path
|
||||
(`fetch.json`). Like any program function it vanishes at the data boundary.
|
||||
- [x] Each call to an extension global runs inside the host's `extension.before`/`extension.after` hooks as
|
||||
`{ extension, name, args }`, with the host's own error on failure; calls to functions inside results do not.
|
||||
- [x] A host `Promise` becomes a program promise. Whatever host code returns, resolves, throws, or rejects with
|
||||
crosses the same way, so `catch (e)` receives a copy of the thrown value (an `Error` of the matching type, or
|
||||
plain data).
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/codemode",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.4",
|
||||
"description": "Effect-native confined code execution over schema-described tools",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -7,7 +7,14 @@ import { type Services, type ToolDescription, ToolRuntime } from "./tool-runtime
|
||||
import type { Tools } from "./tools.js"
|
||||
|
||||
/** A tool call admitted during an execution. */
|
||||
export type { ToolCall, ToolCallEnded, ToolCallHooks, ToolCallStarted, ToolDescription } from "./tool-runtime.js"
|
||||
export type {
|
||||
CallResult,
|
||||
ExtensionInvocation,
|
||||
Hooks,
|
||||
ToolCall,
|
||||
ToolDescription,
|
||||
ToolInvocation,
|
||||
} from "./tool-runtime.js"
|
||||
/** Signature-construction helpers for host-owned catalog instructions. */
|
||||
export { searchSignature, toolExpression } from "./tool-runtime.js"
|
||||
|
||||
@@ -34,9 +41,11 @@ export type ResolvedExecutionLimits = {
|
||||
}
|
||||
|
||||
/** Configuration shared by `CodeMode.make` and `CodeMode.execute`. */
|
||||
export type Options<Provided extends Record<string, unknown> = {}> = ToolRuntime.ToolCallHooks<Services<Provided>> & {
|
||||
export type Options<Provided extends Record<string, unknown> = {}> = {
|
||||
/** Explicit tools exposed to the program as `tools`. */
|
||||
tools?: Provided & Tools<Services<Provided>>
|
||||
/** Hooks around every tool and extension call the program makes; see `Hooks`. */
|
||||
hooks?: ToolRuntime.Hooks<Services<Provided>>
|
||||
/** Host functions exposed as globals; see `Extension.make`. */
|
||||
extensions?: ReadonlyArray<Extension>
|
||||
/** Resource limits enforced on each execution. */
|
||||
@@ -147,7 +156,10 @@ export const make = <const Provided extends Record<string, unknown> = {}>(
|
||||
}
|
||||
}
|
||||
return {
|
||||
catalog: prepared.catalog,
|
||||
execute: (code) => executeProgram(code, prepared, limits, options, (ctx) => extensionGlobals(ctx, extensions)),
|
||||
get catalog() {
|
||||
return prepared.catalog
|
||||
},
|
||||
execute: (code) =>
|
||||
executeProgram(code, prepared, limits, options.hooks ?? {}, (ctx) => extensionGlobals(ctx, extensions)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ export const executeProgram = <R>(
|
||||
code: string,
|
||||
prepared: ToolRuntime.Prepared<R>,
|
||||
limits: ResolvedExecutionLimits,
|
||||
hooks: ToolRuntime.ToolCallHooks<R>,
|
||||
hooks: ToolRuntime.Hooks<R>,
|
||||
globals?: (ctx: Interpreter<R>) => ReadonlyArray<readonly [string, unknown]>,
|
||||
): Effect.Effect<Result, never, R> => {
|
||||
if (code.trim().length === 0) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Effect } from "effect"
|
||||
import type { Extension } from "../extension.js"
|
||||
import { coerceToString } from "../stdlib/value.js"
|
||||
import { type ExtensionInvocation, hooked } from "../tool-runtime.js"
|
||||
import type { Interpreter } from "./interpreter.js"
|
||||
import { createErrorValue, isErrorType } from "./intrinsics.js"
|
||||
import { MAX_VALUE_DEPTH } from "./limits.js"
|
||||
@@ -11,7 +12,6 @@ import {
|
||||
define,
|
||||
entries,
|
||||
get,
|
||||
type Native,
|
||||
Arr,
|
||||
Bytes,
|
||||
DateObj,
|
||||
@@ -128,26 +128,35 @@ export const extensionGlobals = <R>(
|
||||
throw typeError(`${label} produced ${describeHost(value)}, which the program cannot hold.`)
|
||||
}
|
||||
|
||||
// A host function as a program function: arguments cross in, and whatever it returns, resolves, throws, or
|
||||
// rejects with crosses out, so the program catches what the author threw.
|
||||
const wrap = (value: Function, label: string): Native<R> =>
|
||||
// A host function as a program function. Arguments cross in; a global's call runs inside the host's extension
|
||||
// hooks, which see the host's own error on failure; then whatever came back, or was thrown, crosses out so the
|
||||
// program catches a copy. Functions inside results are part of a value's API and skip the hooks.
|
||||
const wrap = (value: Function, label: string, describe?: (args: ReadonlyArray<unknown>) => ExtensionInvocation) =>
|
||||
fn<R>(builtins, value.name, value.length, (_, values) => {
|
||||
const converted = values.map((item, index) => toHost(item, `Argument ${index + 1} to ${label}`))
|
||||
const thrown = (reason: unknown) => new Throw(fromHost(reason, label))
|
||||
const args = values.map((item, index) => toHost(item, `Argument ${index + 1} to ${label}`))
|
||||
const hooks = ctx.tools.hooks
|
||||
const settle = (run: Effect.Effect<unknown, unknown, R>) =>
|
||||
(describe === undefined
|
||||
? run
|
||||
: hooked(describe(args), hooks["extension.before"], hooks["extension.after"], run)
|
||||
).pipe(
|
||||
Effect.mapError((reason) => new Throw(fromHost(reason, label))),
|
||||
Effect.map((settled) => fromHost(settled, label)),
|
||||
)
|
||||
let result: unknown
|
||||
try {
|
||||
result = value.apply(undefined, converted)
|
||||
result = value.apply(undefined, args)
|
||||
} catch (reason) {
|
||||
return Effect.fail(thrown(reason))
|
||||
return settle(Effect.fail(reason))
|
||||
}
|
||||
if (!(result instanceof Promise)) return fromHost(result, label)
|
||||
return ctx.pending.create(
|
||||
Effect.map(Effect.tryPromise({ try: () => result, catch: thrown }), (settled) => fromHost(settled, label)),
|
||||
)
|
||||
if (!(result instanceof Promise)) return settle(Effect.succeed(result))
|
||||
return ctx.pending.create(settle(Effect.tryPromise({ try: () => result, catch: (reason) => reason })))
|
||||
})
|
||||
|
||||
return extensions.flatMap((extension) =>
|
||||
Object.entries(extension.globals).map(([name, value]) => [name, wrap(value, name)] as const),
|
||||
Object.entries(extension.globals).map(
|
||||
([name, value]) => [name, wrap(value, name, (args) => ({ extension: extension.name, name, args }))] as const,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -36,26 +36,48 @@ export type ToolCall = {
|
||||
readonly name: string
|
||||
}
|
||||
|
||||
export type ToolCallStarted = {
|
||||
readonly index: number
|
||||
/** A tool call the program is making, with its decoded input. */
|
||||
export type ToolInvocation = { readonly name: string; readonly input: unknown }
|
||||
|
||||
/** A call the program is making to an extension global, with its arguments. */
|
||||
export type ExtensionInvocation = {
|
||||
readonly extension: string
|
||||
readonly name: string
|
||||
readonly input: unknown
|
||||
readonly args: ReadonlyArray<unknown>
|
||||
}
|
||||
|
||||
export type ToolCallEnded = {
|
||||
readonly index: number
|
||||
readonly name: string
|
||||
readonly input: unknown
|
||||
readonly durationMs: number
|
||||
readonly outcome: "success" | "failure" | "interrupted"
|
||||
readonly message?: string
|
||||
/** How a call ended; `after` hooks observe it and cannot change it. */
|
||||
export type CallResult =
|
||||
| { readonly status: "success"; readonly value: unknown }
|
||||
| { readonly status: "failure"; readonly error: unknown }
|
||||
| { readonly status: "interrupted" }
|
||||
|
||||
/** Hooks around every call the program makes into the host. A failing `before` denies the call. */
|
||||
export type Hooks<R = never> = {
|
||||
readonly "tool.before"?: ((call: ToolInvocation) => Effect.Effect<void, unknown, R>) | undefined
|
||||
readonly "tool.after"?: ((call: ToolInvocation, result: CallResult) => Effect.Effect<void, never, R>) | undefined
|
||||
readonly "extension.before"?: ((call: ExtensionInvocation) => Effect.Effect<void, unknown, R>) | undefined
|
||||
readonly "extension.after"?:
|
||||
| ((call: ExtensionInvocation, result: CallResult) => Effect.Effect<void, never, R>)
|
||||
| undefined
|
||||
}
|
||||
|
||||
export type ToolCallHooks<R = never> = {
|
||||
/** Observes decoded tool input immediately before tool execution. */
|
||||
readonly onToolCallStart?: ((call: ToolCallStarted) => Effect.Effect<void, never, R>) | undefined
|
||||
/** Observes each admitted tool call as it succeeds, fails, or is interrupted. */
|
||||
readonly onToolCallEnd?: ((call: ToolCallEnded) => Effect.Effect<void, never, R>) | undefined
|
||||
/** Runs `before`, then `run`, then `after` with how it ended, including when interrupted. */
|
||||
export const hooked = <Call, A, R>(
|
||||
call: Call,
|
||||
before: ((call: Call) => Effect.Effect<void, unknown, R>) | undefined,
|
||||
after: ((call: Call, result: CallResult) => Effect.Effect<void, never, R>) | undefined,
|
||||
run: Effect.Effect<A, unknown, R>,
|
||||
): Effect.Effect<A, unknown, R> => {
|
||||
const observed =
|
||||
after === undefined
|
||||
? run
|
||||
: Effect.onExit(run, (exit) => {
|
||||
if (Exit.isSuccess(exit)) return after(call, { status: "success", value: exit.value })
|
||||
if (Cause.hasInterruptsOnly(exit.cause)) return after(call, { status: "interrupted" })
|
||||
return after(call, { status: "failure", error: Cause.squash(exit.cause) })
|
||||
})
|
||||
return before === undefined ? observed : Effect.andThen(before(call), observed)
|
||||
}
|
||||
|
||||
export type ToolDescription = {
|
||||
@@ -144,15 +166,24 @@ const flattenTools = <R>(
|
||||
]
|
||||
}
|
||||
|
||||
const describeTool = <R>(visible: VisibleTool<R>): ToolDescription => ({
|
||||
path: visible.path,
|
||||
description: visible.tool.description,
|
||||
signature: isEmptyInput(visible.tool)
|
||||
? `${toolExpression(visible.path)}(): Promise<${outputTypeScript(visible.tool, true)}>`
|
||||
: `${toolExpression(visible.path)}(input: ${inputTypeScript(visible.tool, true)}): Promise<${outputTypeScript(visible.tool, true)}>`,
|
||||
})
|
||||
const describeTool = <R>(visible: VisibleTool<R>): ToolDescription => {
|
||||
let signature: string | undefined
|
||||
return {
|
||||
path: visible.path,
|
||||
description: visible.tool.description,
|
||||
get signature() {
|
||||
// Search ranks paths and descriptions first; only returned matches need their schemas rendered.
|
||||
// Joining the final fragments avoids retaining the rendering's intermediate string ropes in JSC.
|
||||
return (signature ??= [
|
||||
toolExpression(visible.path),
|
||||
isEmptyInput(visible.tool) ? "()" : `(input: ${inputTypeScript(visible.tool, true)})`,
|
||||
`: Promise<${outputTypeScript(visible.tool, true)}>`,
|
||||
].join(""))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** Tools indexed once per runtime: the lookup trie plus the model-facing catalog and search index. */
|
||||
/** Tools indexed once per runtime, with discovery materialized on demand. */
|
||||
export type Prepared<R = never> = {
|
||||
readonly root: ToolNode<R>
|
||||
readonly catalog: ReadonlyArray<ToolDescription>
|
||||
@@ -264,12 +295,20 @@ const toSearchEntry = <R>(visible: VisibleTool<R>): SearchEntry => ({
|
||||
|
||||
export const prepare = <R>(tools: Tools<R>): Prepared<R> => {
|
||||
const root = toolTrie(tools)
|
||||
// Discovery bytes are durable instructions, so order only after canonical-path collisions settle.
|
||||
const visible = flattenTools(root).sort((left, right) => compareText(left.path, right.path))
|
||||
let searchIndex: ReadonlyArray<SearchEntry> | undefined
|
||||
let catalog: ReadonlyArray<ToolDescription> | undefined
|
||||
return {
|
||||
root,
|
||||
catalog: visible.map(describeTool),
|
||||
searchIndex: visible.map(toSearchEntry),
|
||||
get catalog() {
|
||||
return (catalog ??= this.searchIndex.map((entry) => entry.description))
|
||||
},
|
||||
get searchIndex() {
|
||||
// Executing known tools only needs the trie. Render discovery when it is actually read,
|
||||
// ordering after canonical-path collisions settle so instruction bytes stay deterministic.
|
||||
return (searchIndex ??= flattenTools(root)
|
||||
.sort((left, right) => compareText(left.path, right.path))
|
||||
.map(toSearchEntry))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -316,6 +355,7 @@ export class ToolRuntimeError extends Error {
|
||||
/** The tool bridge of one execution. Arguments arrive and results leave as JSON; program values never enter. */
|
||||
export type ToolRuntime<R = never> = {
|
||||
readonly calls: Array<ToolCall>
|
||||
readonly hooks: Hooks<R>
|
||||
readonly execute: (
|
||||
path: ReadonlyArray<string>,
|
||||
args: Array<Json | undefined>,
|
||||
@@ -328,27 +368,10 @@ export type ToolRuntime<R = never> = {
|
||||
export const make = <R>(
|
||||
prepared: Prepared<R>,
|
||||
maxToolCalls: number | undefined,
|
||||
hooks?: ToolCallHooks<R>,
|
||||
hooks: Hooks<R> = {},
|
||||
): ToolRuntime<R> => {
|
||||
const calls: Array<ToolCall> = []
|
||||
const root = prepared.root
|
||||
const searchTool = makeSearchTool(prepared.searchIndex)
|
||||
|
||||
const observeEnd = <A, E>(effect: Effect.Effect<A, E, R>, call: ToolCallStarted): Effect.Effect<A, E, R> => {
|
||||
const onEnd = hooks?.onToolCallEnd
|
||||
if (onEnd === undefined) return effect
|
||||
const startedAt = Date.now()
|
||||
return effect.pipe(
|
||||
Effect.onExit((exit) => {
|
||||
const durationMs = Date.now() - startedAt
|
||||
if (Exit.isSuccess(exit)) return onEnd({ ...call, durationMs, outcome: "success" })
|
||||
if (Cause.hasInterruptsOnly(exit.cause)) return onEnd({ ...call, durationMs, outcome: "interrupted" })
|
||||
const error = Cause.squash(exit.cause)
|
||||
const message = error instanceof Error ? error.message : Cause.pretty(exit.cause)
|
||||
return onEnd({ ...call, durationMs, outcome: "failure", message })
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const recordCall = (call: ToolCall): void => {
|
||||
if (maxToolCalls !== undefined && calls.length >= maxToolCalls) {
|
||||
@@ -371,14 +394,12 @@ export const make = <R>(
|
||||
name === "search" ? [] : ["The signature may have changed. Use search to get the current signature."],
|
||||
),
|
||||
})
|
||||
const index = yield* Effect.sync(() => {
|
||||
recordCall({ name })
|
||||
return calls.length - 1
|
||||
})
|
||||
const call = { index, name, input }
|
||||
return yield* observeEnd(
|
||||
yield* Effect.sync(() => recordCall({ name }))
|
||||
return yield* hooked(
|
||||
{ name, input },
|
||||
hooks["tool.before"],
|
||||
hooks["tool.after"],
|
||||
Effect.gen(function* () {
|
||||
if (hooks?.onToolCallStart !== undefined) yield* hooks.onToolCallStart(call)
|
||||
const raw = yield* Effect.suspend(() => tool.execute(input)).pipe(
|
||||
Effect.catchCause((cause) => {
|
||||
if (Cause.hasInterruptsOnly(cause)) return Effect.interrupt
|
||||
@@ -400,20 +421,20 @@ export const make = <R>(
|
||||
catch: (cause) => new ToolRuntimeError("InvalidToolOutput", `Invalid output from tool '${name}': ${cause}`),
|
||||
})
|
||||
}),
|
||||
call,
|
||||
)
|
||||
})
|
||||
|
||||
return {
|
||||
calls,
|
||||
hooks,
|
||||
keys: (path) => namespaceKeys(root, path),
|
||||
search: (args) => Effect.suspend(() => executeTool("search", searchTool, args)),
|
||||
search: (args) => Effect.suspend(() => executeTool("search", makeSearchTool(prepared.searchIndex), args)),
|
||||
execute: (path, args) =>
|
||||
Effect.suspend(() => {
|
||||
const segments = canonicalSegments(path)
|
||||
// Models often write `tools.search(...)` for the bare `search(...)`; honor it unless a tool owns that path.
|
||||
if (segments.length === 1 && segments[0] === "search" && lookup(root, segments) === undefined)
|
||||
return executeTool("search", searchTool, args)
|
||||
return executeTool("search", makeSearchTool(prepared.searchIndex), args)
|
||||
return executeTool(segments.join("."), resolve(root, path), args)
|
||||
}),
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Cause, Effect, Schema } from "effect"
|
||||
import { CodeMode, Tool, toolError } from "../src/index.js"
|
||||
import { CodeMode, Extension, Tool, toolError } from "../src/index.js"
|
||||
|
||||
const run = (tool: Tool.Tool<never>) =>
|
||||
Effect.runPromise(CodeMode.make({ tools: { host: { call: tool } } }).execute("return await tools.host.call({})"))
|
||||
@@ -160,77 +160,154 @@ describe("CodeMode host failure boundary", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("CodeMode tool-call observation", () => {
|
||||
test("reports the tools actually invoked with decoded input", async () => {
|
||||
const calls: Array<unknown> = []
|
||||
describe("CodeMode call hooks", () => {
|
||||
const ended = (result: CodeMode.CallResult) => {
|
||||
if (result.status !== "failure") return result.status
|
||||
return `failure:${result.error instanceof Error ? result.error.message : String(result.error)}`
|
||||
}
|
||||
// Every hook logs its event, so the order across tools and extensions is visible.
|
||||
const observe = (events: Array<string>): CodeMode.Hooks => ({
|
||||
"tool.before": (call) => Effect.sync(() => void events.push(`tool.before ${JSON.stringify(call)}`)),
|
||||
"tool.after": (call, result) => Effect.sync(() => void events.push(`tool.after ${call.name} ${ended(result)}`)),
|
||||
"extension.before": (call) => Effect.sync(() => void events.push(`extension.before ${JSON.stringify(call)}`)),
|
||||
"extension.after": (call, result) =>
|
||||
Effect.sync(() => void events.push(`extension.after ${call.name} ${ended(result)}`)),
|
||||
})
|
||||
|
||||
test("surround the tools actually invoked, with decoded input", async () => {
|
||||
const events: Array<string> = []
|
||||
const lookup = Tool.make({
|
||||
description: "Look up a value",
|
||||
input: Schema.Struct({ query: Schema.String }),
|
||||
output: Schema.String,
|
||||
execute: ({ query }) => Effect.succeed(query),
|
||||
execute: ({ query }) => (query === "boom" ? Effect.fail(toolError("Lookup refused")) : Effect.succeed(query)),
|
||||
})
|
||||
const runtime = CodeMode.make({ tools: { context: { lookup } }, hooks: observe(events) })
|
||||
|
||||
const result = await Effect.runPromise(
|
||||
CodeMode.make({
|
||||
tools: { context: { lookup } },
|
||||
onToolCallStart: (call) => Effect.sync(() => calls.push(call)),
|
||||
}).execute(`
|
||||
const success = await Effect.runPromise(
|
||||
runtime.execute(`
|
||||
if (false) await tools.context.lookup({ query: "not called" })
|
||||
return await tools.context.lookup({ query: "deployment failure" })
|
||||
`),
|
||||
)
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
expect(calls).toStrictEqual([{ index: 0, name: "context.lookup", input: { query: "deployment failure" } }])
|
||||
})
|
||||
|
||||
test("observes settled calls with outcome and duration", async () => {
|
||||
const events: Array<{ phase: string; index: number; name: string; outcome?: string; message?: string }> = []
|
||||
const lookup = Tool.make({
|
||||
description: "Look up a value",
|
||||
input: Schema.Struct({ query: Schema.String }),
|
||||
output: Schema.String,
|
||||
execute: ({ query }) =>
|
||||
query === "boom"
|
||||
? Effect.fail(toolError("Lookup refused"))
|
||||
: query === "defect"
|
||||
? Effect.die("broken")
|
||||
: Effect.succeed(query),
|
||||
})
|
||||
|
||||
const runtime = CodeMode.make({
|
||||
tools: { context: { lookup } },
|
||||
onToolCallStart: (call) =>
|
||||
Effect.sync(() => {
|
||||
events.push({ phase: "start", index: call.index, name: call.name })
|
||||
}),
|
||||
onToolCallEnd: (call) =>
|
||||
Effect.sync(() => {
|
||||
expect(call.durationMs).toBeGreaterThanOrEqual(0)
|
||||
events.push({
|
||||
phase: "end",
|
||||
index: call.index,
|
||||
name: call.name,
|
||||
outcome: call.outcome,
|
||||
...(call.message === undefined ? {} : { message: call.message }),
|
||||
})
|
||||
}),
|
||||
})
|
||||
|
||||
const success = await Effect.runPromise(runtime.execute(`return await tools.context.lookup({ query: "ok" })`))
|
||||
expect(success.ok).toBe(true)
|
||||
expect(success).toMatchObject({ ok: true, value: "deployment failure" })
|
||||
const failure = await Effect.runPromise(runtime.execute(`return await tools.context.lookup({ query: "boom" })`))
|
||||
expect(failure.ok).toBe(false)
|
||||
const defect = await Effect.runPromise(runtime.execute(`return await tools.context.lookup({ query: "defect" })`))
|
||||
expect(defect.ok).toBe(false)
|
||||
expect(events).toEqual([
|
||||
'tool.before {"name":"context.lookup","input":{"query":"deployment failure"}}',
|
||||
"tool.after context.lookup success",
|
||||
'tool.before {"name":"context.lookup","input":{"query":"boom"}}',
|
||||
"tool.after context.lookup failure:Lookup refused",
|
||||
])
|
||||
})
|
||||
|
||||
expect(events).toStrictEqual([
|
||||
{ phase: "start", index: 0, name: "context.lookup" },
|
||||
{ phase: "end", index: 0, name: "context.lookup", outcome: "success" },
|
||||
{ phase: "start", index: 0, name: "context.lookup" },
|
||||
{ phase: "end", index: 0, name: "context.lookup", outcome: "failure", message: "Lookup refused" },
|
||||
{ phase: "start", index: 0, name: "context.lookup" },
|
||||
{ phase: "end", index: 0, name: "context.lookup", outcome: "failure", message: "broken" },
|
||||
test("surround each call to an extension global, with its arguments, but not functions inside results", async () => {
|
||||
const events: Array<string> = []
|
||||
const runtime = CodeMode.make({
|
||||
extensions: [
|
||||
Extension.make({
|
||||
name: "web",
|
||||
globals: {
|
||||
fetch: async (url: string, init?: { method?: string }) => ({
|
||||
url,
|
||||
method: init?.method ?? "GET",
|
||||
json: () => 1,
|
||||
}),
|
||||
fail: () => {
|
||||
throw new RangeError("nope")
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
hooks: observe(events),
|
||||
})
|
||||
const result = await Effect.runPromise(
|
||||
runtime.execute(`
|
||||
const res = await fetch("https://a.test/", { method: "POST" })
|
||||
res.json()
|
||||
try { fail() } catch {}
|
||||
return res.url
|
||||
`),
|
||||
)
|
||||
expect(result).toMatchObject({ ok: true, value: "https://a.test/", toolCalls: [] })
|
||||
expect(events).toEqual([
|
||||
'extension.before {"extension":"web","name":"fetch","args":["https://a.test/",{"method":"POST"}]}',
|
||||
"extension.after fetch success",
|
||||
'extension.before {"extension":"web","name":"fail","args":[]}',
|
||||
"extension.after fail failure:nope",
|
||||
])
|
||||
})
|
||||
|
||||
test("a failing before hook denies the call; the program catches the host's error", async () => {
|
||||
const runtime = CodeMode.make({
|
||||
tools: {
|
||||
lookup: Tool.make({
|
||||
description: "Look up",
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.String,
|
||||
execute: () => Effect.succeed("ok"),
|
||||
}),
|
||||
},
|
||||
extensions: [Extension.make({ name: "web", globals: { fetch: async (url: string) => url } })],
|
||||
hooks: { "extension.before": (call) => Effect.fail(new Error(`${call.name} is not allowed`)) },
|
||||
})
|
||||
expect(
|
||||
await Effect.runPromise(
|
||||
runtime.execute(`
|
||||
const denied = await fetch("https://a.test/").catch((e) => [e instanceof Error, e.message])
|
||||
return [denied, await tools.lookup({})]
|
||||
`),
|
||||
),
|
||||
).toMatchObject({ ok: true, value: [[true, "fetch is not allowed"], "ok"] })
|
||||
expect(await Effect.runPromise(runtime.execute(`await fetch("https://a.test/")`))).toMatchObject({
|
||||
ok: false,
|
||||
error: { kind: "ExecutionFailure", message: "Error: fetch is not allowed" },
|
||||
})
|
||||
const refused = CodeMode.make({
|
||||
tools: {
|
||||
lookup: Tool.make({
|
||||
description: "Look up",
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.String,
|
||||
execute: () => Effect.succeed("ok"),
|
||||
}),
|
||||
},
|
||||
hooks: { "tool.before": () => Effect.fail(toolError("lookup is not allowed")) },
|
||||
})
|
||||
expect(
|
||||
await Effect.runPromise(
|
||||
refused.execute(`try { await tools.lookup({}) } catch (e) { return [e.name, e.message] }`),
|
||||
),
|
||||
).toMatchObject({ ok: true, value: ["Error", "lookup is not allowed"], toolCalls: [{ name: "lookup" }] })
|
||||
expect(await Effect.runPromise(refused.execute(`await tools.lookup({})`))).toMatchObject({
|
||||
ok: false,
|
||||
error: { kind: "ToolFailure", message: "lookup is not allowed" },
|
||||
})
|
||||
})
|
||||
|
||||
test("see a tool defect as the failure the program gets, and an interrupted extension call", async () => {
|
||||
const events: Array<string> = []
|
||||
const broken = Tool.make({
|
||||
description: "Broken",
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.String,
|
||||
execute: () => Effect.die("broken"),
|
||||
})
|
||||
const runtime = CodeMode.make({
|
||||
tools: { broken },
|
||||
extensions: [Extension.make({ name: "slow", globals: { forever: () => new Promise(() => {}) } })],
|
||||
hooks: observe(events),
|
||||
})
|
||||
expect(await Effect.runPromise(runtime.execute(`await tools.broken({})`))).toMatchObject({ ok: false })
|
||||
expect(await Effect.runPromise(runtime.execute(`forever(); return "done"`))).toMatchObject({
|
||||
ok: true,
|
||||
value: "done",
|
||||
})
|
||||
expect(events).toEqual([
|
||||
'tool.before {"name":"broken","input":{}}',
|
||||
"tool.after broken failure:broken",
|
||||
'extension.before {"extension":"slow","name":"forever","args":[]}',
|
||||
"extension.after forever interrupted",
|
||||
])
|
||||
})
|
||||
|
||||
@@ -243,15 +320,11 @@ describe("CodeMode tool-call observation", () => {
|
||||
execute: () => Effect.interrupt,
|
||||
})
|
||||
const exit = await Effect.runPromiseExit(
|
||||
CodeMode.make({
|
||||
tools: { host: { call } },
|
||||
onToolCallStart: () => Effect.sync(() => events.push("start")),
|
||||
onToolCallEnd: (call) => Effect.sync(() => events.push(`end:${call.outcome}`)),
|
||||
}).execute("return await tools.host.call({})"),
|
||||
CodeMode.make({ tools: { host: { call } }, hooks: observe(events) }).execute("return await tools.host.call({})"),
|
||||
)
|
||||
|
||||
expect(exit._tag).toBe("Failure")
|
||||
expect(events).toEqual(["start", "end:interrupted"])
|
||||
expect(events).toEqual(['tool.before {"name":"host.call","input":{}}', "tool.after host.call interrupted"])
|
||||
})
|
||||
|
||||
test("observes running calls interrupted during completion", async () => {
|
||||
@@ -263,39 +336,17 @@ describe("CodeMode tool-call observation", () => {
|
||||
execute: () => Effect.never,
|
||||
})
|
||||
const result = await Effect.runPromise(
|
||||
CodeMode.make({
|
||||
tools: { host: { call } },
|
||||
onToolCallStart: () => Effect.sync(() => events.push("start")),
|
||||
onToolCallEnd: (call) => Effect.sync(() => events.push(`end:${call.outcome}`)),
|
||||
}).execute('tools.host.call({}); return "done"'),
|
||||
CodeMode.make({ tools: { host: { call } }, hooks: observe(events) }).execute(
|
||||
'tools.host.call({}); return "done"',
|
||||
),
|
||||
)
|
||||
|
||||
expect(result).toMatchObject({ ok: true, value: "done" })
|
||||
expect(events).toEqual(["start", "end:interrupted"])
|
||||
})
|
||||
|
||||
test("ends calls interrupted during start observation", async () => {
|
||||
const events: Array<string> = []
|
||||
const call = Tool.make({
|
||||
description: "Unused",
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.String,
|
||||
execute: () => Effect.succeed("unused"),
|
||||
})
|
||||
const exit = await Effect.runPromiseExit(
|
||||
CodeMode.make({
|
||||
tools: { host: { call } },
|
||||
onToolCallStart: () => Effect.interrupt,
|
||||
onToolCallEnd: (call) => Effect.sync(() => events.push(call.outcome)),
|
||||
}).execute("return await tools.host.call({})"),
|
||||
)
|
||||
|
||||
expect(exit._tag).toBe("Failure")
|
||||
expect(events).toEqual(["interrupted"])
|
||||
expect(events).toEqual(['tool.before {"name":"host.call","input":{}}', "tool.after host.call interrupted"])
|
||||
})
|
||||
|
||||
test("observes calls interrupted by the execution timeout", async () => {
|
||||
const outcomes: Array<string> = []
|
||||
const events: Array<string> = []
|
||||
const call = Tool.make({
|
||||
description: "Pending",
|
||||
input: Schema.Struct({}),
|
||||
@@ -303,15 +354,13 @@ describe("CodeMode tool-call observation", () => {
|
||||
execute: () => Effect.never,
|
||||
})
|
||||
const result = await Effect.runPromise(
|
||||
CodeMode.make({
|
||||
tools: { host: { call } },
|
||||
limits: { timeoutMs: 10 },
|
||||
onToolCallEnd: (call) => Effect.sync(() => outcomes.push(call.outcome)),
|
||||
}).execute("return await tools.host.call({})"),
|
||||
CodeMode.make({ tools: { host: { call } }, limits: { timeoutMs: 10 }, hooks: observe(events) }).execute(
|
||||
"return await tools.host.call({})",
|
||||
),
|
||||
)
|
||||
|
||||
expect(result).toMatchObject({ ok: false, error: { kind: "TimeoutExceeded" } })
|
||||
expect(outcomes).toEqual(["interrupted"])
|
||||
expect(events).toEqual(['tool.before {"name":"host.call","input":{}}', "tool.after host.call interrupted"])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -840,20 +889,17 @@ describe("CodeMode public contract", () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("search is a counted tool call: it burns maxToolCalls and fires the hooks", async () => {
|
||||
const started: Array<string> = []
|
||||
const ended: Array<string> = []
|
||||
test("search is a counted tool call: it burns maxToolCalls and is observed", async () => {
|
||||
const observed: Array<CodeMode.ToolInvocation> = []
|
||||
const limited = CodeMode.make({
|
||||
tools,
|
||||
limits: { maxToolCalls: 1 },
|
||||
onToolCallStart: (call) => Effect.sync(() => void started.push(call.name)),
|
||||
onToolCallEnd: (call) => Effect.sync(() => void ended.push(`${call.name}:${call.outcome}`)),
|
||||
hooks: { "tool.before": (call) => Effect.sync(() => void observed.push(call)) },
|
||||
})
|
||||
const result = await Effect.runPromise(limited.execute(`search({}); return search({})`))
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) expect(result.error.kind).toBe("ToolCallLimitExceeded")
|
||||
expect(started).toEqual(["search"])
|
||||
expect(ended).toEqual(["search:success"])
|
||||
expect(observed).toEqual([{ name: "search", input: {} }])
|
||||
})
|
||||
|
||||
test("search is an opaque, shadowable global like other built-ins", async () => {
|
||||
@@ -1089,7 +1135,7 @@ describe("CodeMode public contract", () => {
|
||||
})
|
||||
const runtime = CodeMode.make({
|
||||
tools: { math: { double: transformed } },
|
||||
onToolCallStart: (call) => Effect.sync(() => observed.push(call.input)),
|
||||
hooks: { "tool.before": (call) => Effect.sync(() => void observed.push(call.input)) },
|
||||
})
|
||||
|
||||
const success = await Effect.runPromise(runtime.execute(`return await tools.math.double({ value: "21" })`))
|
||||
|
||||
@@ -192,6 +192,16 @@ describe("values are converted at the boundary, never shared", () => {
|
||||
expect((await failure(`big()`, target)).message).toContain("big produced a bigint")
|
||||
})
|
||||
|
||||
test("a value that cannot come out carries a location like any built-in error, sync or async", async () => {
|
||||
const target = CodeMode.make({
|
||||
extensions: [
|
||||
Extension.make({ name: "odd", globals: { sym: () => Symbol("s"), later: async () => Symbol("s") } }),
|
||||
],
|
||||
})
|
||||
expect((await failure(`sym()`, target)).location).toEqual((await failure(`JSON.parse("{")`, target)).location)
|
||||
expect((await failure(`await later()`, target)).location).toBeDefined()
|
||||
})
|
||||
|
||||
test("a class instance cannot come out", async () => {
|
||||
class Other {}
|
||||
const target = CodeMode.make({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/console-app",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.4",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/console-core",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.4",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/console-function",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.4",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/console-mail",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.4",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/console-support",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.4",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.4",
|
||||
"name": "@opencode/core",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
import { Effect, Ref, Schema, Semaphore } from "effect"
|
||||
import { definition, normalizedName } from "../tool/runtime.js"
|
||||
import { CodeModeCatalog } from "./catalog.js"
|
||||
import { CodeModeWeb } from "./web.js"
|
||||
|
||||
const ExecuteFile = Schema.Struct({
|
||||
data: Schema.String,
|
||||
@@ -59,8 +60,8 @@ export type Inventory = {
|
||||
|
||||
// Invariant model-facing guidance; the changing tool catalog is delivered through Instructions.
|
||||
const description = [
|
||||
"Run JavaScript in a confined Code Mode runtime to orchestrate tool calls and compose their results.",
|
||||
"Imports, direct filesystem access, and timers are unavailable. Do not use `fetch`; all external access goes through `tools`.",
|
||||
"Run JavaScript in a confined Code Mode runtime to script tool calls and HTTP requests and compose their results.",
|
||||
"`fetch` is available for HTTP requests. Imports, direct filesystem access, and timers are unavailable; all other external access goes through `tools`.",
|
||||
"Within `{ code }`, the only callable tools are those explicitly listed in the Code Mode catalog instructions or returned by the `search` function. Inside `{ code }`, ignore tools shown outside the Code Mode catalog. They are not available in the Code Mode runtime.",
|
||||
'Call tools through `tools` using only exact paths and signatures from the catalog. Do not infer or normalize tool names; preserve bracket notation such as `tools.<namespace>["tool-name"](input)`.',
|
||||
"Prefer an explicit `return`; if omitted, the final top-level expression becomes the result.",
|
||||
@@ -82,9 +83,9 @@ export const create = (
|
||||
const files = yield* Ref.make<Array<CollectedFiles>>([])
|
||||
const calls = yield* Ref.make<Array<ExecuteCall>>([])
|
||||
const lock = Semaphore.makeUnsafe(1)
|
||||
const updateCalls = (update: (items: Array<ExecuteCall>) => Array<ExecuteCall>) =>
|
||||
const record = (update: (items: Array<ExecuteCall>) => Array<ExecuteCall>) =>
|
||||
lock.withPermit(
|
||||
Ref.updateAndGet(calls, update).pipe(Effect.flatMap((toolCalls) => context.progress({ toolCalls }))),
|
||||
Ref.updateAndGet(calls, update).pipe(Effect.tap((toolCalls) => context.progress({ toolCalls }))),
|
||||
)
|
||||
const result = yield* runtime(
|
||||
inventory,
|
||||
@@ -103,27 +104,7 @@ export const create = (
|
||||
const text = content.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n")
|
||||
return text === "" ? null : text
|
||||
}),
|
||||
{
|
||||
onToolCallStart: ({ index, name, input }) => {
|
||||
const shown = displayInput(input)
|
||||
return updateCalls((items) => {
|
||||
const next = [...items]
|
||||
next[index] = { tool: name, status: "running", ...(shown ? { input: shown } : {}) }
|
||||
return next
|
||||
})
|
||||
},
|
||||
onToolCallEnd: ({ index, name, input, outcome }) => {
|
||||
const shown = displayInput(input)
|
||||
return updateCalls((items) => {
|
||||
const next = [...items]
|
||||
next[index] = {
|
||||
...(items[index] ?? { tool: name, ...(shown ? { input: shown } : {}) }),
|
||||
status: outcome === "success" ? "completed" : "error",
|
||||
}
|
||||
return next
|
||||
})
|
||||
},
|
||||
},
|
||||
progressHooks(record),
|
||||
).execute(code)
|
||||
const toolCalls = yield* Ref.get(calls)
|
||||
const collected = (yield* Ref.get(files))
|
||||
@@ -158,6 +139,42 @@ export const create = (
|
||||
} satisfies Info
|
||||
}
|
||||
|
||||
// Rows appear in start order; the same call object arrives at both hooks, so a call finds its row again.
|
||||
function progressHooks(record: (update: (items: Array<ExecuteCall>) => Array<ExecuteCall>) => Effect.Effect<unknown>) {
|
||||
const rows = new WeakMap<object, number>()
|
||||
const start = (call: object, entry: ExecuteCall) =>
|
||||
record((items) => {
|
||||
rows.set(call, items.length)
|
||||
return [...items, entry]
|
||||
})
|
||||
const settle = (call: object, result: CodeMode.CallResult) => {
|
||||
const index = rows.get(call)
|
||||
if (index === undefined) return Effect.void
|
||||
return record((items) => {
|
||||
const next = [...items]
|
||||
next[index] = { ...items[index], status: result.status === "success" ? "completed" : "error" }
|
||||
return next
|
||||
})
|
||||
}
|
||||
return {
|
||||
"tool.before": (call) => {
|
||||
const shown = displayInput(call.input)
|
||||
return start(call, { tool: call.name, status: "running", ...(shown ? { input: shown } : {}) })
|
||||
},
|
||||
"tool.after": settle,
|
||||
// Only listed extension functions get a row; anything else stays out of the TUI.
|
||||
"extension.before": (call) => {
|
||||
switch (call.name) {
|
||||
case "fetch":
|
||||
return start(call, { tool: call.name, status: "running", input: CodeModeWeb.display(call.args) })
|
||||
default:
|
||||
return Effect.void
|
||||
}
|
||||
},
|
||||
"extension.after": settle,
|
||||
} satisfies CodeMode.Hooks
|
||||
}
|
||||
|
||||
export const catalog = (inventory: Inventory) => {
|
||||
const pinned = new Set(
|
||||
Array.from(inventory.tools.values())
|
||||
@@ -204,7 +221,7 @@ function renderCatalog(root: CatalogNode): ReadonlyArray<CodeModeCatalog.Tool |
|
||||
function runtime(
|
||||
inventory: Inventory,
|
||||
executeTool: (name: string, tool: Info, input: unknown) => Effect.Effect<unknown, unknown>,
|
||||
hooks?: CodeMode.ToolCallHooks,
|
||||
hooks?: CodeMode.Hooks,
|
||||
) {
|
||||
// A path may carry namespace metadata, a callable tool, child tools, or all three.
|
||||
const root: ToolNode = { children: new Map() }
|
||||
@@ -219,7 +236,7 @@ function runtime(
|
||||
})
|
||||
}
|
||||
const tools = renderTools(root)
|
||||
return CodeMode.make<typeof tools>({ tools, ...hooks })
|
||||
return CodeMode.make<typeof tools>({ tools, extensions: [CodeModeWeb.extension], hooks })
|
||||
}
|
||||
|
||||
function getNode<T>(root: Node<T>, path: string) {
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
export * as CodeModeWeb from "./web.js"
|
||||
|
||||
import { Extension } from "@opencode/codemode"
|
||||
|
||||
const TIMEOUT_MS = 30_000
|
||||
|
||||
type Init = {
|
||||
readonly method?: string
|
||||
readonly headers?: Record<string, string> | Array<[string, string]>
|
||||
readonly body?: string | Uint8Array<ArrayBuffer> | URLSearchParams
|
||||
}
|
||||
|
||||
const fetch = async (input: string | URL, init: Init = {}) => {
|
||||
const response = await globalThis.fetch(input, {
|
||||
method: init.method,
|
||||
headers: init.headers,
|
||||
body: init.body,
|
||||
signal: AbortSignal.timeout(TIMEOUT_MS),
|
||||
})
|
||||
const bytes = await response.bytes()
|
||||
const headers = Object.fromEntries(response.headers)
|
||||
const text = () => new TextDecoder().decode(bytes)
|
||||
return {
|
||||
url: response.url,
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
ok: response.ok,
|
||||
redirected: response.redirected,
|
||||
headers: {
|
||||
get: (name: string) => headers[name.toLowerCase()] ?? null,
|
||||
has: (name: string) => name.toLowerCase() in headers,
|
||||
entries: () => Object.entries(headers),
|
||||
},
|
||||
text: async () => text(),
|
||||
json: async () => JSON.parse(text()) as unknown,
|
||||
bytes: async () => bytes,
|
||||
}
|
||||
}
|
||||
|
||||
export const extension = Extension.make({ name: "web", globals: { fetch } })
|
||||
|
||||
/** What to show for a fetch call: its method and URL. */
|
||||
export const display = (args: ReadonlyArray<unknown>) => {
|
||||
const [input, init] = args as [string | URL, Init | undefined]
|
||||
return { method: init?.method?.toUpperCase() ?? "GET", url: String(input) }
|
||||
}
|
||||
@@ -168,7 +168,12 @@ export const provider = (options: Options): OAuthClientProvider => {
|
||||
if (!resource) return identity
|
||||
if (!checkResourceAllowed({ requestedResource: identity, configuredResource: resource }))
|
||||
throw new Error(`Protected resource ${resource} does not cover ${identity}`)
|
||||
return new URL(resource)
|
||||
const canonical = new URL(resource)
|
||||
// The transport dials the configured URL with extra query parameters and some servers echo
|
||||
// that back as the resource. Query is transport detail, not identity: the token stays bound
|
||||
// to the configured URL so a later refresh names the same resource the login did.
|
||||
if (canonical.origin === identity.origin && canonical.pathname === identity.pathname) return identity
|
||||
return canonical
|
||||
},
|
||||
...(options.clientMetadataUrl ? { clientMetadataUrl: options.clientMetadataUrl } : {}),
|
||||
...(redirect ? { state: () => redirect.state } : {}),
|
||||
|
||||
+76
-57
@@ -68,7 +68,7 @@ export interface Interface extends State.Transformable<Editor> {
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Model") {}
|
||||
|
||||
type Data = {
|
||||
models: Map<Provider.ID, Map<ID, MutableInfo>>
|
||||
models: Map<Provider.ID, ReadonlyMap<ID, Info>>
|
||||
defaultModel?: { providerID: Provider.ID; modelID: ID }
|
||||
}
|
||||
|
||||
@@ -82,59 +82,73 @@ const layer = Layer.effect(
|
||||
const state: State.Interface<Data, Editor> = State.create<Data, Editor>({
|
||||
name: "model",
|
||||
initial: () => ({
|
||||
models: new Map(
|
||||
(input?.available ?? []).map((record) => [
|
||||
record.provider.id,
|
||||
new Map(
|
||||
Array.from(record.models, ([id, model]) => [
|
||||
id,
|
||||
{
|
||||
...structuredClone(model),
|
||||
id,
|
||||
providerID: record.provider.id,
|
||||
} as MutableInfo,
|
||||
]),
|
||||
),
|
||||
]),
|
||||
),
|
||||
models: new Map((input?.available ?? []).map((record) => [record.provider.id, record.models])),
|
||||
}),
|
||||
editor: (data) => ({
|
||||
list: (providerID) =>
|
||||
providerID === undefined
|
||||
? Array.from(data.models.values()).flatMap((models) => Array.from(models.values()))
|
||||
: Array.from(data.models.get(providerID)?.values() ?? []),
|
||||
get: (providerID, modelID) => data.models.get(providerID)?.get(modelID),
|
||||
update: (providerID, modelID, update) => {
|
||||
// Model edits cannot create/enable a provider or bypass its availability decision.
|
||||
const models = data.models.get(providerID)
|
||||
if (!models) return
|
||||
const model = models.get(modelID) ?? (Info.default(providerID, modelID) as MutableInfo)
|
||||
update(model)
|
||||
model.id = modelID
|
||||
model.providerID = providerID
|
||||
const provider = input?.records.get(providerID)?.provider
|
||||
AISDKNative.rewrite(model, {
|
||||
specifier: model.package ?? provider?.package,
|
||||
providerID,
|
||||
canonical: model.canonical ?? provider?.canonical,
|
||||
modelID: model.modelID ?? modelID,
|
||||
})
|
||||
models.set(modelID, model)
|
||||
},
|
||||
remove: (providerID, modelID) => {
|
||||
data.models.get(providerID)?.delete(modelID)
|
||||
},
|
||||
default: {
|
||||
get: () => data.defaultModel,
|
||||
set: (providerID, modelID) => {
|
||||
data.defaultModel = { providerID, modelID }
|
||||
editor: (data) => {
|
||||
// Definitions are shared across Locations; a provider's map and a model are copied before their first edit.
|
||||
const owned = new WeakSet<ReadonlyMap<ID, Info>>()
|
||||
const drafts = new WeakSet<Info>()
|
||||
const writable = (providerID: Provider.ID) => {
|
||||
const current = data.models.get(providerID)
|
||||
if (!current) return undefined
|
||||
if (owned.has(current)) return current as Map<ID, Info>
|
||||
const copy = new Map(current)
|
||||
owned.add(copy)
|
||||
data.models.set(providerID, copy)
|
||||
return copy
|
||||
}
|
||||
const draft = (providerID: Provider.ID, modelID: ID) => {
|
||||
const models = writable(providerID)
|
||||
if (!models) return undefined
|
||||
const current = models.get(modelID)
|
||||
if (!current) return undefined
|
||||
if (drafts.has(current)) return current as MutableInfo
|
||||
const copy = structuredClone(current) as MutableInfo
|
||||
drafts.add(copy)
|
||||
models.set(modelID, copy)
|
||||
return copy
|
||||
}
|
||||
return {
|
||||
list: (providerID) => {
|
||||
const ids = providerID === undefined ? Array.from(data.models.keys()) : [providerID]
|
||||
return ids.flatMap((id) =>
|
||||
Array.from(data.models.get(id)?.keys() ?? []).flatMap((modelID) => draft(id, modelID) ?? []),
|
||||
)
|
||||
},
|
||||
},
|
||||
provider: {
|
||||
list: () => Array.from(input?.records.values() ?? []),
|
||||
get: (providerID) => input?.records.get(providerID),
|
||||
},
|
||||
}),
|
||||
get: draft,
|
||||
update: (providerID, modelID, update) => {
|
||||
// Model edits cannot create/enable a provider or bypass its availability decision.
|
||||
const models = writable(providerID)
|
||||
if (!models) return
|
||||
const model = draft(providerID, modelID) ?? (Info.default(providerID, modelID) as MutableInfo)
|
||||
update(model)
|
||||
model.id = modelID
|
||||
model.providerID = providerID
|
||||
const provider = input?.records.get(providerID)?.provider
|
||||
AISDKNative.rewrite(model, {
|
||||
specifier: model.package ?? provider?.package,
|
||||
providerID,
|
||||
canonical: model.canonical ?? provider?.canonical,
|
||||
modelID: model.modelID ?? modelID,
|
||||
})
|
||||
drafts.add(model)
|
||||
models.set(modelID, model)
|
||||
},
|
||||
remove: (providerID, modelID) => {
|
||||
writable(providerID)?.delete(modelID)
|
||||
},
|
||||
default: {
|
||||
get: () => data.defaultModel,
|
||||
set: (providerID, modelID) => {
|
||||
data.defaultModel = { providerID, modelID }
|
||||
},
|
||||
},
|
||||
provider: {
|
||||
list: () => Array.from(input?.records.values() ?? []),
|
||||
get: (providerID) => input?.records.get(providerID),
|
||||
},
|
||||
}
|
||||
},
|
||||
// read() also refreshes dependencies changed inside a State.batch before notification.
|
||||
notify: () => notify,
|
||||
})
|
||||
@@ -147,6 +161,8 @@ const layer = Layer.effect(
|
||||
byProvider: ReadonlyMap<Provider.ID, ReadonlyMap<ID, Info>>
|
||||
}
|
||||
| undefined
|
||||
// An unedited model keeps its shared definition object across rebuilds, so its merged output is reusable.
|
||||
const merged = new WeakMap<Info, { provider: Provider.Info | undefined; model: Info }>()
|
||||
const read = Effect.fn("Model.snapshot")(function* () {
|
||||
while (true) {
|
||||
const current = yield* providers.snapshot()
|
||||
@@ -165,9 +181,10 @@ const layer = Layer.effect(
|
||||
return [
|
||||
providerID,
|
||||
new Map(
|
||||
Array.from(models, ([id, model]) => [
|
||||
id,
|
||||
{
|
||||
Array.from(models, ([id, model]) => {
|
||||
const reusable = merged.get(model)
|
||||
if (reusable && reusable.provider === provider) return [id, reusable.model]
|
||||
const value = {
|
||||
...model,
|
||||
...(provider?.canonical === undefined ? {} : { canonical: provider.canonical }),
|
||||
package: model.package ?? provider?.package,
|
||||
@@ -176,8 +193,10 @@ const layer = Layer.effect(
|
||||
settings: Provider.mergeOverlay(provider?.settings, model.settings),
|
||||
headers: Provider.mergeHeaders(provider?.headers, model.headers),
|
||||
body: Provider.mergeOverlay(provider?.body, model.body),
|
||||
} satisfies Info,
|
||||
]),
|
||||
} satisfies Info
|
||||
merged.set(model, { provider, model: value })
|
||||
return [id, value]
|
||||
}),
|
||||
),
|
||||
]
|
||||
}),
|
||||
|
||||
@@ -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)))),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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)
|
||||
}),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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])
|
||||
})
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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 })
|
||||
}),
|
||||
)
|
||||
@@ -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()),
|
||||
|
||||
@@ -151,13 +151,13 @@ describeHg("Vcs mercurial", () => {
|
||||
})
|
||||
const vcs = yield* Vcs.Service
|
||||
const bus = yield* Bus.Service
|
||||
expect(yield* vcs.info()).toEqual({ branch: { current: "default", default: "default" } })
|
||||
expect(yield* vcs.info()).toEqual({ provider: "hg", branch: { current: "default", default: "default" } })
|
||||
|
||||
const updated = yield* bus
|
||||
.subscribe(VcsEvent.BranchUpdated)
|
||||
.pipe(Stream.take(1), Stream.runHead, Effect.forkScoped({ startImmediately: true }))
|
||||
yield* Effect.promise(() => hg(directory, "branch", "-q", "feature"))
|
||||
expect(yield* vcs.info()).toEqual({ branch: { current: "default", default: "default" } })
|
||||
expect(yield* vcs.info()).toEqual({ provider: "hg", branch: { current: "default", default: "default" } })
|
||||
|
||||
yield* bus.publish(FileSystem.Event.Changed, {
|
||||
file: path.join(directory, ".hg", "branch"),
|
||||
@@ -167,7 +167,7 @@ describeHg("Vcs mercurial", () => {
|
||||
_tag: "Some",
|
||||
value: { location: { directory }, data: { branch: "feature" } },
|
||||
})
|
||||
expect(yield* vcs.info()).toEqual({ branch: { current: "feature", default: "default" } })
|
||||
expect(yield* vcs.info()).toEqual({ provider: "hg", branch: { current: "feature", default: "default" } })
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -126,7 +126,7 @@ describe("Vcs", () => {
|
||||
editor.default.set("custom")
|
||||
})
|
||||
|
||||
expect(yield* vcs.info()).toEqual({ branch: { current: "feature", default: "main" } })
|
||||
expect(yield* vcs.info()).toEqual({ provider: "custom", branch: { current: "feature", default: "main" } })
|
||||
expect(yield* vcs.base()).toBeNull()
|
||||
expect(yield* vcs.branches()).toEqual(["feature", "main"])
|
||||
expect(yield* vcs.status()).toEqual([{ file: "file.txt", additions: 1, deletions: 0, status: "added" }])
|
||||
@@ -146,10 +146,10 @@ describe("Vcs", () => {
|
||||
Effect.gen(function* () {
|
||||
const vcs = yield* Vcs.Service
|
||||
const registration = yield* vcs.transform((editor) => editor.add(provider({ id: "git" })))
|
||||
expect(yield* vcs.info()).toEqual({ branch: { current: "feature", default: "main" } })
|
||||
expect(yield* vcs.info()).toEqual({ provider: "git", branch: { current: "feature", default: "main" } })
|
||||
|
||||
yield* registration.dispose
|
||||
expect(yield* vcs.info()).toEqual({ branch: { current: "main", default: undefined } })
|
||||
expect(yield* vcs.info()).toEqual({ provider: "git", branch: { current: "main", default: undefined } })
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -188,7 +188,7 @@ describe("Vcs", () => {
|
||||
}),
|
||||
)
|
||||
expect(reads).toEqual(["final"])
|
||||
expect(yield* vcs.info()).toEqual({ branch: { current: "final" } })
|
||||
expect(yield* vcs.info()).toEqual({ provider: "custom", branch: { current: "final" } })
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -456,7 +456,7 @@ describe("Vcs", () => {
|
||||
),
|
||||
)
|
||||
expect((yield* vcs.status())[0]?.file).toBe("config.txt")
|
||||
expect(yield* vcs.info()).toEqual({ branch: { current: "initial" } })
|
||||
expect(yield* vcs.info()).toEqual({ provider: "git", branch: { current: "initial" } })
|
||||
yield* Deferred.succeed(accepted, undefined)
|
||||
}),
|
||||
).pipe(Effect.forkScoped({ startImmediately: true }))
|
||||
@@ -466,7 +466,7 @@ describe("Vcs", () => {
|
||||
yield* Deferred.succeed(release, undefined)
|
||||
yield* Fiber.join(configured)
|
||||
expect(Option.getOrUndefined(yield* Fiber.join(updates))?.data.branch).toBe("config")
|
||||
expect(yield* vcs.info()).toEqual({ branch: { current: "config" } })
|
||||
expect(yield* vcs.info()).toEqual({ provider: "git", branch: { current: "config" } })
|
||||
expect(reads).toEqual(["initial", "filesystem", "config"])
|
||||
}).pipe(Effect.ensuring(Deferred.succeed(release, undefined)))
|
||||
}),
|
||||
@@ -502,7 +502,7 @@ describe("Vcs", () => {
|
||||
})
|
||||
yield* bus.publish(Done, {})
|
||||
const events = (yield* Fiber.join(updates)).filter((event) => event.type === VcsEvent.BranchUpdated.type)
|
||||
expect(yield* vcs.info()).toEqual({ branch: { current: "listener" } })
|
||||
expect(yield* vcs.info()).toEqual({ provider: "custom", branch: { current: "listener" } })
|
||||
expect(events.length).toBeGreaterThanOrEqual(2)
|
||||
expect(events.at(-1)?.data.branch).toBe((yield* vcs.info()).branch.current)
|
||||
}).pipe(Effect.ensuring(unsubscribe))
|
||||
@@ -567,7 +567,7 @@ describe("Vcs", () => {
|
||||
})
|
||||
const vcs = yield* Vcs.Service
|
||||
const bus = yield* Bus.Service
|
||||
expect(yield* vcs.info()).toEqual({ branch: { current: "main", default: undefined } })
|
||||
expect(yield* vcs.info()).toEqual({ provider: "git", branch: { current: "main", default: undefined } })
|
||||
|
||||
const updated = yield* bus
|
||||
.subscribe(VcsEvent.BranchUpdated)
|
||||
@@ -575,14 +575,14 @@ describe("Vcs", () => {
|
||||
yield* Effect.promise(() => $`git checkout -q -b feature`.cwd(directory).quiet())
|
||||
|
||||
yield* bus.publish(FileSystem.Event.Changed, { file: path.join(directory, "HEAD"), event: "change" })
|
||||
expect(yield* vcs.info()).toEqual({ branch: { current: "main", default: undefined } })
|
||||
expect(yield* vcs.info()).toEqual({ provider: "git", branch: { current: "main", default: undefined } })
|
||||
|
||||
yield* bus.publish(FileSystem.Event.Changed, { file: path.join(directory, ".git", "HEAD"), event: "change" })
|
||||
expect(yield* Fiber.join(updated)).toMatchObject({
|
||||
_tag: "Some",
|
||||
value: { location: { directory }, data: { branch: "feature" } },
|
||||
})
|
||||
expect(yield* vcs.info()).toEqual({ branch: { current: "feature", default: "main" } })
|
||||
expect(yield* vcs.info()).toEqual({ provider: "git", branch: { current: "feature", default: "main" } })
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode/desktop",
|
||||
"private": true,
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.4",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"homepage": "https://opencode.ai",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/enterprise",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.4",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/function",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.4",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.4",
|
||||
"name": "@opencode/http-recorder",
|
||||
"description": "Record and replay Effect HTTP and WebSocket traffic with deterministic cassettes",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/httpapi-codegen",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.4",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/latex",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.4",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/merman",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.4",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/plugin-browser",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.4",
|
||||
"description": "OpenCode's desktop browser plugin",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/plugin",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.4",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/protocol",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.4",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/schema",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.4",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.4",
|
||||
"name": "@opencode/sdk",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/server",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.4",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/session-ui",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.4",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/simulation",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.4",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/stats-app",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.4",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/stats-core",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.4",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/stats-server",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.4",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/theme",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.4",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/tui",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.4",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -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" : "")
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/ui",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.4",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/util",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.4",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "@opencode/web",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.4",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "opencode",
|
||||
"displayName": "opencode",
|
||||
"description": "opencode for VS Code",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.4",
|
||||
"publisher": "sst-dev",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/update",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.4",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
Reference in New Issue
Block a user