Compare commits

..

3 Commits

Author SHA1 Message Date
David Hill
5718d59e31 tweak(ui): session spacing 2026-04-03 19:54:28 +01:00
Adam
e79f97db3d chore: more storybook control 2026-04-03 10:15:29 -05:00
David Hill
219ed4e2ef tweak(ui): wip session spacing 2026-04-03 09:57:29 -05:00
37 changed files with 61471 additions and 1500 deletions

View File

@@ -15,7 +15,6 @@ concurrency:
permissions:
contents: read
checks: write
jobs:
unit:
@@ -46,40 +45,14 @@ jobs:
git config --global user.email "bot@opencode.ai"
git config --global user.name "opencode"
- name: Cache Turbo
uses: actions/cache@v4
with:
path: node_modules/.cache/turbo
key: turbo-${{ runner.os }}-${{ hashFiles('turbo.json', '**/package.json') }}-${{ github.sha }}
restore-keys: |
turbo-${{ runner.os }}-${{ hashFiles('turbo.json', '**/package.json') }}-
turbo-${{ runner.os }}-
- name: Run unit tests
run: bun turbo test:ci
run: bun turbo test
env:
# Bun 1.3.11 intermittently crashes on Windows during test teardown
# inside the native @parcel/watcher binding. Unit CI does not rely on
# the live watcher backend there, so disable it for that platform.
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}
- name: Publish unit reports
if: always()
uses: mikepenz/action-junit-report@v6
with:
report_paths: packages/*/.artifacts/unit/junit.xml
check_name: "unit results (${{ matrix.settings.name }})"
detailed_summary: true
include_time_in_summary: true
fail_on_failure: false
- name: Upload unit artifacts
if: always()
uses: actions/upload-artifact@v4
with:
name: unit-${{ matrix.settings.name }}-${{ github.run_attempt }}
include-hidden-files: true
if-no-files-found: ignore
retention-days: 7
path: packages/*/.artifacts/unit/junit.xml
e2e:
name: e2e (${{ matrix.settings.name }})
strategy:

View File

