Compare commits

...

10 Commits

Author SHA1 Message Date
Aiden Cline c590e27639 feat(core): add grouped and deferred tool registration (#35232) 2026-07-04 11:49:24 -05:00
Kit Langton 8f4b62eb49 fix(core): report missing search paths (#35337) 2026-07-04 12:12:56 -04:00
Kit Langton 945d1c8cb2 fix(core): bound recursive file watching (#35323) 2026-07-04 11:55:05 -04:00
Kit Langton 610e618bc5 fix(tui): clear completed background shell status (#35320) 2026-07-04 11:33:45 -04:00
Kit Langton 9daa4d85a4 fix(tui): reconcile session state after reconnect (#35262) 2026-07-04 11:05:41 -04:00
Kit Langton 62af66a74f fix(tui): distinguish variant switch notices (#35315) 2026-07-04 10:58:56 -04:00
Kit Langton 5b44e5bf41 test(core): release shell test locations (#35271) 2026-07-04 10:54:34 -04:00
Dax Raad a15afbe8f2 bye bye orchestrator
publish / version (push) Has been cancelled
publish / build-cli (push) Has been cancelled
publish / sign-cli-windows (push) Has been cancelled
publish / build-electron (map[bun_install_flags:--os=darwin --cpu=arm64 host:macos-26 platform_flag:--mac --arm64 target:aarch64-apple-darwin]) (push) Has been cancelled
publish / build-electron (map[bun_install_flags:--os=darwin --cpu=x64 host:macos-26-intel platform_flag:--mac --x64 target:x86_64-apple-darwin]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-ubuntu-2404 platform_flag:--linux target:x86_64-unknown-linux-gnu]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-ubuntu-2404-arm platform_flag:--linux --arm64 target:aarch64-unknown-linux-gnu]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-windows-2025 platform_flag:--win target:x86_64-pc-windows-msvc]) (push) Has been cancelled
publish / build-electron (map[host:windows-2025 platform_flag:--win --arm64 target:aarch64-pc-windows-msvc]) (push) Has been cancelled
publish / publish (push) Has been cancelled
typecheck / typecheck (push) Has been cancelled
2026-07-04 02:04:58 -04:00
Dax Raad 8e0856c43b fix(core): disable external plugin hot reload 2026-07-04 01:58:17 -04:00
Dax Raad f66c829231 Revert "fix(core): bust external plugin import cache"
This reverts commit afe3ebbc35.
2026-07-04 01:54:30 -04:00
37 changed files with 681 additions and 309 deletions
-36
View File
@@ -1,36 +0,0 @@
export default {
id: "Orchestrator",
setup: async (ctx) => {
await ctx.agent.transform((agents) => {
agents.update("orchestrator", (agent) => {
agent.description = "Coordinates work by delegating implementation tasks to the minion subagent."
agent.mode = "primary"
agent.system = [
"You are Orchestrator, the primary coordinating agent for this repository. You do meta work only: you coordinate, brief, and synthesize — you do not perform the work itself.",
"Delegate ALL actual work to the minion subagent — implementation, exploration, discovery, searching the codebase, reading files to understand a problem, and even trivial one-line edits. Task size is never a reason to do it yourself, and there is no 'final integration' exception.",
"You are not hard-banned from tools, but direct tool use is reserved for coordination overhead: a quick peek to phrase a better brief, a fast read-only check to verify a minion's reported result, or answering a question about coordination state. If a tool call is producing the answer or the artifact the user asked for, that call belongs to a minion, not you.",
"Exploration is work. If the user asks how something works or where something lives, delegate the investigation to a minion rather than exploring yourself.",
"Always start minion subagents in the background. Even if you have nothing else to coordinate right now, the user may assign you new work while a Minion runs, and you must stay free to receive it. Never poll; you will be notified when they finish.",
"Give each minion a clear, self-contained brief: the goal, constraints, expected output, and any files or context already known from the user or previous minion reports.",
"Synthesize minion results, decide next steps, and report back concisely.",
].join("\n")
})
agents.update("minion", (agent) => {
agent.description = "Subagent that executes focused tasks delegated by Orchestrator."
agent.mode = "subagent"
agent.model = { providerID: "opencode", id: "glm-5.2" }
agent.system = [
"You are minion, a focused execution subagent for this repository.",
"Complete the specific task delegated to you by Orchestrator using the available tools.",
"Inspect the codebase before making assumptions, make targeted changes when requested, and verify your work when feasible.",
"Follow the repository's AGENTS.md conventions: respect the style guide, run `bun typecheck` from the affected package directory after code changes, never run tests from the repo root, and do not modify packages/opencode unless the task explicitly says V1 work.",
"If the task is ambiguous or you hit a blocker, stop and report your findings instead of guessing.",
"Keep your final response concise: summarize what you did, list important files changed or findings, and call out blockers or verification gaps.",
"Do not delegate to other subagents; execute the assigned work yourself.",
].join("\n")
agent.permissions.push({ action: "subagent", resource: "*", effect: "deny" })
})
})
},
}
+19
View File
@@ -104,6 +104,25 @@ bun dev api <operationId> --param key=value
- If no compatible background server is registered, `bun dev api` starts one through the daemon service. Use `bun dev service status`, `bun dev service restart`, and `bun dev service stop` when you need explicit lifecycle control.
- Prefer raw method/path calls for quick server debugging and operation IDs when exercising documented OpenAPI routes with path or query parameters.
## Auditing installed `opencode2` sessions
Installed next-channel sessions normally use `~/.local/share/opencode/opencode-next.db` and `~/.local/share/opencode/log/opencode.log`; `OPENCODE_DB` can override the database. Before calling `opencode2 api`, inspect `~/.local/state/opencode/service.json` because the command may start a daemon when none is healthy.
For a supplied `ses_...` ID, compare three sources:
- `opencode2 api get /api/session/active` and the Session/message endpoints for live server state.
- The database's ordered `event` rows for durable history.
- `packages/tui/src/context/data.tsx` and the relevant route for client projection and rendering.
Locate an uncertain database without modifying it:
```bash
SESSION=ses_...
for db in ~/.local/share/opencode/*.db; do
sqlite3 "file:$db?mode=ro" "select 1 from session where id='$SESSION' limit 1" 2>/dev/null | grep -q 1 && printf '%s\n' "$db"
done
```
## Logs
- Log files live under `~/.local/share/opencode/log/`. In a local/dev checkout the active file is `opencode-local.log`; `opencode.log` is used for non-local (released) channel installs. Both are append-only, shared across every CLI and server process on the machine.
+1
View File
@@ -151,6 +151,7 @@ const table = sqliteTable("session", {
## V2 Session Core
- Keep durable events minimal: record irreducible new facts and do not repeat state derivable by folding the ordered aggregate history. Enrich projections and read models with previous or derived state when consumers need self-contained views.
- Keep durable prompt admission separate from model execution. `SessionV2.prompt(...)` admits one durable `session_input` row before scheduling advisory `SessionExecution.wake(sessionID)` unless `resume: false` requests admit-only behavior. The serialized runner promotes admitted inputs into visible user messages at safe boundaries.
- Reusing a Session ID adopts the existing Session. Reusing a prompt message ID reconciles an exact retry only when Session, prompt, and delivery mode match; conflicting reuse fails. Historical projected prompts lazily synthesize promoted inbox records during exact retry.
- Keep `SessionExecution` process-global and Session-ID based. Its local implementation owns the process-local Session coordinator and discovers placement through `SessionStore` plus `LocationServiceMap.get(session.location)` only when a drain starts; no layer should take a Session ID. V2 interruption targets the active process-local ownership chain for that Session; idle or missing interruption is a no-op.
@@ -928,6 +928,7 @@ export type SessionContextOutput = {
readonly time: { readonly created: number }
readonly type: "model-switched"
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
readonly previous?: { readonly id: string; readonly providerID: string; readonly variant?: string }
}
| {
readonly id: string
@@ -1621,6 +1622,7 @@ export type SessionMessageOutput = {
readonly time: { readonly created: number }
readonly type: "model-switched"
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
readonly previous?: { readonly id: string; readonly providerID: string; readonly variant?: string }
}
| {
readonly id: string
@@ -1820,6 +1822,7 @@ export type MessageListOutput = {
readonly time: { readonly created: number }
readonly type: "model-switched"
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
readonly previous?: { readonly id: string; readonly providerID: string; readonly variant?: string }
}
| {
readonly id: string
+4 -36
View File
@@ -2,8 +2,7 @@ export * as ConfigExternalPlugin from "./external"
import type { Plugin as EffectPlugin } from "@opencode-ai/plugin/v2/effect"
import type { Plugin as PromisePlugin } from "@opencode-ai/plugin/v2/promise"
import { Effect, Schema, Stream } from "effect"
import { createRequire } from "node:module"
import { Effect, Schema } from "effect"
import path from "path"
import { fileURLToPath, pathToFileURL } from "url"
import { Config } from "../../config"
@@ -36,9 +35,6 @@ const PluginPackage = Schema.Struct({
module: Schema.optional(Schema.String),
})
let importGeneration = 0
const moduleCache = createRequire(import.meta.url).cache
export const Plugin = define({
id: "config-plugin",
effect: Effect.fn(function* (ctx) {
@@ -46,7 +42,6 @@ export const Plugin = define({
const fs = yield* FSUtil.Service
const location = yield* Location.Service
const npm = yield* Npm.Service
const active = new Set<string>()
const load = Effect.fn("ConfigExternalPlugin.load")(function* () {
const configured: { package: string; options?: Record<string, unknown> }[] = []
@@ -110,7 +105,7 @@ export const Plugin = define({
: (yield* npm.add(ref.package)).entrypoint
if (!entrypoint) return
yield* Effect.log({ msg: "loading plugin", id: ref.package, entrypoint })
const mod = yield* Effect.promise(() => import(cacheBust(entrypoint)))
const mod = yield* Effect.promise(() => import(entrypoint))
const value = (yield* Schema.decodeUnknownEffect(PluginModule)(mod)).default
const plugin = "effect" in value ? value : PluginPromise.fromPromise(value)
return {
@@ -118,40 +113,13 @@ export const Plugin = define({
effect: (host: Parameters<typeof plugin.effect>[0]) =>
plugin.effect({ ...host, options: ref.options ?? {} }),
}
}).pipe(
Effect.catchCause((cause) =>
Effect.logError("failed to load plugin", { package: ref.package, cause }).pipe(Effect.as(undefined)),
),
),
}).pipe(Effect.catchCause(() => Effect.succeed(undefined))),
).pipe(Effect.map((plugins) => plugins.filter((plugin) => plugin !== undefined)))
})
const reconcile = Effect.fn("ConfigExternalPlugin.reconcile")(function* () {
const plugins = yield* load()
const next = new Set(plugins.map((plugin) => plugin.id))
for (const id of active) {
if (!next.has(id)) yield* ctx.plugin.remove(id)
}
for (const plugin of plugins) yield* ctx.plugin.add(plugin)
active.clear()
for (const id of next) active.add(id)
})
yield* reconcile()
yield* ctx.event.subscribe().pipe(
Stream.filter((event) => event.type === "config.updated"),
Stream.runForEach(() => reconcile()),
Effect.forkScoped({ startImmediately: true }),
)
for (const plugin of yield* load()) yield* ctx.plugin.add(plugin)
}),
})
function cacheBust(entrypoint: string) {
const url = path.isAbsolute(entrypoint) ? pathToFileURL(entrypoint) : new URL(entrypoint)
if (url.protocol === "file:") delete moduleCache[fileURLToPath(url)]
url.searchParams.set("opencode-reload", String(++importGeneration))
return url.href
}
const resolvePackageEntrypoint = Effect.fnUntraced(function* (fs: FSUtil.Interface, directory: string) {
const pkg = yield* fs.readJson(path.join(directory, "package.json")).pipe(
Effect.flatMap(Schema.decodeUnknownEffect(PluginPackage)),
+1 -1
View File
@@ -45,7 +45,7 @@ const FILES = [
"**/.nyc_output/**",
]
export const PATTERNS = [...FILES, ...FOLDERS]
export const PATTERNS = [...FILES, ...FOLDERS, `**/{${Array.from(FOLDERS).join(",")}}/**`]
export function match(filepath: string, opts?: { extra?: string[]; whitelist?: string[] }) {
for (const pattern of opts?.whitelist || []) {
+1 -1
View File
@@ -302,7 +302,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
}),
},
tool: {
register: (input) => tools.register(input),
register: (input, options) => tools.register(input, options),
execute: {
before: (callback) =>
toolHooks.hook.before((event) => {
+3 -3
View File
@@ -540,7 +540,8 @@ const layer = Layer.effect(
const started = yield* Effect.gen(function* () {
const shell = yield* Shell.Service
return yield* shell.create({ command: input.command, cwd: session.location.directory })
}).pipe(Effect.provide(locations.get(session.location)))
})
.pipe(Effect.provide(locations.get(session.location)))
yield* events.publish(
SessionEvent.Shell.Started,
{
@@ -563,8 +564,7 @@ const layer = Layer.effect(
.pipe(Effect.catchTag("Shell.NotFoundError", () => Effect.succeed(missingShellOutput())))
: missingShellOutput()
return { shell: terminal.info, output }
})
.pipe(Effect.provide(locations.get(session.location)))
}).pipe(Effect.provide(locations.get(session.location)))
yield* events.publish(SessionEvent.Shell.Ended, {
sessionID: input.sessionID,
shell: completed.shell,
+23 -9
View File
@@ -8,6 +8,7 @@ export type MemoryState = {
}
export interface Adapter {
readonly getModel: () => Effect.Effect<SessionMessage.ModelSelected["model"] | undefined, never, never>
readonly getCurrentAssistant: () => Effect.Effect<SessionMessage.Assistant | undefined, never, never>
readonly getAssistant: (
messageID: SessionMessage.ID,
@@ -29,6 +30,15 @@ export function memory(state: MemoryState): Adapter {
const latestAssistantIndex = () => state.messages.findLastIndex((message) => message.type === "assistant")
return {
getModel() {
return Effect.sync(
() =>
state.messages.findLast(
(message): message is SessionMessage.ModelSelected | SessionMessage.Assistant =>
message.type === "model-switched" || message.type === "assistant",
)?.model,
)
},
getCurrentAssistant() {
return Effect.sync(() => {
const index = latestAssistantIndex()
@@ -115,15 +125,19 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
)
},
"session.model.selected": (event) => {
return adapter.appendMessage(
SessionMessage.ModelSelected.make({
id: SessionMessage.ID.fromEvent(event.id),
type: "model-switched",
metadata: event.metadata,
model: event.data.model,
time: { created: event.created },
}),
)
return Effect.gen(function* () {
const previous = yield* adapter.getModel()
yield* adapter.appendMessage(
SessionMessage.ModelSelected.make({
id: SessionMessage.ID.fromEvent(event.id),
type: "model-switched",
metadata: event.metadata,
model: event.data.model,
previous,
time: { created: event.created },
}),
)
})
},
"session.moved": () => Effect.void,
"session.renamed": () => Effect.void,
+13 -1
View File
@@ -5,6 +5,7 @@ import { DateTime, Effect, Layer, Schema } from "effect"
import { Database } from "../database/database"
import { EventV2 } from "../event"
import { makeGlobalNode } from "../effect/app-node"
import { ModelV2 } from "../model"
import { SessionEvent } from "./event"
import { SessionV1 } from "../v1/session"
import { WorkspaceTable } from "../control-plane/workspace.sql"
@@ -356,6 +357,17 @@ function run(db: DatabaseService, event: MessageEvent) {
}
const appendMessage = (message: SessionMessage.Message) => insertMessage(db, event, message)
const adapter: SessionMessageUpdater.Adapter = {
getModel() {
return db
.select({ model: SessionTable.model })
.from(SessionTable)
.where(eq(SessionTable.id, event.data.sessionID))
.get()
.pipe(
Effect.orDie,
Effect.map((row) => (row?.model ? Schema.decodeUnknownSync(ModelV2.Ref)(row.model) : undefined)),
)
},
getCurrentAssistant() {
return Effect.gen(function* () {
// A newer step supersedes stale incomplete rows; never resume an older assistant projection.
@@ -570,13 +582,13 @@ const layer = Layer.effectDiscard(
)
yield* events.project(SessionEvent.ModelSelected, (event) =>
Effect.gen(function* () {
yield* run(db, event)
yield* db
.update(SessionTable)
.set({ model: event.data.model, time_updated: DateTime.toEpochMillis(event.created) })
.where(eq(SessionTable.id, event.data.sessionID))
.run()
.pipe(Effect.orDie)
yield* run(db, event)
}),
)
yield* events.project(SessionEvent.Renamed, (event) =>
+2 -1
View File
@@ -28,7 +28,8 @@ Leaves own resolution, permission, and side-effect ordering. Translate only expe
## Registration
Built-ins and plugin tools register through `Tools.Service.register({ [name]: tool })`.
Built-ins and plugin tools register through `Tools.Service.register({ [name]: tool })`. Registrations may provide a
group, which flattens direct model names to `<group>_<tool>`, and may be deferred from direct model exposure.
Registrations are scoped:
+14 -1
View File
@@ -5,6 +5,7 @@ import type { PluginContext } from "@opencode-ai/plugin/v2/effect"
import { Effect, Schema } from "effect"
import path from "path"
import { FileSystem } from "../filesystem"
import { FSUtil } from "../fs-util"
import { Location } from "../location"
import { Ripgrep } from "../ripgrep"
import { RelativePath } from "../schema"
@@ -36,6 +37,7 @@ export const toModelOutput = (output: ModelOutput) => {
export const Plugin = {
id: "core-glob-tool",
effect: Effect.fn("GlobTool.Plugin")(function* (ctx: PluginContext) {
const fs = yield* FSUtil.Service
const ripgrep = yield* Ripgrep.Service
const location = yield* Location.Service
const permission = yield* PermissionV2.Service
@@ -71,6 +73,13 @@ export const Plugin = {
source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID },
})
const cwd = path.resolve(location.directory, input.path ?? ".")
yield* fs
.stat(cwd)
.pipe(
Effect.catchReason("PlatformError", "NotFound", () =>
Effect.fail(new ToolFailure({ message: `Search path does not exist: ${input.path ?? "."}` })),
),
)
return yield* ripgrep
.glob({
cwd,
@@ -88,7 +97,11 @@ export const Plugin = {
),
)
}).pipe(
Effect.mapError(() => new ToolFailure({ message: `Unable to find files matching ${input.pattern}` })),
Effect.mapError((error) =>
error instanceof ToolFailure
? error
: new ToolFailure({ message: `Unable to find files matching ${input.pattern}` }),
),
),
}),
})
+14 -2
View File
@@ -91,7 +91,13 @@ export const Plugin = {
source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID },
})
const target = path.resolve(location.directory, input.path ?? ".")
const info = yield* fs.stat(target).pipe(Effect.catch(() => Effect.succeed(undefined)))
const info = yield* fs
.stat(target)
.pipe(
Effect.catchReason("PlatformError", "NotFound", () =>
Effect.fail(new ToolFailure({ message: `Search path does not exist: ${input.path ?? "."}` })),
),
)
return yield* ripgrep
.grep({
cwd: info?.type === "Directory" ? target : path.dirname(target),
@@ -121,7 +127,13 @@ export const Plugin = {
),
),
)
}).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to grep for ${input.pattern}` }))),
}).pipe(
Effect.mapError((error) =>
error instanceof ToolFailure
? error
: new ToolFailure({ message: `Unable to grep for ${input.pattern}` }),
),
),
}),
})
.pipe(Effect.orDie)
+55 -56
View File
@@ -1,6 +1,5 @@
export * as McpTool from "./mcp"
import { createHash } from "node:crypto"
import { ToolFailure } from "@opencode-ai/llm"
import { McpEvent } from "@opencode-ai/schema/mcp-event"
import { Effect, Exit, type JsonSchema, Layer, Scope, Semaphore, Stream } from "effect"
@@ -11,35 +10,11 @@ import { Tool } from "./tool"
import { Tools } from "./tools"
import { ToolRegistry } from "./registry"
const MAX_NAME_LENGTH = 64
const HASH_LENGTH = 8
const sanitize = (value: string) => value.replace(/[^A-Za-z0-9_-]/g, "_")
// Deterministic short suffix used to keep overlong or colliding names unique and stable across restarts.
const hashSuffix = (raw: string) => "_" + createHash("sha1").update(raw).digest("hex").slice(0, HASH_LENGTH)
const fit = (base: string, raw: string) => base.slice(0, MAX_NAME_LENGTH - HASH_LENGTH - 1) + hashSuffix(raw)
/**
* Registry/permission action name for an MCP tool: V1-compatible `<server>_<tool>` so existing deny
* rules keep working. Sanitized to a valid tool name, prefixed when it would not start with a letter,
* and hashed down when it would exceed the 64-char limit.
* Registry and permission action name for an MCP tool.
*/
export const name = (server: string, tool: string) => {
const joined = sanitize(server) + "_" + sanitize(tool)
const base = /^[A-Za-z]/.test(joined) ? joined : "mcp_" + joined
return base.length > MAX_NAME_LENGTH ? fit(base, `${server}\u0000${tool}`) : base
}
const toContent = (part: MCP.ToolResultContent): Tool.Content =>
part.type === "text" ? { type: "text", text: part.text } : { type: "file", data: part.data, mime: part.mimeType }
const errorText = (content: ReadonlyArray<MCP.ToolResultContent>) =>
content
.flatMap((part) => (part.type === "text" ? [part.text] : []))
.join("\n")
.trim()
export const name = (server: string, tool: string) =>
`${server.replace(/[^a-zA-Z0-9_-]/g, "_")}_${tool.replace(/[^a-zA-Z0-9_-]/g, "_")}`
export const layer = Layer.effectDiscard(
Effect.gen(function* () {
@@ -50,47 +25,71 @@ export const layer = Layer.effectDiscard(
const lock = Semaphore.makeUnsafe(1)
let current: Scope.Closeable | undefined
const make = (server: MCP.ServerName, tool: MCP.Tool) =>
Tool.make({
description: tool.description ?? "",
jsonSchema: (tool.inputSchema as JsonSchema.JsonSchema | undefined) ?? { type: "object", properties: {} },
execute: (input) =>
Effect.gen(function* () {
const result = yield* mcp.callTool({ server, name: tool.name, args: (input ?? {}) as Record<string, unknown> }).pipe(
Effect.catchTags({
"MCP.NotFoundError": (error) => new ToolFailure({ message: `MCP server "${error.server}" is not available` }),
"MCP.ToolCallError": (error) => new ToolFailure({ message: error.message }),
}),
)
if (result.isError)
return yield* new ToolFailure({ message: errorText(result.content) || "MCP tool returned an error" })
return { structured: result.structured ?? {}, content: result.content.map(toContent) }
}),
})
// Register the current tool set under a fresh child scope, then close the previous one so the
// registry never has a gap where MCP tools disappear mid-swap.
const reconcile = lock.withPermit(
Effect.gen(function* () {
const used = new Set<string>()
const record: Record<string, Tool.AnyTool> = {}
const groups = new Map<string, Record<string, Tool.AnyTool>>()
for (const tool of yield* mcp.tools()) {
const initial = name(tool.server, tool.name)
const key = used.has(initial) ? fit(initial, `${tool.server}\u0000${tool.name}`) : initial
used.add(key)
record[key] = make(tool.server, tool)
const group = groups.get(tool.server) ?? {}
const schema = (tool.inputSchema ?? {}) as JsonSchema.JsonSchema
group[tool.name] = Tool.make({
description: tool.description ?? "",
jsonSchema: {
...schema,
type: "object",
properties: schema.properties ?? {},
additionalProperties: false,
},
execute: (input) =>
Effect.gen(function* () {
const result = yield* mcp
.callTool({
server: tool.server,
name: tool.name,
args: (input ?? {}) as Record<string, unknown>,
})
.pipe(
Effect.catchTags({
"MCP.NotFoundError": (error) =>
new ToolFailure({ message: `MCP server "${error.server}" is not available` }),
"MCP.ToolCallError": (error) => new ToolFailure({ message: error.message }),
}),
)
if (result.isError)
return yield* new ToolFailure({
message:
result.content
.flatMap((part) => (part.type === "text" ? [part.text] : []))
.join("\n")
.trim() || "MCP tool returned an error",
})
return {
structured: result.structured ?? {},
content: result.content.map((part) =>
part.type === "text"
? { type: "text" as const, text: part.text }
: { type: "file" as const, data: part.data, mime: part.mimeType },
),
}
}),
})
groups.set(tool.server, group)
}
const next = yield* Scope.fork(scope)
yield* tools.register(record).pipe(Scope.provide(next), Effect.orDie)
yield* Effect.forEach(groups, ([group, record]) => tools.register(record, { group }), {
discard: true,
}).pipe(Scope.provide(next), Effect.orDie)
if (current) yield* Scope.close(current, Exit.void)
current = next
}),
)
yield* reconcile.pipe(Effect.forkScoped)
yield* events
.subscribe(McpEvent.ToolsChanged)
.pipe(Stream.runForEach(() => reconcile), Effect.forkScoped({ startImmediately: true }))
yield* events.subscribe(McpEvent.ToolsChanged).pipe(
Stream.runForEach(() => reconcile),
Effect.forkScoped({ startImmediately: true }),
)
}),
)
+52 -18
View File
@@ -23,7 +23,10 @@ export type ExecuteInput = {
export interface Interface {
readonly materialize: (input: MaterializeInput) => Effect.Effect<Materialization>
/** Internal registration capability exposed publicly only through Tools.Service. */
readonly register: (tools: Readonly<Record<string, AnyTool>>) => Effect.Effect<void, RegistrationError, Scope.Scope>
readonly register: (
tools: Readonly<Record<string, AnyTool>>,
options?: Tools.RegisterOptions,
) => Effect.Effect<void, RegistrationError, Scope.Scope>
}
export interface MaterializeInput {
@@ -49,7 +52,13 @@ const registryLayer = Layer.effect(
Effect.gen(function* () {
const resources = yield* ToolOutputStore.Service
const toolHooks = yield* ToolHooks.Service
type Registration = { readonly identity: object; readonly tool: AnyTool }
type Registration = {
readonly identity: object
readonly tool: AnyTool
readonly name: string
readonly group?: string
readonly deferred: boolean
}
const local = new Map<string, Array<{ readonly token: object; readonly registration: Registration }>>()
const settleWith = Effect.fn("ToolRegistry.settle")(function* (input: ExecuteInput, advertised?: object) {
@@ -73,12 +82,16 @@ const registryLayer = Layer.effect(
input: input.call.input,
}
yield* toolHooks.runBefore(beforeEvent)
const pending = yield* settle(registration.tool, { ...input.call, input: beforeEvent.input }, {
sessionID: input.sessionID,
agent: input.agent,
assistantMessageID: input.assistantMessageID,
toolCallID: input.call.id,
}).pipe(
const pending = yield* settle(
registration.tool,
{ ...input.call, input: beforeEvent.input },
{
sessionID: input.sessionID,
agent: input.agent,
assistantMessageID: input.assistantMessageID,
toolCallID: input.call.id,
},
).pipe(
Effect.map((output) => ({ output })),
Effect.catchTag("LLM.ToolFailure", (failure) =>
Effect.succeed({ result: { type: "error" as const, value: failure.message } }),
@@ -88,7 +101,11 @@ const registryLayer = Layer.effect(
if ("result" in pending) {
settlement = pending
} else {
const bounded = yield* resources.bound({ sessionID: input.sessionID, toolCallID: input.call.id, output: pending.output })
const bounded = yield* resources.bound({
sessionID: input.sessionID,
toolCallID: input.call.id,
output: pending.output,
})
const result = ToolOutput.toResultValue(bounded.output)
settlement =
result.type === "error"
@@ -119,20 +136,33 @@ const registryLayer = Layer.effect(
})
return Service.of({
register: Effect.fn("ToolRegistry.register")(function* (tools) {
const entries = registrationEntries(tools)
register: Effect.fn("ToolRegistry.register")(function* (tools, options) {
const entries = registrationEntries(tools, options?.group)
if (entries.length === 0) return
yield* Effect.uninterruptible(
Effect.gen(function* () {
const token = {}
for (const [name, tool] of entries)
local.set(name, [...(local.get(name) ?? []), { token, registration: { identity: {}, tool } }])
for (const entry of entries)
local.set(entry.key, [
...(local.get(entry.key) ?? []),
{
token,
registration: {
identity: {},
tool: entry.tool,
name: entry.name,
group: entry.group,
deferred: options?.deferred ?? false,
},
},
])
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
for (const [name] of entries) {
const registrations = local.get(name)?.filter((registration) => registration.token !== token) ?? []
if (registrations.length > 0) local.set(name, registrations)
else local.delete(name)
for (const entry of entries) {
const registrations =
local.get(entry.key)?.filter((registration) => registration.token !== token) ?? []
if (registrations.length > 0) local.set(entry.key, registrations)
else local.delete(entry.key)
}
}),
)
@@ -149,7 +179,11 @@ const registryLayer = Layer.effect(
const usePatch = input.model.provider.toLowerCase() === "openai" || input.model.id.toLowerCase().includes("gpt")
for (const [name, registration] of registrations) {
const wrongEditTool = name === "apply_patch" ? !usePatch : (name === "edit" || name === "write") && usePatch
if (wrongEditTool || whollyDisabled(permission(registration.tool, name), input.permissions ?? []))
if (
registration.deferred ||
wrongEditTool ||
whollyDisabled(permission(registration.tool, name), input.permissions ?? [])
)
registrations.delete(name)
}
return {
+5 -3
View File
@@ -18,8 +18,9 @@ export const DEFAULT_TIMEOUT_MS = 2 * 60 * 1_000
export const MAX_TIMEOUT_MS = 10 * 60 * 1_000
export const MAX_CAPTURE_BYTES = 1024 * 1024
const BACKGROUND_STARTED =
"The command has not completed; it is now running in the background."
const BACKGROUND_STARTED = "The command was moved to the background."
const BACKGROUND_INSTRUCTION =
"You will be notified automatically when the command finishes. DO NOT sleep, poll, or proactively check on its progress."
export const Input = Schema.Struct({
command: Schema.String.annotate({ description: "Shell command string to execute" }),
@@ -54,10 +55,11 @@ const Output = Schema.Struct({
type Output = typeof Output.Type
const modelOutput = (output: Output): string | undefined => {
if (output.status === "running") return undefined
const warnings = output.warnings?.length
? `\n\nWarnings:\n${output.warnings.map((warning) => `- ${warning}`).join("\n")}`
: ""
if (output.status === "running")
return `${warnings.trimStart()}${warnings ? "\n\n" : ""}${BACKGROUND_INSTRUCTION}`
if (output.timeout) return `${warnings.trimStart()}${warnings ? "\n\n" : ""}Command timed out before completion.`
return `${warnings.trimStart()}${warnings ? "\n\n" : ""}Command exited with code ${output.exit}.`
}
+3
View File
@@ -3,9 +3,12 @@ export * as Tools from "./tools"
import { Context, Effect, Scope } from "effect"
import { Tool } from "./tool"
export type RegisterOptions = Tool.RegisterOptions
export interface Interface {
readonly register: (
tools: Readonly<Record<string, Tool.AnyTool>>,
options?: Tool.RegisterOptions,
) => Effect.Effect<void, Tool.RegistrationError, Scope.Scope>
}
-73
View File
@@ -1,19 +1,15 @@
import fs from "fs/promises"
import path from "path"
import { describe, expect } from "bun:test"
import { Config as ConfigSchema } from "@opencode-ai/schema/config"
import { Effect, Schema } from "effect"
import { AgentV2 } from "@opencode-ai/core/agent"
import { Config } from "@opencode-ai/core/config"
import { ConfigExternalPlugin } from "@opencode-ai/core/config/plugin/external"
import { EventV2 } from "@opencode-ai/core/event"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Location } from "@opencode-ai/core/location"
import { Npm } from "@opencode-ai/core/npm"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { tmpdir } from "../fixture/tmpdir"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "../plugin/fixture"
@@ -244,49 +240,6 @@ describe("ConfigExternalPlugin", () => {
})
}),
)
it.live("reloads changed plugin source from the same entrypoint", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const plugins = yield* PluginV2.Service
const agents = yield* AgentV2.Service
const events = yield* EventV2.Service
const fsUtil = yield* FSUtil.Service
const location = yield* Location.Service
const npm = yield* Npm.Service
const host = yield* PluginHost.make(plugins)
const plugin = path.join(tmp.path, "plugin.ts")
const config = Config.Service.of({
entries: () =>
Effect.succeed([
new Config.Document({
type: "document",
info: decode({ plugins: [plugin] }),
}),
]),
})
yield* Effect.promise(() => fs.writeFile(plugin, pluginSource("First source")))
yield* ConfigExternalPlugin.Plugin.effect(host).pipe(
Effect.provideService(PluginV2.Service, plugins),
Effect.provideService(FSUtil.Service, fsUtil),
Effect.provideService(Location.Service, location),
Effect.provideService(Npm.Service, npm),
Effect.provideService(Config.Service, config),
)
expect((yield* waitForAgent(agents, "hot-reload"))?.description).toBe("First source")
yield* Effect.promise(() => fs.writeFile(plugin, pluginSource("Second source")))
yield* events.publish(ConfigSchema.Event.Updated, {})
expect(yield* waitForAgentDescription(agents, "hot-reload", "Second source")).toBe(true)
}),
),
),
)
})
const waitForAgent = Effect.fnUntraced(function* (agents: AgentV2.Interface, id: string) {
@@ -297,29 +250,3 @@ const waitForAgent = Effect.fnUntraced(function* (agents: AgentV2.Interface, id:
}
return yield* Effect.die(`Timed out waiting for agent ${id}`)
})
const waitForAgentDescription = Effect.fnUntraced(function* (
agents: AgentV2.Interface,
id: string,
description: string,
) {
for (let attempt = 0; attempt < 100; attempt++) {
if ((yield* agents.get(AgentV2.ID.make(id)))?.description === description) return true
yield* Effect.sleep("10 millis")
}
return false
})
function pluginSource(description: string) {
return `export default {
id: "source-hot-reload",
setup: async (ctx) => {
await ctx.agent.transform((agents) => {
agents.update("hot-reload", (agent) => {
agent.description = ${JSON.stringify(description)}
agent.mode = "subagent"
})
})
},
}`
}
+3 -7
View File
@@ -27,7 +27,7 @@ const decode = Schema.decodeUnknownSync(Config.Info)
const document = path.join(import.meta.dir, "opencode.json")
describe("config plugin reloads", () => {
it.live("reloads every config-backed domain", () =>
it.live("reloads config-backed domains without reloading external plugins", () =>
Effect.gen(function* () {
const agents = yield* AgentV2.Service
const catalog = yield* Catalog.Service
@@ -69,8 +69,7 @@ describe("config plugin reloads", () => {
(yield* commands.get("second"))?.description === "Second command" &&
(yield* references.list()).some((reference) => reference.name === "second") &&
(yield* catalog.provider.get(ProviderV2.ID.make("first"))) === undefined &&
(yield* catalog.provider.get(ProviderV2.ID.make("second"))) !== undefined &&
(yield* agents.get(AgentV2.ID.make("configured")))?.description === "Second plugin"
(yield* catalog.provider.get(ProviderV2.ID.make("second"))) !== undefined
)
}),
)
@@ -81,10 +80,7 @@ describe("config plugin reloads", () => {
expect(
(yield* skills.sources()).some((source) => source.type === "directory" && source.path === "/skills/second"),
).toBe(true)
entries = [config("second")]
yield* events.publish(ConfigSchema.Event.Updated, {})
yield* waitUntil(agents.get(AgentV2.ID.make("configured")).pipe(Effect.map((agent) => agent === undefined)))
expect((yield* agents.get(AgentV2.ID.make("configured")))?.description).toBe("First plugin")
}).pipe(Effect.provideService(Global.Service, Global.Service.of(Global.make()))),
)
})
@@ -1,5 +1,7 @@
import { expect, test } from "bun:test"
import { Ignore } from "@opencode-ai/core/filesystem/ignore"
// @ts-ignore
import { createWrapper } from "@parcel/watcher/wrapper"
test("match nested and non-nested", () => {
expect(Ignore.match("node_modules/index.js")).toBe(true)
@@ -8,3 +10,30 @@ test("match nested and non-nested", () => {
expect(Ignore.match("node_modules/bar")).toBe(true)
expect(Ignore.match("node_modules/bar/")).toBe(true)
})
test("parcel patterns ignore built-in folders at any depth", async () => {
let ignoreGlobs: string[] = []
const watcher = createWrapper({
subscribe: async (
_directory: string,
_callback: (...args: unknown[]) => unknown,
options: { ignoreGlobs?: string[] },
) => {
ignoreGlobs = options.ignoreGlobs ?? []
},
})
await watcher.subscribe("/tmp/project", () => {}, { ignore: Ignore.PATTERNS })
const patterns = ignoreGlobs.map((source) => new RegExp(source))
for (const path of [
"nested/node_modules",
"nested/node_modules/package/index.js",
"nested/.git",
"nested/.git/HEAD",
"nested/dist",
"nested/dist/index.js",
]) {
expect(patterns.some((pattern) => pattern.test(path))).toBe(true)
}
expect(patterns.some((pattern) => pattern.test("nested/src/index.ts"))).toBe(false)
})
+25 -5
View File
@@ -2,7 +2,7 @@ import { $ } from "bun"
import { describe, expect } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { Deferred, Duration, Effect, Fiber, Layer, Option, Stream } from "effect"
import { Deferred, Duration, Effect, Fiber, Layer, Option, Schedule, Stream } from "effect"
import { Config } from "@opencode-ai/core/config"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
@@ -149,12 +149,14 @@ describeWatcher("LocationWatcher", () => {
const update = yield* watcher
.subscribe({ path: target, type: "file" })
.pipe(Stream.take(1), Stream.runHead, Effect.forkScoped({ startImmediately: true }))
yield* Effect.yieldNow
yield* fs.writeFileString(sibling, "sibling")
yield* fs.writeFileString(target, "target")
const writes = yield* Effect.suspend(() => fs.writeFileString(target, `target-${Math.random()}`)).pipe(
Effect.repeat(Schedule.spaced("10 millis")),
Effect.forkScoped,
)
const event = yield* Fiber.join(update).pipe(Effect.ensuring(Fiber.interrupt(writes)))
expect((yield* Fiber.join(update)).valueOrUndefined?.path).toBe(target)
expect(event.valueOrUndefined?.path).toBe(target)
}).pipe(Effect.provide(AppNodeBuilder.build(Watcher.node))),
),
)
@@ -197,6 +199,24 @@ describeWatcher("LocationWatcher", () => {
),
)
it.live("ignores dependency, VCS, and build directories at any depth", () =>
withTmp((directory) =>
Effect.gen(function* () {
const afs = yield* FSUtil.Service
yield* ready(directory)
const roots = ["node_modules", ".git", "dist"].map((name) => path.join(directory, "nested", name))
const files = roots.map((root) => path.join(root, "package", "index.js"))
yield* noUpdate(
(event) => roots.some((root) => event.file === root || event.file.startsWith(`${root}${path.sep}`)),
Effect.forEach(files, (file) => afs.writeWithDirs(file, "ignored"), {
concurrency: "unbounded",
discard: true,
}),
)
}),
),
)
it.live("cleanup stops publishing events", () =>
Effect.gen(function* () {
const events = yield* EventV2.Service
+5
View File
@@ -1,6 +1,7 @@
import { describe, expect, test } from "bun:test"
import { MCP } from "@opencode-ai/core/mcp/index"
import { MCPClient } from "@opencode-ai/core/mcp/client"
import { McpTool } from "@opencode-ai/core/tool/mcp"
describe("MCP errors", () => {
test("expose useful messages", () => {
@@ -12,3 +13,7 @@ describe("MCP errors", () => {
expect(new MCPClient.ConnectError({ server: "demo", message: "offline" }).message).toBe("offline")
})
})
test("MCP tool names match V1 sanitization", () => {
expect(McpTool.name("context 7", "resolve.library/id")).toBe("context_7_resolve_library_id")
})
+32
View File
@@ -126,6 +126,38 @@ describe("PluginV2", () => {
}),
)
it.effect("groups tool names and defers registrations from direct exposure", () =>
Effect.gen(function* () {
const plugins = yield* PluginV2.Service
const registry = yield* ToolRegistry.Service
const tool = (description: string) =>
Tool.make({
description,
input: Schema.Struct({}),
output: Schema.Struct({ ok: Schema.Boolean }),
execute: () => Effect.succeed({ ok: true }),
})
const plugin = define({
id: "grouped-tools",
effect: (ctx) =>
Effect.gen(function* () {
yield* ctx.tool.register({ plain: tool("Plain") }).pipe(Effect.orDie)
yield* ctx.tool.register({ "look/up": tool("Lookup") }, { group: "context 7" }).pipe(Effect.orDie)
yield* ctx.tool
.register({ search: tool("Search") }, { group: "context 7", deferred: true })
.pipe(Effect.orDie)
}),
})
yield* plugins.add(PluginV2.ID.make(plugin.id), plugin.effect)
expect((yield* registry.materialize({ model: testModel })).definitions.map((tool) => tool.name)).toEqual([
"plain",
"context_7_look_up",
])
}),
)
it.effect("fires before/after tool hooks with mutable events around settlement", () =>
Effect.gen(function* () {
const plugins = yield* PluginV2.Service
+3 -3
View File
@@ -562,9 +562,9 @@ describe("SessionV2.create", () => {
yield* session.switchModel({ sessionID: created.id, model })
expect(yield* session.get(created.id)).toMatchObject({ model })
expect(
Array.from(yield* logEvents(session, created.id, true).pipe(Stream.take(1), Stream.runCollect)),
).toMatchObject([{ type: "session.model.selected", data: { model } }])
const events = Array.from(yield* logEvents(session, created.id, true).pipe(Stream.take(1), Stream.runCollect))
expect(events).toMatchObject([{ type: "session.model.selected" }])
expect(events[0]?.data).toEqual({ sessionID: created.id, model })
}),
)
@@ -34,6 +34,7 @@ const sessionsLayer = AppNodeBuilder.build(SessionV2.node, [[SessionExecution.no
const sessionID = SessionV2.ID.make("ses_projector_test")
const created = DateTime.makeUnsafe(0)
const model = { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") }
const previousModel = { ...model, variant: ModelV2.VariantID.make("medium") }
const encodeMessage = Schema.encodeSync(SessionMessage.Message)
const assistantRow = (
@@ -238,6 +239,7 @@ describe("SessionProjector", () => {
directory: "/project",
title: "test",
version: "test",
model: previousModel,
})
.run()
.pipe(Effect.orDie)
@@ -337,6 +339,7 @@ describe("SessionProjector", () => {
text: "synthetic context",
metadata: { source: "projector-test" },
})
expect(messages.find((message) => message.type === "model-switched")).toMatchObject({ previous: previousModel })
expect(messages.find((message) => message.type === "shell")).toMatchObject({
shell: { command: "pwd", status: "exited", exit: 0 },
output: { output: "/project", truncated: false },
+87
View File
@@ -0,0 +1,87 @@
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { makeLocationNode } from "@opencode-ai/core/effect/app-node"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Location } from "@opencode-ai/core/location"
import { PermissionV2 } from "@opencode-ai/core/permission"
import { Ripgrep } from "@opencode-ai/core/ripgrep"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionV2 } from "@opencode-ai/core/session"
import { GlobTool } from "@opencode-ai/core/tool/glob"
import { GrepTool } from "@opencode-ai/core/tool/grep"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { location } from "./fixture/location"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
import { executeTool, registerToolPlugin, toolIdentity } from "./lib/tool"
const globToolNode = makeLocationNode({
name: "test/glob-tool-plugin",
layer: Layer.effectDiscard(registerToolPlugin(GlobTool.Plugin)),
deps: [ToolRegistry.toolsNode, FSUtil.node, Ripgrep.node, Location.node, PermissionV2.node],
})
const grepToolNode = makeLocationNode({
name: "test/grep-tool-plugin",
layer: Layer.effectDiscard(registerToolPlugin(GrepTool.Plugin)),
deps: [ToolRegistry.toolsNode, FSUtil.node, Ripgrep.node, Location.node, PermissionV2.node],
})
const permission = Layer.succeed(
PermissionV2.Service,
PermissionV2.Service.of({
assert: () => Effect.void,
ask: () => Effect.die("unused"),
reply: () => Effect.die("unused"),
get: () => Effect.die("unused"),
forSession: () => Effect.die("unused"),
list: () => Effect.die("unused"),
}),
)
const sessionID = SessionV2.ID.make("ses_search_tool_test")
const withTools = <A, E, R>(directory: string, body: (registry: ToolRegistry.Interface) => Effect.Effect<A, E, R>) =>
Effect.gen(function* () {
return yield* body(yield* ToolRegistry.Service)
}).pipe(
Effect.provide(
AppNodeBuilder.build(LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, globToolNode, grepToolNode]), [
[
Location.node,
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
],
[PermissionV2.node, permission],
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
]),
),
)
const call = (name: "glob" | "grep", input: unknown) => ({
sessionID,
...toolIdentity,
call: { type: "tool-call" as const, id: `call-${name}`, name, input },
})
const it = testEffect(Layer.empty)
describe("search tools", () => {
for (const name of ["glob", "grep"] as const) {
it.live(`${name} reports a missing search path`, () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) =>
withTools(tmp.path, (registry) =>
Effect.gen(function* () {
const result = yield* executeTool(
registry,
call(name, { path: "missing", pattern: name === "glob" ? "*" : "needle" }),
)
expect(result).toEqual({ type: "error", value: "Search path does not exist: missing" })
}),
),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
)
}
})
+11 -5
View File
@@ -171,9 +171,11 @@ const withSession = <A, E, R>(directory: string, body: (registry: ToolRegistry.I
})
const locations = yield* LocationServiceMap.Service
const locationLayer = locations.get(location)
const registry = yield* ToolRegistry.Service.pipe(Effect.provide(locationLayer))
yield* waitForTool(registry, ShellTool.name)
return yield* body(registry).pipe(Effect.provide(locationLayer))
return yield* Effect.gen(function* () {
const registry = yield* ToolRegistry.Service
yield* waitForTool(registry, ShellTool.name)
return yield* body(registry)
}).pipe(Effect.provide(locationLayer), Effect.ensuring(locations.invalidate(location)))
})
describe("ShellTool", () => {
@@ -478,9 +480,13 @@ describe("ShellTool", () => {
const structured = settled.output?.structured as Record<string, unknown> | undefined
const shellID = typeof structured?.shellID === "string" ? structured.shellID : undefined
expect(settled.output?.structured).toMatchObject({ truncated: false })
expect(settled.output?.content[0]).toMatchObject({
expect(settled.output?.content[0]).toEqual({
type: "text",
text: expect.stringContaining("running in the background"),
text: "The command was moved to the background.",
})
expect(settled.output?.content[1]).toMatchObject({
type: "text",
text: expect.stringContaining("DO NOT sleep, poll"),
})
expect(shellID).toStartWith("sh_")
+20 -3
View File
@@ -186,8 +186,17 @@ export const validateName = (name: string) =>
? Effect.void
: Effect.fail(new RegistrationError({ name, message: `Invalid tool name: ${name}` }))
export const registrationEntries = (tools: Readonly<Record<string, AnyTool>>) =>
Object.entries(tools).map(([name, tool]) => [name.replace(/[^a-zA-Z0-9_-]/g, "_"), tool] as const)
export const registrationEntries = (tools: Readonly<Record<string, AnyTool>>, group?: string) =>
Object.entries(tools).map(([name, tool]) => {
const normalized = name.replace(/[^a-zA-Z0-9_-]/g, "_")
const parent = group?.replace(/[^a-zA-Z0-9_-]/g, "_")
return {
key: parent === undefined ? normalized : `${parent}_${normalized}`,
name: normalized,
group: parent,
tool,
}
})
export const withPermission = <Input extends SchemaType<any>, Output extends SchemaType<any>>(
tool: Definition<Input, Output>,
@@ -235,7 +244,15 @@ export interface ToolExecuteAfterEvent {
outputPaths?: ReadonlyArray<string>
}
export interface RegisterOptions {
readonly group?: string
readonly deferred?: boolean
}
export interface ToolDomain {
readonly register: (tools: Readonly<Record<string, AnyTool>>) => Effect.Effect<void, RegistrationError, Scope.Scope>
readonly register: (
tools: Readonly<Record<string, AnyTool>>,
options?: RegisterOptions,
) => Effect.Effect<void, RegistrationError, Scope.Scope>
readonly execute: Hooks<{ before: ToolExecuteBeforeEvent; after: ToolExecuteAfterEvent }>
}
+1
View File
@@ -44,6 +44,7 @@ export const ModelSelected = Schema.Struct({
...Base,
type: Schema.Literal("model-switched"),
model: Model.Ref,
previous: Model.Ref.pipe(optional),
}).annotate({ identifier: "Session.Message.ModelSelected" })
export interface User extends Schema.Schema.Type<typeof User> {}
+2
View File
@@ -4310,6 +4310,7 @@ export type SessionMessageModelSelected = {
}
type: "model-switched"
model: ModelRef
previous?: ModelRef
}
export type SessionMessageUser = {
@@ -7985,6 +7986,7 @@ export type SessionMessageModelSelected2 = {
}
type: "model-switched"
model: ModelRef2
previous?: ModelRef2
}
export type SessionMessageUser2 = {
+56 -23
View File
@@ -20,7 +20,7 @@ import type {
SkillV2Info,
V2Event,
} from "@opencode-ai/sdk/v2"
import { createStore, produce } from "solid-js/store"
import { createStore, produce, reconcile } from "solid-js/store"
import { createSimpleContext } from "./helper"
import { useSDK } from "./sdk"
import { createSignal, onCleanup } from "solid-js"
@@ -100,8 +100,15 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
directory: process.cwd(),
})
const messageIndex = new Map<string, Map<string, number>>()
let connectionGeneration = 0
let statusChanges: Set<string> | undefined
let bootstrapping: Promise<void> | undefined
function setSessionStatus(sessionID: string, status: DataSessionStatus) {
statusChanges?.add(sessionID)
setStore("session", "status", sessionID, status)
}
const message = {
update(sessionID: string, fn: (messages: SessionMessage[], index: Map<string, number>) => void) {
setStore(
@@ -235,6 +242,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
case "session.model.selected":
if (store.session.info[event.data.sessionID])
setStore("session", "info", event.data.sessionID, "model", event.data.model)
if (!store.session.message[event.data.sessionID]) break
message.update(event.data.sessionID, (draft, index) => {
message.append(draft, index, {
id: messageIDFromEvent(event.id),
@@ -243,22 +251,35 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
time: { created: event.created },
})
})
void sdk.api.session
.message({ sessionID: event.data.sessionID, messageID: messageIDFromEvent(event.id) })
.then((item) => {
message.update(event.data.sessionID, (draft, index) => {
const position = index.get(item.id)
if (position === undefined) return message.append(draft, index, mutable(item))
draft[position] = mutable(item)
})
})
.catch((error) => console.error("Failed to load projected model switch message", error))
break
case "session.renamed":
if (store.session.info[event.data.sessionID])
setStore("session", "info", event.data.sessionID, "title", event.data.title)
break
case "session.prompt.promoted": {
setStore("session", "status", event.data.sessionID, "running")
setSessionStatus(event.data.sessionID, "running")
message.update(event.data.sessionID, (draft, index) => {
const position = index.get(event.data.inputID)
const existing = position === undefined ? undefined : draft[position]
if (existing?.type === "user") {
if (position === undefined) return
const existing = draft[position]
if (existing?.type === "user" && existing.metadata?.queued === true) {
existing.time.created = event.created
if (existing.metadata?.queued === true) {
delete existing.metadata.queued
if (Object.keys(existing.metadata).length === 0) existing.metadata = undefined
}
delete existing.metadata.queued
if (Object.keys(existing.metadata).length === 0) existing.metadata = undefined
draft.splice(position, 1)
draft.push(existing)
index.clear()
draft.forEach((message, indexValue) => index.set(message.id, indexValue))
return
}
})
@@ -300,7 +321,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
})
break
case "session.shell.started":
setStore("session", "status", event.data.sessionID, "running")
setSessionStatus(event.data.sessionID, "running")
message.update(event.data.sessionID, (draft, index) => {
message.append(draft, index, {
id: messageIDFromEvent(event.id),
@@ -311,7 +332,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
})
break
case "session.shell.ended":
setStore("session", "status", event.data.sessionID, "idle")
setSessionStatus(event.data.sessionID, "idle")
message.update(event.data.sessionID, (draft) => {
const match = message.shell(draft, event.data.shell.id)
if (!match) return
@@ -321,7 +342,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
})
break
case "session.step.started":
setStore("session", "status", event.data.sessionID, "running")
setSessionStatus(event.data.sessionID, "running")
message.update(event.data.sessionID, (draft, index) => {
if (index.has(event.data.assistantMessageID)) return
const currentAssistant = message.activeAssistant(draft)
@@ -338,7 +359,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
})
break
case "session.step.ended":
setStore("session", "status", event.data.sessionID, "running")
setSessionStatus(event.data.sessionID, "running")
message.update(event.data.sessionID, (draft, index) => {
const currentAssistant = message.assistant(draft, index, event.data.assistantMessageID)
if (!currentAssistant) return
@@ -518,10 +539,10 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
break
case "session.retried":
case "session.compaction.started":
setStore("session", "status", event.data.sessionID, "running")
setSessionStatus(event.data.sessionID, "running")
break
case "session.execution.settled":
setStore("session", "status", event.data.sessionID, "idle")
setSessionStatus(event.data.sessionID, "idle")
break
case "session.revert.staged":
if (store.session.info[event.data.sessionID])
@@ -858,15 +879,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
)
for (const session of response.data) registerSession(session.id)
}),
sdk.api.session
.active()
.then((active) =>
setStore(
"session",
"status",
Object.fromEntries(Object.keys(active.data).map((sessionID) => [sessionID, "running" as const])),
),
),
result.location.refresh(),
result.location.agent.refresh(),
result.location.integration.refresh(),
@@ -888,9 +900,30 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
return bootstrapping
}
function refreshActive() {
const generation = ++connectionGeneration
const changed = new Set<string>()
statusChanges = changed
void sdk.api.session
.active()
.then((active) => {
if (generation !== connectionGeneration) return
const status: Record<string, DataSessionStatus> = Object.fromEntries(
Object.keys(active.data).map((sessionID) => [sessionID, "running" as const]),
)
for (const sessionID of changed) status[sessionID] = store.session.status[sessionID]
setStore("session", "status", reconcile(status))
})
.catch(() => undefined)
.finally(() => {
if (statusChanges === changed) statusChanges = undefined
})
}
onCleanup(
sdk.event.listen(({ details }) => {
if (details.type === "server.connected") {
refreshActive()
void bootstrap()
return
}
+13 -5
View File
@@ -1231,7 +1231,8 @@ function SessionSwitchMessageV2(props: { message: SessionMessage }) {
const { theme } = useTheme()
const text = () => {
if (props.message.type === "agent-switched") return `Switched agent to ${props.message.agent}`
if (props.message.type === "model-switched") return switchLabel(props.message.model, ctx.models())
if (props.message.type === "model-switched")
return switchLabel(props.message.model, ctx.models(), props.message.previous)
return ""
}
return <text fg={theme.textMuted}>{text()}</text>
@@ -2079,14 +2080,16 @@ function Shell(props: ToolProps) {
return request?.source?.type === "tool" && request.source.callID === props.part.id
})
const color = createMemo(() => (permission() ? theme.warning : theme.text))
const isRunning = createMemo(() => {
if (props.part.state.status === "running") return true
const shellID = stringValue(props.metadata.shellID)
return Boolean(shellID && data.shell.get(shellID))
const shellID = createMemo(() => stringValue(props.metadata.shellID))
const backgroundRunning = createMemo(() => {
const id = shellID()
return Boolean(id && data.shell.get(id))
})
const isRunning = createMemo(() => props.part.state.status === "running" || backgroundRunning())
const command = createMemo(() => stringValue(props.input.command))
const output = createMemo(() => {
if (props.part.state.status === "pending") return ""
if (shellID()) return ""
const content = props.part.state.content[0]
return stripAnsi(content?.type === "text" ? content.text.trim() : "")
})
@@ -2129,6 +2132,11 @@ function Shell(props: ToolProps) {
</Spinner>
</Show>
</Show>
<Show when={shellID()}>
<text>
<span style={{ bg: theme.backgroundElement, fg: theme.textMuted }}> Backgrounded </span>
</text>
</Show>
<Show when={collapsed().overflow}>
<text fg={theme.textMuted}>{expanded() ? "Click to collapse" : "Click to expand"}</text>
</Show>
+33 -10
View File
@@ -28,23 +28,24 @@ export function createSessionRows(sessionID: Accessor<string>) {
function reduce() {
const messages = data.session.message.list(sessionID())
const boundary = revertBoundary()
return reduceSessionRows(boundary ? messages.filter((message) => message.id < boundary) : messages)
const rows = reduceSessionRows(boundary ? messages.filter((message) => message.id < boundary) : messages)
partitionPending(rows, pendingPermissions())
return rows
}
createEffect(() => {
const pending = new Set(
function pendingPermissions() {
return new Set(
(data.session.permission.list(sessionID()) ?? []).flatMap((request) =>
request.source?.type === "tool" ? [request.source.callID] : [],
),
)
}
createEffect(() => {
const pending = pendingPermissions()
setRows(
produce((draft) => {
draft.forEach((row) => {
if (row.type !== "group") return
const refs = [...row.refs, ...row.pending]
row.refs = refs.filter((ref) => !pending.has(ref.partID))
row.pending = refs.filter((ref) => pending.has(ref.partID))
})
partitionPending(draft, pending)
}),
)
})
@@ -69,6 +70,20 @@ export function createSessionRows(sessionID: Accessor<string>) {
}),
)
createEffect(
on(
() =>
data.session.message
.list(sessionID())
.flatMap((message) =>
message.type === "user"
? [{ id: message.id, created: message.time.created, queued: message.metadata?.queued === true }]
: [],
),
() => setRows(reconcile(reduce())),
),
)
const appendMessage = (messageID: string) =>
setRows(
produce((draft) => {
@@ -134,7 +149,6 @@ export function createSessionRows(sessionID: Accessor<string>) {
}
const subscriptions = [
data.on("session.prompt.admitted", input),
data.on("session.prompt.promoted", input),
data.on("session.context.updated", message),
data.on("session.synthetic", (event) => {
if (event.data.sessionID === sessionID() && event.data.description?.trim())
@@ -225,6 +239,15 @@ function completePrevious(rows: SessionRow[], index = rows.length) {
if (previous?.type === "group") previous.completed = true
}
function partitionPending(rows: SessionRow[], pending: Set<string>) {
rows.forEach((row) => {
if (row.type !== "group") return
const refs = [...row.refs, ...row.pending]
row.refs = refs.filter((ref) => !pending.has(ref.partID))
row.pending = refs.filter((ref) => pending.has(ref.partID))
})
}
function exploration(name: string) {
return ["read", "glob", "grep"].includes(name.toLowerCase())
}
+3 -2
View File
@@ -34,11 +34,12 @@ export function formatRef(model: { providerID: string; id: string; variant?: str
export function switchLabel(
model: { providerID: string; id: string; variant?: string },
models?: readonly { providerID: string; id: string; name: string }[],
previous?: { providerID: string; id: string; variant?: string },
) {
if (previous?.providerID === model.providerID && previous.id === model.id)
return `Switched variant to ${model.variant ?? "default"}`
const display = models?.find((item) => item.providerID === model.providerID && item.id === model.id)?.name
if (display === undefined) return `Switched model to ${formatRef(model)}`
// Variant-only switches publish the same model id; without the variant the
// notice would look like a redundant model switch.
const variant = model.variant && model.variant !== "default" ? ` (${model.variant})` : ""
return `Switched model to ${display}${variant}`
}
+128 -3
View File
@@ -8,6 +8,7 @@ import { onMount } from "solid-js"
import { ProjectProvider } from "../../../src/context/project"
import { SDKProvider } from "../../../src/context/sdk"
import { DataProvider, useData } from "../../../src/context/data"
import { createSessionRows } from "../../../src/routes/session/rows"
import { createApi, createClient, createEventStream, createFetch, directory, json } from "../../fixture/tui-sdk"
import { TestTuiContexts } from "../../fixture/tui-environment"
@@ -109,12 +110,20 @@ test("refreshes resources into reactive getters", async () => {
test("reconnects the event stream and bootstraps fresh data", async () => {
const events = createEventStream()
const requests = { event: 0, model: 0 }
const requests = { active: 0, event: 0, model: 0 }
let resolveActive!: (response: Response) => void
const calls = createFetch((url) => {
if (url.pathname === "/api/event") {
requests.event++
return events.v2()
}
if (url.pathname === "/api/session/active") {
requests.active++
if (requests.active === 1) return json({ data: { "session-stale": { type: "running" } }, watermarks: {} })
return new Promise<Response>((resolve) => {
resolveActive = resolve
})
}
if (url.pathname !== "/api/model") return
requests.model++
return json({
@@ -157,6 +166,7 @@ test("reconnects the event stream and bootstraps fresh data", async () => {
try {
await wait(() => data.location.model.list()?.[0]?.id === "model-1")
await wait(() => data.session.status("session-stale") === "running")
expect(data.connection.status()).toBe("connected")
expect(data.connection.attempt()).toBe(0)
@@ -165,7 +175,25 @@ test("reconnects the event stream and bootstraps fresh data", async () => {
expect(data.connection.attempt()).toBe(1)
expect(data.connection.error()).toBe("Event stream disconnected")
await wait(() => requests.active === 2 && data.connection.status() === "connected", 4000)
emitEvent(events, {
id: "evt_step_started_after_reconnect",
created: 1,
type: "session.step.started",
durable: durable("session-new"),
data: {
sessionID: "session-new",
assistantMessageID: "message-new",
agent: "build",
model: { id: "model", providerID: "provider" },
},
})
await wait(() => data.session.status("session-new") === "running")
resolveActive(json({ data: {}, watermarks: {} }))
await wait(() => data.location.model.list()?.[0]?.id === "model-2", 4000)
await wait(() => data.session.status("session-stale") === "idle")
expect(data.session.status("session-new")).toBe("running")
expect(requests.event).toBe(2)
expect(data.connection.status()).toBe("connected")
expect(data.connection.attempt()).toBe(0)
@@ -175,6 +203,87 @@ test("reconnects the event stream and bootstraps fresh data", async () => {
}
})
test("completes exploration when a queued prompt is promoted", async () => {
const events = createEventStream()
const sessionID = "session-promotion"
const calls = createFetch((url) => {
if (url.pathname === `/api/session/${sessionID}/message`) return json({ data: [], cursor: {} })
}, events)
let rows!: ReturnType<typeof createSessionRows>
function Probe() {
rows = createSessionRows(() => sessionID)
return <box />
}
const app = await testRender(() => (
<TestTuiContexts>
<SDKProvider client={createClient(calls.fetch)} api={createApi(calls.fetch)}>
<ProjectProvider>
<DataProvider>
<Probe />
</DataProvider>
</ProjectProvider>
</SDKProvider>
</TestTuiContexts>
))
try {
emitEvent(events, {
id: "evt_step_started",
created: 1,
type: "session.step.started",
durable: durable(sessionID),
data: {
sessionID,
assistantMessageID: "message-assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
},
})
emitEvent(events, {
id: "evt_tool_started",
created: 2,
type: "session.tool.input.started",
durable: durable(sessionID, 1),
data: {
sessionID,
assistantMessageID: "message-assistant",
callID: "call-read",
name: "read",
},
})
await wait(() => rows.some((row) => row.type === "group" && !row.completed))
emitEvent(events, {
id: "evt_prompt_admitted",
created: 3,
type: "session.prompt.admitted",
durable: durable(sessionID, 2),
data: {
sessionID,
inputID: "message-user",
prompt: { text: "Continue" },
delivery: "steer",
},
})
await wait(() => rows.at(-1)?.type === "message")
expect(rows.find((row) => row.type === "group")?.completed).toBe(false)
emitEvent(events, {
id: "evt_prompt_promoted",
created: 4,
type: "session.prompt.promoted",
durable: durable(sessionID, 3),
data: { sessionID, inputID: "message-user" },
})
await wait(() => rows.find((row) => row.type === "group")?.completed === true)
expect(rows.at(-1)).toEqual({ type: "message", messageID: "message-user" })
} finally {
app.renderer.destroy()
}
})
test("connectedOnce is false until first connect and persists across disconnect", async () => {
const encoder = new TextEncoder()
let stream: ReadableStreamDefaultController<Uint8Array> | undefined
@@ -767,7 +876,18 @@ test("adds and dismisses question requests from live events", async () => {
test("settles pending tools when a live failure arrives", async () => {
const events = createEventStream()
const calls = createFetch(undefined, events)
const calls = createFetch((url) => {
if (url.pathname === "/api/session/session-1/message/msg_model_1")
return json({
data: {
id: "msg_model_1",
type: "model-switched",
previous: { id: "model-1", providerID: "provider-1", variant: "medium" },
model: { id: "model-1", providerID: "provider-1", variant: "high" },
time: { created: 0 },
},
})
}, events)
let sync!: ReturnType<typeof useData>
let ready!: () => void
const mounted = new Promise<void>((resolve) => {
@@ -808,7 +928,7 @@ test("settles pending tools when a live failure arrives", async () => {
durable: durable("session-1", 1),
data: {
sessionID: "session-1",
model: { id: "model-1", providerID: "provider-1" },
model: { id: "model-1", providerID: "provider-1", variant: "high" },
},
})
emitEvent(events, {
@@ -895,6 +1015,11 @@ test("settles pending tools when a live failure arrives", async () => {
"model-switched",
"assistant",
])
expect(sync.session.message.get("session-1", "msg_model_1")).toMatchObject({
type: "model-switched",
previous: { id: "model-1", providerID: "provider-1", variant: "medium" },
model: { id: "model-1", providerID: "provider-1", variant: "high" },
})
} finally {
app.renderer.destroy()
}
+12
View File
@@ -34,4 +34,16 @@ describe("util.model", () => {
"Switched model to removed/gone/high",
)
})
test("distinguishes variant-only switches from model switches", () => {
const previous = { providerID: "openai", id: "gpt-5.5", variant: "medium" }
expect(switchLabel({ ...previous, variant: "high" }, undefined, previous)).toBe("Switched variant to high")
expect(switchLabel({ providerID: "openai", id: "gpt-5.5" }, undefined, previous)).toBe(
"Switched variant to default",
)
expect(switchLabel({ providerID: "anthropic", id: "sonnet", variant: "high" }, undefined, previous)).toBe(
"Switched model to anthropic/sonnet/high",
)
})
})
+2 -2
View File
@@ -749,12 +749,12 @@ You can configure file watcher ignore patterns through the `watcher` option.
{
"$schema": "https://opencode.ai/config.json",
"watcher": {
"ignore": ["node_modules/**", "dist/**", ".git/**"]
"ignore": ["**/generated/**"]
}
}
```
Patterns follow glob syntax. Use this to exclude noisy directories from file watching.
Patterns follow glob syntax. Common dependency, VCS, build, and cache directories are ignored automatically at any depth.
---