@@ -9,7 +9,6 @@
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/script": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"heap-snapshot-toolkit": "1.1.3",
"typescript": "catalog:",
},
"devDependencies": {
@@ -3233,8 +3232,6 @@
"he": ["he@1.2.0", "", { "bin": { "he": "bin/he" } }, "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw=="],
"heap-snapshot-toolkit": ["heap-snapshot-toolkit@1.1.3", "", {}, "sha512-joThu2rEsDu8/l4arupRDI1qP4CZXNG+J6Wr348vnbLGSiBkwRdqZ6aOHl5BzEiC+Dc8OTbMlmWjD0lbXD5K2Q=="],
"hey-listen": ["hey-listen@1.0.8", "", {}, "sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q=="],
"hono": ["hono@4.10.7", "", {}, "sha512-icXIITfw/07Q88nLSkB9aiUrd8rYzSweK681Kjo/TSggaGbOX4RRyxxm71v+3PC8C/j+4rlxGeoTRxQDkaJkUw=="],

View File

@@ -90,7 +90,6 @@
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/script": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"heap-snapshot-toolkit": "1.1.3",
"typescript": "catalog:"
},
"repository": {

View File

@@ -15,7 +15,6 @@
"build": "vite build",
"serve": "vite preview",
"test": "bun run test:unit",
"test:ci": "mkdir -p .artifacts/unit && bun test --preload ./happydom.ts ./src --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml",
"test:unit": "bun test --preload ./happydom.ts ./src",
"test:unit:watch": "bun test --watch --preload ./happydom.ts ./src",
"test:e2e": "playwright test",

View File

@@ -243,6 +243,23 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
},
)
const working = createMemo(() => status()?.type !== "idle")
const tip = () => {
if (working()) {
return (
<div class="flex items-center gap-2">
<span>{language.t("prompt.action.stop")}</span>
<span class="text-icon-base text-12-medium text-[10px]!">{language.t("common.key.esc")}</span>
</div>
)
}
return (
<div class="flex items-center gap-2">
<span>{language.t("prompt.action.send")}</span>
<Icon name="enter" size="small" class="text-icon-base" />
</div>
)
}
const imageAttachments = createMemo(() =>
prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"),
)
@@ -280,31 +297,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
if (store.mode === "shell") return 0
return prompt.context.items().filter((item) => !!item.comment?.trim()).length
})
const blank = createMemo(() => {
const text = prompt
.current()
.map((part) => ("content" in part ? part.content : ""))
.join("")
return text.trim().length === 0 && imageAttachments().length === 0 && commentCount() === 0
})
const stopping = createMemo(() => working() && blank())
const tip = () => {
if (stopping()) {
return (
<div class="flex items-center gap-2">
<span>{language.t("prompt.action.stop")}</span>
<span class="text-icon-base text-12-medium text-[10px]!">{language.t("common.key.esc")}</span>
</div>
)
}
return (
<div class="flex items-center gap-2">
<span>{language.t("prompt.action.send")}</span>
<Icon name="enter" size="small" class="text-icon-base" />
</div>
)
}
const contextItems = createMemo(() => {
const items = prompt.context.items()
@@ -1415,17 +1407,17 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
/>
<div class="flex items-center gap-1 pointer-events-auto">
<Tooltip placement="top" inactive={!working() && blank()} value={tip()}>
<Tooltip placement="top" inactive={!prompt.dirty() && !working()} value={tip()}>
<IconButton
data-action="prompt-submit"
type="submit"
disabled={store.mode !== "normal" || (!working() && blank())}
disabled={store.mode !== "normal" || (!prompt.dirty() && !working() && commentCount() === 0)}
tabIndex={store.mode === "normal" ? undefined : -1}
icon={stopping() ? "stop" : "arrow-up"}
icon={working() ? "stop" : "arrow-up"}
variant="primary"
class="size-8"
style={buttons()}
aria-label={stopping() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
aria-label={working() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
/>
</Tooltip>
</div>

View File

@@ -139,6 +139,11 @@ export const SettingsGeneral: Component = () => {
{ value: "dark", label: language.t("theme.scheme.dark") },
])
const followupOptions = createMemo((): { value: "queue" | "steer"; label: string }[] => [
{ value: "queue", label: language.t("settings.general.row.followup.option.queue") },
{ value: "steer", label: language.t("settings.general.row.followup.option.steer") },
])
const languageOptions = createMemo(() =>
language.locales.map((locale) => ({
value: locale,
@@ -236,6 +241,24 @@ export const SettingsGeneral: Component = () => {
/>
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.followup.title")}
description={language.t("settings.general.row.followup.description")}
>
<Select
data-action="settings-followup"
options={followupOptions()}
current={followupOptions().find((o) => o.value === settings.general.followup())}
value={(o) => o.value}
label={(o) => o.label}
onSelect={(option) => option && settings.general.setFollowup(option.value)}
variant="secondary"
size="small"
triggerVariant="settings"
triggerStyle={{ "min-width": "180px" }}
/>
</SettingsRow>
</SettingsList>
</div>
)

View File

@@ -136,11 +136,6 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
root.style.setProperty("--font-family-sans", sansFontFamily(store.appearance?.sans))
})
createEffect(() => {
if (store.general?.followup !== "queue") return
setStore("general", "followup", "steer")
})
return {
ready,
get current() {
@@ -155,12 +150,9 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
setReleaseNotes(value: boolean) {
setStore("general", "releaseNotes", value)
},
followup: withFallback(
() => (store.general?.followup === "queue" ? "steer" : store.general?.followup),
defaultSettings.general.followup,
),
followup: withFallback(() => store.general?.followup, defaultSettings.general.followup),
setFollowup(value: "queue" | "steer") {
setStore("general", "followup", value === "queue" ? "steer" : value)
setStore("general", "followup", value)
},
showReasoningSummaries: withFallback(
() => store.general?.showReasoningSummaries,

View File

@@ -9,7 +9,6 @@
"prepare": "effect-language-service patch || true",
"typecheck": "tsgo --noEmit",
"test": "bun test --timeout 30000",
"test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml",
"build": "bun run script/build.ts",
"upgrade-opentui": "bun run script/upgrade-opentui.ts",
"dev": "bun run --conditions=browser ./src/index.ts",

View File

@@ -1,14 +0,0 @@
# 2.0
What we would change if we could
## Keybindings vs. Keymappings
Make it `keymappings`, closer to neovim. Can be layered like `<leader>abc`. Commands don't define their binding, but have an id that a key can be mapped to like
```ts
{ key: "ctrl+w", cmd: string | function, description }
```
_Why_
Currently its keybindings that have an `id` like `message_redo` and then a command can use that or define it's own binding. While some keybindings are just used with `.match` in arbitrary key handlers and there is no info what the key is used for, except the binding id maybe. It also is unknown in which context/scope what binding is active, so a plugin like `which-key` is nearly impossible to get right.

View File

@@ -71,7 +71,7 @@ export const AgentCommand = cmd({
async function getAvailableTools(agent: Agent.Info) {
const model = agent.model ?? (await Provider.defaultModel())
return ToolRegistry.tools(model)
return ToolRegistry.tools(model, agent)
}
async function resolveTools(agent: Agent.Info, availableTools: Awaited<ReturnType<typeof getAvailableTools>>) {

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -11,6 +11,7 @@ import { Provider } from "../provider/provider"
import { ModelID, ProviderID } from "../provider/schema"
import { type Tool as AITool, tool, jsonSchema, type ToolExecutionOptions, asSchema } from "ai"
import { SessionCompaction } from "./compaction"
import { Instance } from "../project/instance"
import { Bus } from "../bus"
import { ProviderTransform } from "../provider/transform"
import { SystemPrompt } from "./system"
@@ -23,6 +24,7 @@ import { ToolRegistry } from "../tool/registry"
import { Runner } from "@/effect/runner"
import { MCP } from "../mcp"
import { LSP } from "../lsp"
import { ReadTool } from "../tool/read"
import { FileTime } from "../file/time"
import { Flag } from "../flag/flag"
import { ulid } from "ulid"
@@ -35,6 +37,7 @@ import { ConfigMarkdown } from "../config/markdown"
import { SessionSummary } from "./summary"
import { NamedError } from "@opencode-ai/util/error"
import { SessionProcessor } from "./processor"
import { TaskTool } from "@/tool/task"
import { Tool } from "@/tool/tool"
import { Permission } from "@/permission"
import { SessionStatus } from "./status"
@@ -47,8 +50,6 @@ import { Process } from "@/util/process"
import { Cause, Effect, Exit, Layer, Option, Scope, ServiceMap } from "effect"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { TaskTool } from "@/tool/task"
import { ReadTool } from "@/tool/read"
// @ts-ignore
globalThis.AI_SDK_LOG_WARNINGS = false
@@ -432,10 +433,10 @@ NOTE: At any point in time through this workflow you should feel free to ask the
),
})
for (const item of yield* registry.tools({
modelID: ModelID.make(input.model.api.id),
providerID: input.model.providerID,
})) {
for (const item of yield* registry.tools(
{ modelID: ModelID.make(input.model.api.id), providerID: input.model.providerID },
input.agent,
)) {
const schema = ProviderTransform.schema(input.model, z.toJSONSchema(item.parameters))
tools[item.id] = tool({
id: item.id as any,
@@ -559,7 +560,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
}) {
const { task, model, lastUser, sessionID, session, msgs } = input
const ctx = yield* InstanceState.context
const taskTool = yield* registry.fromID(TaskTool.id)
const taskTool = yield* Effect.promise(() => registry.named.task.init())
const taskModel = task.model ? yield* getModel(task.model.providerID, task.model.modelID, sessionID) : model
const assistantMessage: MessageV2.Assistant = yield* sessions.updateMessage({
id: MessageID.ascending(),
@@ -582,7 +583,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
sessionID: assistantMessage.sessionID,
type: "tool",
callID: ulid(),
tool: TaskTool.id,
tool: registry.named.task.id,
state: {
status: "running",
input: {
@@ -1109,7 +1110,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
text: `Called the Read tool with the following input: ${JSON.stringify(args)}`,
},
]
const read = yield* registry.fromID(ReadTool.id).pipe(
const read = yield* Effect.promise(() => registry.named.read.init()).pipe(
Effect.flatMap((t) =>
provider.getModel(info.model.providerID, info.model.modelID).pipe(
Effect.flatMap((mdl) =>
@@ -1173,7 +1174,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
if (part.mime === "application/x-directory") {
const args = { filePath: filepath }
const result = yield* registry.fromID(ReadTool.id).pipe(
const result = yield* Effect.promise(() => registry.named.read.init()).pipe(
Effect.flatMap((t) =>
Effect.promise(() =>
t.execute(args, {

View File

@@ -82,7 +82,7 @@ export namespace Todo {
}),
)
export const defaultLayer = layer.pipe(Layer.provide(Bus.layer))
const defaultLayer = layer.pipe(Layer.provide(Bus.layer))
const { runPromise } = makeRuntime(Service, defaultLayer)
export async function update(input: { sessionID: SessionID; todos: Info[] }) {

View File

@@ -239,28 +239,22 @@ export namespace Skill {
export function fmt(list: Info[], opts: { verbose: boolean }) {
if (list.length === 0) return "No skills are currently available."
if (opts.verbose) {
return [
"<available_skills>",
...list
.sort((a, b) => a.name.localeCompare(b.name))
.flatMap((skill) => [
" <skill>",
` <name>${skill.name}</name>`,
` <description>${skill.description}</description>`,
` <location>${pathToFileURL(skill.location).href}</location>`,
" </skill>",
]),
...list.flatMap((skill) => [
" <skill>",
` <name>${skill.name}</name>`,
` <description>${skill.description}</description>`,
` <location>${pathToFileURL(skill.location).href}</location>`,
" </skill>",
]),
"</available_skills>",
].join("\n")
}
return [
"## Available Skills",
...list
.toSorted((a, b) => a.name.localeCompare(b.name))
.map((skill) => `- **${skill.name}**: ${skill.description}`),
].join("\n")
return ["## Available Skills", ...list.map((skill) => `- **${skill.name}**: ${skill.description}`)].join("\n")
}
const { runPromise } = makeRuntime(Service, defaultLayer)

View File

@@ -437,146 +437,6 @@ export namespace Snapshot {
const diffFull = Effect.fnUntraced(function* (from: string, to: string) {
return yield* locked(
Effect.gen(function* () {
type Row = {
file: string
status: "added" | "deleted" | "modified"
binary: boolean
additions: number
deletions: number
}
type Ref = {
file: string
side: "before" | "after"
ref: string
}
const show = Effect.fnUntraced(function* (row: Row) {
if (row.binary) return ["", ""]
if (row.status === "added") {
return [
"",
yield* git([...cfg, ...args(["show", `${to}:${row.file}`])]).pipe(
Effect.map((item) => item.text),
),
]
}
if (row.status === "deleted") {
return [
yield* git([...cfg, ...args(["show", `${from}:${row.file}`])]).pipe(
Effect.map((item) => item.text),
),
"",
]
}
return yield* Effect.all(
[
git([...cfg, ...args(["show", `${from}:${row.file}`])]).pipe(Effect.map((item) => item.text)),
git([...cfg, ...args(["show", `${to}:${row.file}`])]).pipe(Effect.map((item) => item.text)),
],
{ concurrency: 2 },
)
})
const load = Effect.fnUntraced(
function* (rows: Row[]) {
const refs = rows.flatMap((row) => {
if (row.binary) return []
if (row.status === "added")
return [{ file: row.file, side: "after", ref: `${to}:${row.file}` } satisfies Ref]
if (row.status === "deleted") {
return [{ file: row.file, side: "before", ref: `${from}:${row.file}` } satisfies Ref]
}
return [
{ file: row.file, side: "before", ref: `${from}:${row.file}` } satisfies Ref,
{ file: row.file, side: "after", ref: `${to}:${row.file}` } satisfies Ref,
]
})
if (!refs.length) return new Map<string, { before: string; after: string }>()
const proc = ChildProcess.make("git", [...cfg, ...args(["cat-file", "--batch"])], {
cwd: state.directory,
extendEnv: true,
stdin: Stream.make(new TextEncoder().encode(refs.map((item) => item.ref).join("\n") + "\n")),
})
const handle = yield* spawner.spawn(proc)
const [out, err] = yield* Effect.all(
[Stream.mkUint8Array(handle.stdout), Stream.mkString(Stream.decodeText(handle.stderr))],
{ concurrency: 2 },
)
const code = yield* handle.exitCode
if (code !== 0) {
log.info("git cat-file --batch failed during snapshot diff, falling back to per-file git show", {
stderr: err,
refs: refs.length,
})
return
}
const fail = (msg: string, extra?: Record<string, string>) => {
log.info(msg, { ...extra, refs: refs.length })
return undefined
}
const map = new Map<string, { before: string; after: string }>()
const dec = new TextDecoder()
let i = 0
// Parse the default `git cat-file --batch` stream: one header line,
// then exactly `size` bytes of blob content, then a trailing newline.
for (const ref of refs) {
let end = i
while (end < out.length && out[end] !== 10) end += 1
if (end >= out.length) {
return fail(
"git cat-file --batch returned a truncated header during snapshot diff, falling back to per-file git show",
)
}
const head = dec.decode(out.slice(i, end))
i = end + 1
const hit = map.get(ref.file) ?? { before: "", after: "" }
if (head.endsWith(" missing")) {
map.set(ref.file, hit)
continue
}
const match = head.match(/^[0-9a-f]+ blob (\d+)$/)
if (!match) {
return fail(
"git cat-file --batch returned an unexpected header during snapshot diff, falling back to per-file git show",
{ head },
)
}
const size = Number(match[1])
if (!Number.isInteger(size) || size < 0 || i + size >= out.length || out[i + size] !== 10) {
return fail(
"git cat-file --batch returned truncated content during snapshot diff, falling back to per-file git show",
{ head },
)
}
const text = dec.decode(out.slice(i, i + size))
if (ref.side === "before") hit.before = text
if (ref.side === "after") hit.after = text
map.set(ref.file, hit)
i += size + 1
}
if (i !== out.length) {
return fail(
"git cat-file --batch returned trailing data during snapshot diff, falling back to per-file git show",
)
}
return map
},
Effect.scoped,
Effect.catch(() =>
Effect.succeed<Map<string, { before: string; after: string }> | undefined>(undefined),
),
)
const result: Snapshot.FileDiff[] = []
const status = new Map<string, "added" | "deleted" | "modified">()
@@ -599,45 +459,30 @@ export namespace Snapshot {
},
)
const rows = numstat.text
.trim()
.split("\n")
.filter(Boolean)
.flatMap((line) => {
const [adds, dels, file] = line.split("\t")
if (!file) return []
const binary = adds === "-" && dels === "-"
const additions = binary ? 0 : parseInt(adds)
const deletions = binary ? 0 : parseInt(dels)
return [
{
file,
status: status.get(file) ?? "modified",
binary,
additions: Number.isFinite(additions) ? additions : 0,
deletions: Number.isFinite(deletions) ? deletions : 0,
} satisfies Row,
]
for (const line of numstat.text.trim().split("\n")) {
if (!line) continue
const [adds, dels, file] = line.split("\t")
if (!file) continue
const binary = adds === "-" && dels === "-"
const [before, after] = binary
? ["", ""]
: yield* Effect.all(
[
git([...cfg, ...args(["show", `${from}:${file}`])]).pipe(Effect.map((item) => item.text)),
git([...cfg, ...args(["show", `${to}:${file}`])]).pipe(Effect.map((item) => item.text)),
],
{ concurrency: 2 },
)
const additions = binary ? 0 : parseInt(adds)
const deletions = binary ? 0 : parseInt(dels)
result.push({
file,
before,
after,
additions: Number.isFinite(additions) ? additions : 0,
deletions: Number.isFinite(deletions) ? deletions : 0,
status: status.get(file) ?? "modified",
})
const step = 100
// Keep batches bounded so a large diff does not buffer every blob at once.
for (let i = 0; i < rows.length; i += step) {
const run = rows.slice(i, i + step)
const text = yield* load(run)
for (const row of run) {
const hit = text?.get(row.file) ?? { before: "", after: "" }
const [before, after] = row.binary ? ["", ""] : text ? [hit.before, hit.after] : yield* show(row)
result.push({
file: row.file,
before,
after,
additions: row.additions,
deletions: row.deletions,
status: row.status,
})
}
}
return result

View File

@@ -50,22 +50,6 @@ const FILES = new Set([
const FLAGS = new Set(["-destination", "-literalpath", "-path"])
const SWITCHES = new Set(["-confirm", "-debug", "-force", "-nonewline", "-recurse", "-verbose", "-whatif"])
const Parameters = z.object({
command: z.string().describe("The command to execute"),
timeout: z.number().describe("Optional timeout in milliseconds").optional(),
workdir: z
.string()
.describe(
`The working directory to run the command in. Defaults to the current directory. Use this instead of 'cd' commands.`,
)
.optional(),
description: z
.string()
.describe(
"Clear, concise description of what this command does in 5-10 words. Examples:\nInput: ls\nOutput: Lists files in current directory\n\nInput: git status\nOutput: Shows working tree status\n\nInput: npm install\nOutput: Installs package dependencies\n\nInput: mkdir foo\nOutput: Creates directory 'foo'",
),
})
type Part = {
type: string
text: string
@@ -468,7 +452,21 @@ export const BashTool = Tool.define("bash", async () => {
.replaceAll("${chaining}", chain)
.replaceAll("${maxLines}", String(Truncate.MAX_LINES))
.replaceAll("${maxBytes}", String(Truncate.MAX_BYTES)),
parameters: Parameters,
parameters: z.object({
command: z.string().describe("The command to execute"),
timeout: z.number().describe("Optional timeout in milliseconds").optional(),
workdir: z
.string()
.describe(
`The working directory to run the command in. Defaults to ${Instance.directory}. Use this instead of 'cd' commands.`,
)
.optional(),
description: z
.string()
.describe(
"Clear, concise description of what this command does in 5-10 words. Examples:\nInput: ls\nOutput: Lists files in current directory\n\nInput: git status\nOutput: Shows working tree status\n\nInput: npm install\nOutput: Installs package dependencies\n\nInput: mkdir foo\nOutput: Creates directory 'foo'",
),
}),
async execute(params, ctx) {
const cwd = params.workdir ? await resolvePath(params.workdir, Instance.directory, shell) : Instance.directory
if (params.timeout !== undefined && params.timeout < 0) {

View File

@@ -7,22 +7,20 @@ import DESCRIPTION from "./batch.txt"
const DISALLOWED = new Set(["batch"])
const FILTERED_FROM_SUGGESTIONS = new Set(["invalid", "patch", ...DISALLOWED])
const Parameters = z.object({
tool_calls: z
.array(
z.object({
tool: z.string().describe("The name of the tool to execute"),
parameters: z.object({}).loose().describe("Parameters for the tool"),
}),
)
.min(1, "Provide at least one tool call")
.describe("Array of tool calls to execute in parallel"),
})
export const BatchTool = Tool.define("batch", async () => {
return {
description: DESCRIPTION,
parameters: Parameters,
parameters: z.object({
tool_calls: z
.array(
z.object({
tool: z.string().describe("The name of the tool to execute"),
parameters: z.object({}).loose().describe("Parameters for the tool"),
}),
)
.min(1, "Provide at least one tool call")
.describe("Array of tool calls to execute in parallel"),
}),
formatValidationError(error) {
const formattedErrors = error.issues
.map((issue) => {

View File

@@ -41,6 +41,6 @@ export const QuestionTool = Tool.defineEffect<typeof parameters, Metadata, Quest
},
}
},
}
} satisfies Tool.Def<typeof parameters, Metadata>
}),
)

View File

@@ -12,8 +12,10 @@ import { WebFetchTool } from "./webfetch"
import { WriteTool } from "./write"
import { InvalidTool } from "./invalid"
import { SkillTool } from "./skill"
import type { Agent } from "../agent/agent"
import { Tool } from "./tool"
import { Config } from "../config/config"
import path from "path"
import { type ToolContext as PluginToolContext, type ToolDefinition } from "@opencode-ai/plugin"
import z from "zod"
import { Plugin } from "../plugin"
@@ -32,43 +34,45 @@ import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { Env } from "../env"
import { Question } from "../question"
import { Todo } from "../session/todo"
import path from "path"
export namespace ToolRegistry {
const log = Log.create({ service: "tool.registry" })
type State = {
custom: Tool.Def[]
builtin: Tool.Def[]
custom: Tool.Info[]
}
export interface Interface {
readonly ids: () => Effect.Effect<string[]>
readonly all: () => Effect.Effect<Tool.Def[]>
readonly tools: (model: { providerID: ProviderID; modelID: ModelID }) => Effect.Effect<Tool.Def[]>
readonly fromID: (id: string) => Effect.Effect<Tool.Def>
readonly named: {
task: Tool.Info
read: Tool.Info
}
readonly tools: (
model: { providerID: ProviderID; modelID: ModelID },
agent?: Agent.Info,
) => Effect.Effect<(Tool.Def & { id: string })[]>
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/ToolRegistry") {}
export const layer: Layer.Layer<Service, never, Config.Service | Plugin.Service | Question.Service | Todo.Service> =
Layer.effect(
Service,
Effect.gen(function* () {
const config = yield* Config.Service
const plugin = yield* Plugin.Service
export const layer: Layer.Layer<Service, never, Config.Service | Plugin.Service | Question.Service> = Layer.effect(
Service,
Effect.gen(function* () {
const config = yield* Config.Service
const plugin = yield* Plugin.Service
const build = <T extends Tool.Info>(tool: T | Effect.Effect<T, never, any>) =>
Effect.isEffect(tool) ? tool.pipe(Effect.flatMap(Tool.init)) : Tool.init(tool)
const build = <T extends Tool.Info>(tool: T | Effect.Effect<T, never, any>) =>
Effect.isEffect(tool) ? tool : Effect.succeed(tool)
const state = yield* InstanceState.make<State>(
Effect.fn("ToolRegistry.state")(function* (ctx) {
const custom: Tool.Def[] = []
const state = yield* InstanceState.make<State>(
Effect.fn("ToolRegistry.state")(function* (ctx) {
const custom: Tool.Info[] = []
function fromPlugin(id: string, def: ToolDefinition): Tool.Def {
return {
id,
function fromPlugin(id: string, def: ToolDefinition): Tool.Info {
return {
id,
init: async (initCtx) => ({
parameters: z.object(def.args),
description: def.description,
execute: async (args, toolCtx) => {
@@ -78,127 +82,139 @@ export namespace ToolRegistry {
worktree: ctx.worktree,
} as unknown as PluginToolContext
const result = await def.execute(args as any, pluginCtx)
const out = await Truncate.output(result, {})
const out = await Truncate.output(result, {}, initCtx?.agent)
return {
title: "",
output: out.truncated ? out.content : result,
metadata: { truncated: out.truncated, outputPath: out.truncated ? out.outputPath : undefined },
}
},
}
}),
}
}
const dirs = yield* config.directories()
const matches = dirs.flatMap((dir) =>
Glob.scanSync("{tool,tools}/*.{js,ts}", { cwd: dir, absolute: true, dot: true, symlink: true }),
const dirs = yield* config.directories()
const matches = dirs.flatMap((dir) =>
Glob.scanSync("{tool,tools}/*.{js,ts}", { cwd: dir, absolute: true, dot: true, symlink: true }),
)
if (matches.length) yield* config.waitForDependencies()
for (const match of matches) {
const namespace = path.basename(match, path.extname(match))
const mod = yield* Effect.promise(
() => import(process.platform === "win32" ? match : pathToFileURL(match).href),
)
if (matches.length) yield* config.waitForDependencies()
for (const match of matches) {
const namespace = path.basename(match, path.extname(match))
const mod = yield* Effect.promise(
() => import(process.platform === "win32" ? match : pathToFileURL(match).href),
)
for (const [id, def] of Object.entries<ToolDefinition>(mod)) {
custom.push(fromPlugin(id === "default" ? namespace : `${namespace}_${id}`, def))
}
for (const [id, def] of Object.entries<ToolDefinition>(mod)) {
custom.push(fromPlugin(id === "default" ? namespace : `${namespace}_${id}`, def))
}
}
const plugins = yield* plugin.list()
for (const p of plugins) {
for (const [id, def] of Object.entries(p.tool ?? {})) {
custom.push(fromPlugin(id, def))
}
const plugins = yield* plugin.list()
for (const p of plugins) {
for (const [id, def] of Object.entries(p.tool ?? {})) {
custom.push(fromPlugin(id, def))
}
}
const cfg = yield* config.get()
const question =
["app", "cli", "desktop"].includes(Flag.OPENCODE_CLIENT) || Flag.OPENCODE_ENABLE_QUESTION_TOOL
return { custom }
}),
)
const invalid = yield* build(InvalidTool)
const ask = yield* build(QuestionTool)
const bash = yield* build(BashTool)
const read = yield* build(ReadTool)
const glob = yield* build(GlobTool)
const grep = yield* build(GrepTool)
const edit = yield* build(EditTool)
const write = yield* build(WriteTool)
const task = yield* build(TaskTool)
const fetch = yield* build(WebFetchTool)
const todo = yield* build(TodoWriteTool)
const search = yield* build(WebSearchTool)
const code = yield* build(CodeSearchTool)
const skill = yield* build(SkillTool)
const patch = yield* build(ApplyPatchTool)
const lsp = yield* build(LspTool)
const batch = yield* build(BatchTool)
const plan = yield* build(PlanExitTool)
const all = Effect.fn("ToolRegistry.all")(function* (custom: Tool.Info[]) {
const cfg = yield* config.get()
const question = ["app", "cli", "desktop"].includes(Flag.OPENCODE_CLIENT) || Flag.OPENCODE_ENABLE_QUESTION_TOOL
return [
invalid,
...(question ? [ask] : []),
bash,
read,
glob,
grep,
edit,
write,
task,
fetch,
todo,
search,
code,
skill,
patch,
...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [lsp] : []),
...(cfg.experimental?.batch_tool === true ? [batch] : []),
...(Flag.OPENCODE_EXPERIMENTAL_PLAN_MODE && Flag.OPENCODE_CLIENT === "cli" ? [plan] : []),
...custom,
]
})
const ids = Effect.fn("ToolRegistry.ids")(function* () {
const s = yield* InstanceState.get(state)
const tools = yield* all(s.custom)
return tools.map((t) => t.id)
})
const tools = Effect.fn("ToolRegistry.tools")(function* (
model: { providerID: ProviderID; modelID: ModelID },
agent?: Agent.Info,
) {
const s = yield* InstanceState.get(state)
const allTools = yield* all(s.custom)
const filtered = allTools.filter((tool) => {
if (tool.id === "codesearch" || tool.id === "websearch") {
return model.providerID === ProviderID.opencode || Flag.OPENCODE_ENABLE_EXA
}
const usePatch =
!!Env.get("OPENCODE_E2E_LLM_URL") ||
(model.modelID.includes("gpt-") && !model.modelID.includes("oss") && !model.modelID.includes("gpt-4"))
if (tool.id === "apply_patch") return usePatch
if (tool.id === "edit" || tool.id === "write") return !usePatch
return true
})
return yield* Effect.forEach(
filtered,
Effect.fnUntraced(function* (tool: Tool.Info) {
using _ = log.time(tool.id)
const next = yield* Effect.promise(() => tool.init({ agent }))
const output = {
description: next.description,
parameters: next.parameters,
}
yield* plugin.trigger("tool.definition", { toolID: tool.id }, output)
return {
custom,
builtin: yield* Effect.forEach(
[
InvalidTool,
BashTool,
ReadTool,
GlobTool,
GrepTool,
EditTool,
WriteTool,
TaskTool,
WebFetchTool,
TodoWriteTool,
WebSearchTool,
CodeSearchTool,
SkillTool,
ApplyPatchTool,
...(question ? [QuestionTool] : []),
...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [LspTool] : []),
...(cfg.experimental?.batch_tool === true ? [BatchTool] : []),
...(Flag.OPENCODE_EXPERIMENTAL_PLAN_MODE && Flag.OPENCODE_CLIENT === "cli" ? [PlanExitTool] : []),
],
build,
{ concurrency: "unbounded" },
),
id: tool.id,
description: output.description,
parameters: output.parameters,
execute: next.execute,
formatValidationError: next.formatValidationError,
}
}),
{ concurrency: "unbounded" },
)
})
const all = Effect.fn("ToolRegistry.all")(function* () {
const s = yield* InstanceState.get(state)
return [...s.builtin, ...s.custom] as Tool.Def[]
})
const fromID = Effect.fn("ToolRegistry.fromID")(function* (id: string) {
const allTools = yield* all()
const match = allTools.find((tool) => tool.id === id)
if (!match) return yield* Effect.die(`Tool not found: ${id}`)
return match
})
const ids = Effect.fn("ToolRegistry.ids")(function* () {
return yield* all().pipe(Effect.map((t) => t.map((x) => x.id)))
})
const tools = Effect.fn("ToolRegistry.tools")(function* (model: { providerID: ProviderID; modelID: ModelID }) {
const allTools = yield* all()
const filtered = allTools.filter((tool) => {
if (tool.id === CodeSearchTool.id || tool.id === WebSearchTool.id) {
return model.providerID === ProviderID.opencode || Flag.OPENCODE_ENABLE_EXA
}
const usePatch =
!!Env.get("OPENCODE_E2E_LLM_URL") ||
(model.modelID.includes("gpt-") && !model.modelID.includes("oss") && !model.modelID.includes("gpt-4"))
if (tool.id === "apply_patch") return usePatch
if (tool.id === "edit" || tool.id === "write") return !usePatch
return true
})
return yield* Effect.forEach(
filtered,
Effect.fnUntraced(function* (tool: Tool.Def) {
using _ = log.time(tool.id)
const output = {
description: tool.description,
parameters: tool.parameters,
}
yield* plugin.trigger("tool.definition", { toolID: tool.id }, output)
return {
id: tool.id,
description: output.description,
parameters: output.parameters,
execute: tool.execute,
formatValidationError: tool.formatValidationError,
}
}),
{ concurrency: "unbounded" },
)
})
return Service.of({ ids, tools, all, fromID })
}),
)
return Service.of({ ids, named: { task, read }, tools })
}),
)
export const defaultLayer = Layer.unwrap(
Effect.sync(() =>
@@ -206,7 +222,6 @@ export namespace ToolRegistry {
Layer.provide(Config.defaultLayer),
Layer.provide(Plugin.defaultLayer),
Layer.provide(Question.defaultLayer),
Layer.provide(Todo.defaultLayer),
),
),
)
@@ -217,10 +232,13 @@ export namespace ToolRegistry {
return runPromise((svc) => svc.ids())
}
export async function tools(model: {
providerID: ProviderID
modelID: ModelID
}): Promise<(Tool.Def & { id: string })[]> {
return runPromise((svc) => svc.tools(model))
export async function tools(
model: {
providerID: ProviderID
modelID: ModelID
},
agent?: Agent.Info,
): Promise<(Tool.Def & { id: string })[]> {
return runPromise((svc) => svc.tools(model, agent))
}
}

View File

@@ -6,12 +6,9 @@ import { Skill } from "../skill"
import { Ripgrep } from "../file/ripgrep"
import { iife } from "@/util/iife"
const Parameters = z.object({
name: z.string().describe("The name of the skill from available_skills"),
})
export const SkillTool = Tool.define("skill", async (ctx) => {
const list = await Skill.available(ctx?.agent)
export const SkillTool = Tool.define("skill", async () => {
const list = await Skill.all()
const description =
list.length === 0
? "Load a specialized skill that provides domain-specific instructions and workflows. No skills are currently available."
@@ -36,10 +33,14 @@ export const SkillTool = Tool.define("skill", async () => {
.join(", ")
const hint = examples.length > 0 ? ` (e.g., ${examples}, ...)` : ""
const parameters = z.object({
name: z.string().describe(`The name of the skill from available_skills${hint}`),
})
return {
description,
parameters: Parameters,
async execute(params: z.infer<typeof Parameters>, ctx) {
parameters,
async execute(params: z.infer<typeof parameters>, ctx) {
const skill = await Skill.get(params.name)
if (!skill) {

View File

@@ -4,11 +4,13 @@ import z from "zod"
import { Session } from "../session"
import { SessionID, MessageID } from "../session/schema"
import { MessageV2 } from "../session/message-v2"
import { Identifier } from "../id/id"
import { Agent } from "../agent/agent"
import { SessionPrompt } from "../session/prompt"
import { iife } from "@/util/iife"
import { defer } from "@/util/defer"
import { Config } from "../config/config"
import { Permission } from "@/permission"
const parameters = z.object({
description: z.string().describe("A short (3-5 words) description of the task"),
@@ -23,12 +25,19 @@ const parameters = z.object({
command: z.string().describe("The command that triggered this task").optional(),
})
export const TaskTool = Tool.define("task", async () => {
export const TaskTool = Tool.define("task", async (ctx) => {
const agents = await Agent.list().then((x) => x.filter((a) => a.mode !== "primary"))
// Filter agents by permissions if agent provided
const caller = ctx?.agent
const accessibleAgents = caller
? agents.filter((a) => Permission.evaluate("task", a.name, caller.permission).action !== "deny")
: agents
const list = accessibleAgents.toSorted((a, b) => a.name.localeCompare(b.name))
const description = DESCRIPTION.replace(
"{agents}",
agents
.toSorted((a, b) => a.name.localeCompare(b.name))
list
.map((a) => `- ${a.name}: ${a.description ?? "This subagent should only be called manually by the user."}`)
.join("\n"),
)

View File

@@ -1,48 +1,31 @@
import z from "zod"
import { Effect } from "effect"
import { Tool } from "./tool"
import DESCRIPTION_WRITE from "./todowrite.txt"
import { Todo } from "../session/todo"
const parameters = z.object({
todos: z.array(z.object(Todo.Info.shape)).describe("The updated todo list"),
})
type Metadata = {
todos: Todo.Info[]
}
export const TodoWriteTool = Tool.defineEffect<typeof parameters, Metadata, Todo.Service>(
"todowrite",
Effect.gen(function* () {
const todo = yield* Todo.Service
return {
description: DESCRIPTION_WRITE,
parameters,
async execute(params: z.infer<typeof parameters>, ctx: Tool.Context<Metadata>) {
await ctx.ask({
permission: "todowrite",
patterns: ["*"],
always: ["*"],
metadata: {},
})
await todo
.update({
sessionID: ctx.sessionID,
todos: params.todos,
})
.pipe(Effect.runPromise)
return {
title: `${params.todos.filter((x) => x.status !== "completed").length} todos`,
output: JSON.stringify(params.todos, null, 2),
metadata: {
todos: params.todos,
},
}
},
} satisfies Tool.DefWithoutID<typeof parameters, Metadata>
export const TodoWriteTool = Tool.define("todowrite", {
description: DESCRIPTION_WRITE,
parameters: z.object({
todos: z.array(z.object(Todo.Info.shape)).describe("The updated todo list"),
}),
)
async execute(params, ctx) {
await ctx.ask({
permission: "todowrite",
patterns: ["*"],
always: ["*"],
metadata: {},
})
await Todo.update({
sessionID: ctx.sessionID,
todos: params.todos,
})
return {
title: `${params.todos.filter((x) => x.status !== "completed").length} todos`,
output: JSON.stringify(params.todos, null, 2),
metadata: {
todos: params.todos,
},
}
},
})

View File

@@ -1,16 +1,20 @@
import z from "zod"
import { Effect } from "effect"
import type { MessageV2 } from "../session/message-v2"
import type { Agent } from "../agent/agent"
import type { Permission } from "../permission"
import type { SessionID, MessageID } from "../session/schema"
import { Truncate } from "./truncate"
import { Agent } from "@/agent/agent"
export namespace Tool {
interface Metadata {
[key: string]: any
}
export interface InitContext {
agent?: Agent.Info
}
export type Context<M extends Metadata = Metadata> = {
sessionID: SessionID
messageID: MessageID
@@ -22,9 +26,7 @@ export namespace Tool {
metadata(input: { title?: string; metadata?: M }): void
ask(input: Omit<Permission.Request, "id" | "sessionID" | "tool">): Promise<void>
}
export interface Def<Parameters extends z.ZodType = z.ZodType, M extends Metadata = Metadata> {
id: string
description: string
parameters: Parameters
execute(
@@ -38,14 +40,10 @@ export namespace Tool {
}>
formatValidationError?(error: z.ZodError): string
}
export type DefWithoutID<Parameters extends z.ZodType = z.ZodType, M extends Metadata = Metadata> = Omit<
Def<Parameters, M>,
"id"
>
export interface Info<Parameters extends z.ZodType = z.ZodType, M extends Metadata = Metadata> {
id: string
init: () => Promise<DefWithoutID<Parameters, M>>
init: (ctx?: InitContext) => Promise<Def<Parameters, M>>
}
export type InferParameters<T> =
@@ -59,10 +57,10 @@ export namespace Tool {
function wrap<Parameters extends z.ZodType, Result extends Metadata>(
id: string,
init: (() => Promise<DefWithoutID<Parameters, Result>>) | DefWithoutID<Parameters, Result>,
init: ((ctx?: InitContext) => Promise<Def<Parameters, Result>>) | Def<Parameters, Result>,
) {
return async () => {
const toolInfo = init instanceof Function ? await init() : { ...init }
return async (initCtx?: InitContext) => {
const toolInfo = init instanceof Function ? await init(initCtx) : { ...init }
const execute = toolInfo.execute
toolInfo.execute = async (args, ctx) => {
try {
@@ -80,7 +78,7 @@ export namespace Tool {
if (result.metadata.truncated !== undefined) {
return result
}
const truncated = await Truncate.output(result.output, {}, await Agent.get(ctx.agent))
const truncated = await Truncate.output(result.output, {}, initCtx?.agent)
return {
...result,
output: truncated.content,
@@ -97,7 +95,7 @@ export namespace Tool {
export function define<Parameters extends z.ZodType, Result extends Metadata>(
id: string,
init: (() => Promise<DefWithoutID<Parameters, Result>>) | DefWithoutID<Parameters, Result>,
init: ((ctx?: InitContext) => Promise<Def<Parameters, Result>>) | Def<Parameters, Result>,
): Info<Parameters, Result> {
return {
id,
@@ -107,18 +105,8 @@ export namespace Tool {
export function defineEffect<Parameters extends z.ZodType, Result extends Metadata, R>(
id: string,
init: Effect.Effect<(() => Promise<DefWithoutID<Parameters, Result>>) | DefWithoutID<Parameters, Result>, never, R>,
init: Effect.Effect<((ctx?: InitContext) => Promise<Def<Parameters, Result>>) | Def<Parameters, Result>, never, R>,
): Effect.Effect<Info<Parameters, Result>, never, R> {
return Effect.map(init, (next) => ({ id, init: wrap(id, next) }))
}
export function init(info: Info): Effect.Effect<Def, never, any> {
return Effect.gen(function* () {
const init = yield* Effect.promise(() => info.init())
return {
...init,
id: info.id,
}
})
}
}

View File

@@ -11,25 +11,6 @@ const API_CONFIG = {
DEFAULT_NUM_RESULTS: 8,
} as const
const Parameters = z.object({
query: z.string().describe("Websearch query"),
numResults: z.number().optional().describe("Number of search results to return (default: 8)"),
livecrawl: z
.enum(["fallback", "preferred"])
.optional()
.describe(
"Live crawl mode - 'fallback': use live crawling as backup if cached content unavailable, 'preferred': prioritize live crawling (default: 'fallback')",
),
type: z
.enum(["auto", "fast", "deep"])
.optional()
.describe("Search type - 'auto': balanced search (default), 'fast': quick results, 'deep': comprehensive search"),
contextMaxCharacters: z
.number()
.optional()
.describe("Maximum characters for context string optimized for LLMs (default: 10000)"),
})
interface McpSearchRequest {
jsonrpc: string
id: number
@@ -61,7 +42,26 @@ export const WebSearchTool = Tool.define("websearch", async () => {
get description() {
return DESCRIPTION.replace("{{year}}", new Date().getFullYear().toString())
},
parameters: Parameters,
parameters: z.object({
query: z.string().describe("Websearch query"),
numResults: z.number().optional().describe("Number of search results to return (default: 8)"),
livecrawl: z
.enum(["fallback", "preferred"])
.optional()
.describe(
"Live crawl mode - 'fallback': use live crawling as backup if cached content unavailable, 'preferred': prioritize live crawling (default: 'fallback')",
),
type: z
.enum(["auto", "fast", "deep"])
.optional()
.describe(
"Search type - 'auto': balanced search (default), 'fast': quick results, 'deep': comprehensive search",
),
contextMaxCharacters: z
.number()
.optional()
.describe("Maximum characters for context string optimized for LLMs (default: 10000)"),
}),
async execute(params, ctx) {
await ctx.ask({
permission: "websearch",

View File

@@ -1,22 +1,12 @@
import { test, expect } from "bun:test"
import { mkdir, unlink } from "fs/promises"
import path from "path"
import { tmpdir } from "../fixture/fixture"
import { Global } from "../../src/global"
import { Instance } from "../../src/project/instance"
import { Plugin } from "../../src/plugin/index"
import { Provider } from "../../src/provider/provider"
import { ProviderID, ModelID } from "../../src/provider/schema"
import { Filesystem } from "../../src/util/filesystem"
import { Env } from "../../src/env"
function paid(providers: Awaited<ReturnType<typeof Provider.list>>) {
const item = providers[ProviderID.make("opencode")]
expect(item).toBeDefined()
return Object.values(item.models).filter((model) => model.cost.input > 0).length
}
test("provider loaded from env variable", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
@@ -2292,203 +2282,3 @@ test("cloudflare-ai-gateway forwards config metadata options", async () => {
},
})
})
test("plugin config providers persist after instance dispose", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const root = path.join(dir, ".opencode", "plugin")
await mkdir(root, { recursive: true })
await Bun.write(
path.join(root, "demo-provider.ts"),
[
"export default {",
' id: "demo.plugin-provider",',
" server: async () => ({",
" async config(cfg) {",
" cfg.provider ??= {}",
" cfg.provider.demo = {",
' name: "Demo Provider",',
' npm: "@ai-sdk/openai-compatible",',
' api: "https://example.com/v1",',
" models: {",
" chat: {",
' name: "Demo Chat",',
" tool_call: true,",
" limit: { context: 128000, output: 4096 },",
" },",
" },",
" }",
" },",
" }),",
"}",
"",
].join("\n"),
)
},
})
const first = await Instance.provide({
directory: tmp.path,
fn: async () => {
await Plugin.init()
return Provider.list()
},
})
expect(first[ProviderID.make("demo")]).toBeDefined()
expect(first[ProviderID.make("demo")].models[ModelID.make("chat")]).toBeDefined()
await Instance.disposeAll()
const second = await Instance.provide({
directory: tmp.path,
fn: async () => Provider.list(),
})
expect(second[ProviderID.make("demo")]).toBeDefined()
expect(second[ProviderID.make("demo")].models[ModelID.make("chat")]).toBeDefined()
})
test("plugin config enabled and disabled providers are honored", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const root = path.join(dir, ".opencode", "plugin")
await mkdir(root, { recursive: true })
await Bun.write(
path.join(root, "provider-filter.ts"),
[
"export default {",
' id: "demo.provider-filter",',
" server: async () => ({",
" async config(cfg) {",
' cfg.enabled_providers = ["anthropic", "openai"]',
' cfg.disabled_providers = ["openai"]',
" },",
" }),",
"}",
"",
].join("\n"),
)
},
})
await Instance.provide({
directory: tmp.path,
init: async () => {
Env.set("ANTHROPIC_API_KEY", "test-anthropic-key")
Env.set("OPENAI_API_KEY", "test-openai-key")
},
fn: async () => {
const providers = await Provider.list()
expect(providers[ProviderID.anthropic]).toBeDefined()
expect(providers[ProviderID.openai]).toBeUndefined()
},
})
})
test("opencode loader keeps paid models when config apiKey is present", async () => {
await using base = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
}),
)
},
})
const none = await Instance.provide({
directory: base.path,
fn: async () => paid(await Provider.list()),
})
await using keyed = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
provider: {
opencode: {
options: {
apiKey: "test-key",
},
},
},
}),
)
},
})
const keyedCount = await Instance.provide({
directory: keyed.path,
fn: async () => paid(await Provider.list()),
})
expect(none).toBe(0)
expect(keyedCount).toBeGreaterThan(0)
})
test("opencode loader keeps paid models when auth exists", async () => {
await using base = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
}),
)
},
})
const none = await Instance.provide({
directory: base.path,
fn: async () => paid(await Provider.list()),
})
await using keyed = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
}),
)
},
})
const authPath = path.join(Global.Path.data, "auth.json")
let prev: string | undefined
try {
prev = await Filesystem.readText(authPath)
} catch {}
try {
await Filesystem.write(
authPath,
JSON.stringify({
opencode: {
type: "api",
key: "test-key",
},
}),
)
const keyedCount = await Instance.provide({
directory: keyed.path,
fn: async () => paid(await Provider.list()),
})
expect(none).toBe(0)
expect(keyedCount).toBeGreaterThan(0)
} finally {
if (prev !== undefined) {
await Filesystem.write(authPath, prev)
}
if (prev === undefined) {
try {
await unlink(authPath)
} catch {}
}
}
})

View File

@@ -16,7 +16,6 @@ import { Provider as ProviderSvc } from "../../src/provider/provider"
import type { Provider } from "../../src/provider/provider"
import { ModelID, ProviderID } from "../../src/provider/schema"
import { Question } from "../../src/question"
import { Todo } from "../../src/session/todo"
import { Session } from "../../src/session"
import { LLM } from "../../src/session/llm"
import { MessageV2 } from "../../src/session/message-v2"
@@ -163,12 +162,7 @@ function makeHttp() {
status,
).pipe(Layer.provideMerge(infra))
const question = Question.layer.pipe(Layer.provideMerge(deps))
const todo = Todo.layer.pipe(Layer.provideMerge(deps))
const registry = ToolRegistry.layer.pipe(
Layer.provideMerge(todo),
Layer.provideMerge(question),
Layer.provideMerge(deps),
)
const registry = ToolRegistry.layer.pipe(Layer.provideMerge(question), Layer.provideMerge(deps))
const trunc = Truncate.layer.pipe(Layer.provideMerge(deps))
const proc = SessionProcessor.layer.pipe(Layer.provideMerge(deps))
const compact = SessionCompaction.layer.pipe(Layer.provideMerge(proc), Layer.provideMerge(deps))

View File

@@ -39,7 +39,6 @@ import { Permission } from "../../src/permission"
import { Plugin } from "../../src/plugin"
import { Provider as ProviderSvc } from "../../src/provider/provider"
import { Question } from "../../src/question"
import { Todo } from "../../src/session/todo"
import { SessionCompaction } from "../../src/session/compaction"
import { Instruction } from "../../src/session/instruction"
import { SessionProcessor } from "../../src/session/processor"
@@ -127,12 +126,7 @@ function makeHttp() {
status,
).pipe(Layer.provideMerge(infra))
const question = Question.layer.pipe(Layer.provideMerge(deps))
const todo = Todo.layer.pipe(Layer.provideMerge(deps))
const registry = ToolRegistry.layer.pipe(
Layer.provideMerge(todo),
Layer.provideMerge(question),
Layer.provideMerge(deps),
)
const registry = ToolRegistry.layer.pipe(Layer.provideMerge(question), Layer.provideMerge(deps))
const trunc = Truncate.layer.pipe(Layer.provideMerge(deps))
const proc = SessionProcessor.layer.pipe(Layer.provideMerge(deps))
const compact = SessionCompaction.layer.pipe(Layer.provideMerge(proc), Layer.provideMerge(deps))

View File

@@ -982,98 +982,6 @@ test("diffFull with new file additions", async () => {
})
})
test("diffFull with a large interleaved mixed diff", async () => {
await using tmp = await bootstrap()
await Instance.provide({
directory: tmp.path,
fn: async () => {
const ids = Array.from({ length: 60 }, (_, i) => i.toString().padStart(3, "0"))
const mod = ids.map((id) => fwd(tmp.path, "mix", `${id}-mod.txt`))
const del = ids.map((id) => fwd(tmp.path, "mix", `${id}-del.txt`))
const add = ids.map((id) => fwd(tmp.path, "mix", `${id}-add.txt`))
const bin = ids.map((id) => fwd(tmp.path, "mix", `${id}-bin.bin`))
await $`mkdir -p ${tmp.path}/mix`.quiet()
await Promise.all([
...mod.map((file, i) => Filesystem.write(file, `before-${ids[i]}\n🙂\nline`)),
...del.map((file, i) => Filesystem.write(file, `gone-${ids[i]}\n你好`)),
...bin.map((file, i) => Filesystem.write(file, new Uint8Array([0, i, 255, i % 251]))),
])
const before = await Snapshot.track()
expect(before).toBeTruthy()
await Promise.all([
...mod.map((file, i) => Filesystem.write(file, `after-${ids[i]}\n🚀\nline`)),
...add.map((file, i) => Filesystem.write(file, `new-${ids[i]}\nこんにちは`)),
...bin.map((file, i) => Filesystem.write(file, new Uint8Array([9, i, 8, i % 251]))),
...del.map((file) => fs.rm(file)),
])
const after = await Snapshot.track()
expect(after).toBeTruthy()
const diffs = await Snapshot.diffFull(before!, after!)
expect(diffs).toHaveLength(ids.length * 4)
const map = new Map(diffs.map((item) => [item.file, item]))
for (let i = 0; i < ids.length; i++) {
const m = map.get(fwd("mix", `${ids[i]}-mod.txt`))
expect(m).toBeDefined()
expect(m!.before).toBe(`before-${ids[i]}\n🙂\nline`)
expect(m!.after).toBe(`after-${ids[i]}\n🚀\nline`)
expect(m!.status).toBe("modified")
const d = map.get(fwd("mix", `${ids[i]}-del.txt`))
expect(d).toBeDefined()
expect(d!.before).toBe(`gone-${ids[i]}\n你好`)
expect(d!.after).toBe("")
expect(d!.status).toBe("deleted")
const a = map.get(fwd("mix", `${ids[i]}-add.txt`))
expect(a).toBeDefined()
expect(a!.before).toBe("")
expect(a!.after).toBe(`new-${ids[i]}\nこんにちは`)
expect(a!.status).toBe("added")
const b = map.get(fwd("mix", `${ids[i]}-bin.bin`))
expect(b).toBeDefined()
expect(b!.before).toBe("")
expect(b!.after).toBe("")
expect(b!.additions).toBe(0)
expect(b!.deletions).toBe(0)
expect(b!.status).toBe("modified")
}
},
})
})
test("diffFull preserves git diff order across batch boundaries", async () => {
await using tmp = await bootstrap()
await Instance.provide({
directory: tmp.path,
fn: async () => {
const ids = Array.from({ length: 140 }, (_, i) => i.toString().padStart(3, "0"))
await $`mkdir -p ${tmp.path}/order`.quiet()
await Promise.all(ids.map((id) => Filesystem.write(`${tmp.path}/order/${id}.txt`, `before-${id}`)))
const before = await Snapshot.track()
expect(before).toBeTruthy()
await Promise.all(ids.map((id) => Filesystem.write(`${tmp.path}/order/${id}.txt`, `after-${id}`)))
const after = await Snapshot.track()
expect(after).toBeTruthy()
const expected = ids.map((id) => `order/${id}.txt`)
const diffs = await Snapshot.diffFull(before!, after!)
expect(diffs.map((item) => item.file)).toEqual(expected)
},
})
})
test("diffFull with file modifications", async () => {
await using tmp = await bootstrap()
await Instance.provide({

View File

@@ -28,8 +28,9 @@ describe("tool.task", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const first = await TaskTool.init()
const second = await TaskTool.init()
const build = await Agent.get("build")
const first = await TaskTool.init({ agent: build })
const second = await TaskTool.init({ agent: build })
expect(first.description).toBe(second.description)

View File

@@ -3,6 +3,7 @@ import z from "zod"
import { Tool } from "../../src/tool/tool"
const params = z.object({ input: z.string() })
const defaultArgs = { input: "test" }
function makeTool(id: string, executeFn?: () => void) {
return {
@@ -26,15 +27,54 @@ describe("Tool.define", () => {
await tool.init()
await tool.init()
// The original object's execute should never be overwritten
expect(original.execute).toBe(originalExecute)
})
test("object-defined tool does not accumulate wrapper layers across init() calls", async () => {
let executeCalls = 0
const tool = Tool.define(
"test-tool",
makeTool("test", () => executeCalls++),
)
// Call init() many times to simulate many agentic steps
for (let i = 0; i < 100; i++) {
await tool.init()
}
// Resolve the tool and call execute
const resolved = await tool.init()
executeCalls = 0
// Capture the stack trace inside execute to measure wrapper depth
let stackInsideExecute = ""
const origExec = resolved.execute
resolved.execute = async (args: any, ctx: any) => {
const result = await origExec.call(resolved, args, ctx)
const err = new Error()
stackInsideExecute = err.stack || ""
return result
}
await resolved.execute(defaultArgs, {} as any)
expect(executeCalls).toBe(1)
// Count how many times tool.ts appears in the stack.
// With the fix: 1 wrapper layer (from the most recent init()).
// Without the fix: 101 wrapper layers from accumulated closures.
const toolTsFrames = stackInsideExecute.split("\n").filter((l) => l.includes("tool.ts")).length
expect(toolTsFrames).toBeLessThan(5)
})
test("function-defined tool returns fresh objects and is unaffected", async () => {
const tool = Tool.define("test-fn-tool", () => Promise.resolve(makeTool("test")))
const first = await tool.init()
const second = await tool.init()
// Function-defined tools return distinct objects each time
expect(first).not.toBe(second)
})
@@ -44,6 +84,28 @@ describe("Tool.define", () => {
const first = await tool.init()
const second = await tool.init()
// Each init() should return a separate object so wrappers don't accumulate
expect(first).not.toBe(second)
})
test("validation still works after many init() calls", async () => {
const tool = Tool.define("test-validation", {
description: "validation test",
parameters: z.object({ count: z.number().int().positive() }),
async execute(args) {
return { title: "test", output: String(args.count), metadata: {} }
},
})
for (let i = 0; i < 100; i++) {
await tool.init()
}
const resolved = await tool.init()
const result = await resolved.execute({ count: 42 }, {} as any)
expect(result.output).toBe("42")
await expect(resolved.execute({ count: -1 }, {} as any)).rejects.toThrow("invalid arguments")
})
})

View File

@@ -9,7 +9,7 @@
overflow: visible;
&.tool-collapsible {
--tool-content-gap: 8px;
--tool-content-gap: 4px;
gap: var(--tool-content-gap);
}

View File

@@ -6,7 +6,7 @@
color: var(--text-strong);
font-family: var(--font-family-sans);
font-size: var(--font-size-base); /* 14px */
line-height: var(--line-height-x-large);
line-height: 160%;
/* Spacing for flow */
> *:first-child {
@@ -23,11 +23,11 @@
h4,
h5,
h6 {
font-size: var(--font-size-base);
font-size: 14px;
color: var(--text-strong);
font-weight: var(--font-weight-medium);
margin-top: 2rem;
margin-bottom: 0.75rem;
margin-top: 24px;
margin-bottom: 24px;
line-height: var(--line-height-large);
}
@@ -58,10 +58,10 @@
/* Lists */
ul,
ol {
margin-top: 0.5rem;
margin-bottom: 1rem;
margin-top: 8px;
margin-bottom: 24px;
margin-left: 0;
padding-left: 1.5rem;
padding-left: 32px;
list-style-position: outside;
}
@@ -117,12 +117,12 @@
hr {
border: none;
height: 0;
margin: 2.5rem 0;
margin: 40px 0;
}
.shiki {
font-size: 13px;
padding: 8px 12px;
padding: 12px;
border-radius: 6px;
border: 0.5px solid var(--border-weak-base);
}
@@ -201,8 +201,8 @@
}
pre {
margin-top: 2rem;
margin-bottom: 2rem;
margin-top: 12px;
margin-bottom: 32px;
overflow: auto;
scrollbar-width: none;
@@ -229,7 +229,7 @@
table {
width: 100%;
border-collapse: collapse;
margin: 1.5rem 0;
margin: 24px 0;
font-size: var(--font-size-base);
display: block;
overflow-x: auto;
@@ -239,7 +239,7 @@
td {
/* Minimal borders for structure, matching TUI "lines" roughly but keeping it web-clean */
border-bottom: 1px solid var(--border-weaker-base);
padding: 0.75rem 0.5rem;
padding: 12px;
text-align: left;
vertical-align: top;
}

View File

@@ -283,9 +283,9 @@
line-height: var(--line-height-normal);
[data-component="markdown"] {
margin-top: 24px;
margin-top: 16px;
font-style: normal;
font-size: var(--font-size-base);
font-size: 13px;
color: var(--text-weak);
strong,
@@ -556,9 +556,12 @@
[data-component="exa-tool-output"] {
width: 100%;
padding-top: 8px;
display: flex;
flex-direction: column;
font-family: var(--font-family-sans);
font-size: var(--font-size-base);
line-height: var(--line-height-large);
color: var(--text-base);
}
[data-slot="basic-tool-tool-subtitle"].exa-tool-query {
@@ -578,6 +581,8 @@
[data-slot="exa-tool-link"] {
display: block;
max-width: 100%;
font: inherit;
line-height: inherit;
color: var(--text-interactive-base);
text-decoration: underline;
text-underline-offset: 2px;
@@ -636,13 +641,13 @@
}
[data-component="context-tool-group-list"] {
padding-top: 6px;
padding-top: 0;
padding-right: 0;
padding-bottom: 4px;
padding-left: 13px;
padding-bottom: 0;
padding-left: 12px;
display: flex;
flex-direction: column;
gap: 8px;
gap: 4px;
[data-slot="context-tool-group-item"] {
min-width: 0;

View File

@@ -26,7 +26,7 @@
align-items: flex-start;
align-self: stretch;
min-width: 0;
gap: 18px;
gap: 0px;
overflow-anchor: none;
}
@@ -220,5 +220,5 @@
}
[data-slot="session-turn-list"] {
gap: 48px;
gap: 24px;
}

View File

@@ -568,6 +568,7 @@ const MD = "markdown.css"
const MP = "message-part.css"
const ST = "session-turn.css"
const CL = "collapsible.css"
const BT = "basic-tool.css"
/**
* Source mapping for a CSS control.
@@ -607,10 +608,10 @@ const CSS_CONTROLS: CSSControl[] = [
// --- Timeline spacing ---
{
key: "turn-gap",
label: "Turn gap",
label: "Above user messages",
group: "Timeline Spacing",
type: "range",
initial: "48",
initial: "32",
selector: '[data-slot="session-turn-list"]',
property: "gap",
min: "0",
@@ -621,10 +622,10 @@ const CSS_CONTROLS: CSSControl[] = [
},
{
key: "container-gap",
label: "Container gap",
label: "Below user messages",
group: "Timeline Spacing",
type: "range",
initial: "18",
initial: "0",
selector: '[data-slot="session-turn-message-container"]',
property: "gap",
min: "0",
@@ -1040,12 +1041,40 @@ const CSS_CONTROLS: CSSControl[] = [
},
// --- Tool parts ---
{
key: "tool-subtitle-font-size",
label: "Subtitle font size",
group: "Tool Parts",
type: "range",
initial: "14",
selector: '[data-slot="basic-tool-tool-subtitle"]',
property: "font-size",
min: "10",
max: "22",
step: "1",
unit: "px",
source: { file: BT, anchor: '[data-slot="basic-tool-tool-subtitle"]', prop: "font-size", format: px },
},
{
key: "exa-output-font-size",
label: "Search output font size",
group: "Tool Parts",
type: "range",
initial: "14",
selector: '[data-component="exa-tool-output"]',
property: "font-size",
min: "10",
max: "22",
step: "1",
unit: "px",
source: { file: MP, anchor: '[data-component="exa-tool-output"]', prop: "font-size", format: px },
},
{
key: "tool-content-gap",
label: "Trigger/content gap",
group: "Tool Parts",
type: "range",
initial: "8",
initial: "4",
selector: '[data-component="collapsible"].tool-collapsible',
property: "--tool-content-gap",
min: "0",
@@ -1059,7 +1088,7 @@ const CSS_CONTROLS: CSSControl[] = [
label: "Explored tool gap",
group: "Explored Group",
type: "range",
initial: "14",
initial: "4",
selector: '[data-component="context-tool-group-list"]',
property: "gap",
min: "0",

View File

@@ -13,19 +13,9 @@
"outputs": [],
"passThroughEnv": ["*"]
},
"opencode#test:ci": {
"dependsOn": ["^build"],
"outputs": [".artifacts/unit/junit.xml"],
"passThroughEnv": ["*"]
},
"@opencode-ai/app#test": {
"dependsOn": ["^build"],
"outputs": []
},
"@opencode-ai/app#test:ci": {
"dependsOn": ["^build"],
"outputs": [".artifacts/unit/junit.xml"],
"passThroughEnv": ["*"]
}
}
}