From 2efe5d10342e794c9a4ffb97a1b0ea552a603f78 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Wed, 19 Aug 2026 20:02:28 -0400 Subject: [PATCH] feat(core): execute command callbacks --- packages/cli/src/acp/service.ts | 3 +- packages/cli/test/acp/service-fixture.ts | 1 - packages/client/src/effect/api/api.ts | 8 +- .../client/src/effect/generated/client.ts | 11 +- .../client/src/promise/generated/client.ts | 16 +- .../client/src/promise/generated/types.ts | 153 +-------- packages/core/src/command.ts | 311 +++--------------- packages/core/src/config/plugin/command.ts | 117 ++++++- packages/core/src/mcp/index.ts | 5 +- packages/core/src/plugin/command.ts | 112 ++++++- packages/core/src/plugin/host.ts | 2 + packages/core/src/session.ts | 58 +--- packages/core/test/command.test.ts | 82 +---- packages/core/test/config/command.test.ts | 94 ++++-- packages/core/test/plugin/command.test.ts | 45 ++- packages/core/test/plugin/host.ts | 2 + packages/plugin/src/effect/command.ts | 5 +- packages/plugin/src/effect/session.ts | 12 +- packages/plugin/src/promise/adapter.ts | 5 +- packages/plugin/src/promise/command.ts | 5 +- packages/plugin/src/promise/session.ts | 12 +- packages/protocol/src/errors.ts | 4 +- packages/protocol/src/groups/session.ts | 18 +- packages/schema/src/command.ts | 6 - packages/schema/src/mcp-event.ts | 9 +- packages/schema/test/event-manifest.test.ts | 7 +- packages/server/src/handlers/session.ts | 90 ++--- packages/server/src/routes.ts | 1 - packages/tui/src/component/prompt/index.tsx | 27 +- packages/tui/src/mini/stream-v2.transport.ts | 12 +- .../tui/test/mini/stream-v2.transport.test.ts | 25 +- 31 files changed, 540 insertions(+), 718 deletions(-) diff --git a/packages/cli/src/acp/service.ts b/packages/cli/src/acp/service.ts index 6e22c7101c8..5cae2d995c6 100644 --- a/packages/cli/src/acp/service.ts +++ b/packages/cli/src/acp/service.ts @@ -377,9 +377,8 @@ async function submitPrompt(client: OpenCodeClient, session: Attached, prompt: P return client.session.command( { sessionID: session.id, - id: prompt.start.id, command: prompt.command.name, - arguments: prompt.slash?.args, + text: prompt.slash?.args ?? "", files: prompt.files, delivery: "steer", }, diff --git a/packages/cli/test/acp/service-fixture.ts b/packages/cli/test/acp/service-fixture.ts index 68deed29be3..f97875d3405 100644 --- a/packages/cli/test/acp/service-fixture.ts +++ b/packages/cli/test/acp/service-fixture.ts @@ -87,7 +87,6 @@ export const planAgent = { export const reviewCommand = { name: "review", description: "Review changes", - template: "", } satisfies CommandInfo export const verifySkill = { diff --git a/packages/client/src/effect/api/api.ts b/packages/client/src/effect/api/api.ts index 3e41acd814e..def276b8f4e 100644 --- a/packages/client/src/effect/api/api.ts +++ b/packages/client/src/effect/api/api.ts @@ -184,18 +184,14 @@ export type SessionPromptOperation = (input: Endpoint5_12Input) => Ef export type Endpoint5_13Input = { readonly sessionID: Session.ID - readonly id?: SessionMessage.ID | undefined readonly command: string - readonly arguments?: string | undefined - readonly agent?: Agent.ID | undefined - readonly model?: Model.Ref | undefined + readonly text: string readonly files?: ReadonlyArray | undefined readonly agents?: ReadonlyArray | undefined readonly skills?: ReadonlyArray | undefined readonly delivery?: SessionInbox.Delivery | undefined - readonly resume?: boolean | undefined } -export type Endpoint5_13Output = SessionInbox.User +export type Endpoint5_13Output = void export type SessionCommandOperation = (input: Endpoint5_13Input) => Effect.Effect export type Endpoint5_14Input = { diff --git a/packages/client/src/effect/generated/client.ts b/packages/client/src/effect/generated/client.ts index dccea27fa09..cf1996a0c08 100644 --- a/packages/client/src/effect/generated/client.ts +++ b/packages/client/src/effect/generated/client.ts @@ -415,21 +415,14 @@ const Endpoint5_13 = (raw: RawClient["server.session"]) => (input: Endpoint5_13I raw["session.command"]({ params: { sessionID: input["sessionID"] }, payload: { - id: input["id"], command: input["command"], - arguments: input["arguments"], - agent: input["agent"], - model: input["model"], + text: input["text"], files: input["files"], agents: input["agents"], skills: input["skills"], delivery: input["delivery"], - resume: input["resume"], }, - }).pipe( - Effect.mapError(mapClientError), - Effect.map((value) => value.data), - ), + }).pipe(Effect.mapError(mapClientError)), ) const Endpoint5_14 = (raw: RawClient["server.session"]) => (input: Endpoint5_14Input) => diff --git a/packages/client/src/promise/generated/client.ts b/packages/client/src/promise/generated/client.ts index 3d5b02b21e0..ecb13ef899b 100644 --- a/packages/client/src/promise/generated/client.ts +++ b/packages/client/src/promise/generated/client.ts @@ -607,28 +607,24 @@ export function make(options: ClientOptions) { requestOptions, ).then((value) => value.data), command: (input: SessionCommandInput, requestOptions?: RequestOptions) => - request<{ readonly data: SessionCommandOutput }>( + request( { method: "POST", path: `/api/session/${encodeURIComponent(input.sessionID)}/command`, body: { - id: input["id"], command: input["command"], - arguments: input["arguments"], - agent: input["agent"], - model: input["model"], + text: input["text"], files: input["files"], agents: input["agents"], skills: input["skills"], delivery: input["delivery"], - resume: input["resume"], }, - successStatus: 200, - declaredStatuses: [409, 400, 404, 500, 401], - empty: false, + successStatus: 204, + declaredStatuses: [404, 500, 400, 401], + empty: true, }, requestOptions, - ).then((value) => value.data), + ), skill: (input: SessionSkillInput, requestOptions?: RequestOptions) => request( { diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index 2ae36dc7a38..e78ed7f64c6 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -118,6 +118,8 @@ export type PermissionSavedInfo = { id: string; projectID: string; action: strin export type FileSystemEntry = { path: string; type: "file" | "directory" } +export type CommandInfo = { name: string; description?: string } + export type SkillInfo = { id: string name: string @@ -183,15 +185,6 @@ export type WebSearchProvider = { id: string; name: string } export type WebSearchResult = { url: string; title?: string; content?: string; time: { published?: number } } -export type CommandInfo = { - name: string - template: string - description?: string - agent?: string - model?: ModelRef - subtask?: boolean -} - export type ProviderRequest = { settings: ProviderSettings headers: { [x: string]: string } @@ -2174,13 +2167,13 @@ export type CommandNotFoundError = { export const isCommandNotFoundError = (value: unknown): value is CommandNotFoundError => typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "CommandNotFoundError" -export type CommandEvaluationError = { - readonly _tag: "CommandEvaluationError" +export type CommandExecutionError = { + readonly _tag: "CommandExecutionError" readonly command: string readonly message: string } -export const isCommandEvaluationError = (value: unknown): value is CommandEvaluationError => - typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "CommandEvaluationError" +export const isCommandExecutionError = (value: unknown): value is CommandExecutionError => + typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "CommandExecutionError" export type SkillNotFoundError = { readonly _tag: "SkillNotFoundError" @@ -3523,35 +3516,9 @@ export type SessionPromptOutput = { data: SessionInboxUser }["data"] export type SessionCommandInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] - readonly id?: { - readonly id?: string | null - readonly command: string - readonly arguments?: string | null - readonly agent?: string | null - readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null - readonly files?: ReadonlyArray<{ - readonly uri: string - readonly name?: string - readonly description?: string - readonly mention?: { readonly start: number; readonly end: number; readonly text: string } - }> - readonly agents?: ReadonlyArray<{ - readonly name: string - readonly mention?: { readonly start: number; readonly end: number; readonly text: string } - }> - readonly skills?: ReadonlyArray<{ - readonly id: string - readonly mention?: { readonly start: number; readonly end: number; readonly text: string } - }> - readonly delivery?: ("steer" | "queue") | null - readonly resume?: boolean | null - }["id"] readonly command: { - readonly id?: string | null readonly command: string - readonly arguments?: string | null - readonly agent?: string | null - readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null + readonly text: string readonly files?: ReadonlyArray<{ readonly uri: string readonly name?: string @@ -3567,14 +3534,10 @@ export type SessionCommandInput = { readonly mention?: { readonly start: number; readonly end: number; readonly text: string } }> readonly delivery?: ("steer" | "queue") | null - readonly resume?: boolean | null }["command"] - readonly arguments?: { - readonly id?: string | null + readonly text: { readonly command: string - readonly arguments?: string | null - readonly agent?: string | null - readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null + readonly text: string readonly files?: ReadonlyArray<{ readonly uri: string readonly name?: string @@ -3590,60 +3553,10 @@ export type SessionCommandInput = { readonly mention?: { readonly start: number; readonly end: number; readonly text: string } }> readonly delivery?: ("steer" | "queue") | null - readonly resume?: boolean | null - }["arguments"] - readonly agent?: { - readonly id?: string | null - readonly command: string - readonly arguments?: string | null - readonly agent?: string | null - readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null - readonly files?: ReadonlyArray<{ - readonly uri: string - readonly name?: string - readonly description?: string - readonly mention?: { readonly start: number; readonly end: number; readonly text: string } - }> - readonly agents?: ReadonlyArray<{ - readonly name: string - readonly mention?: { readonly start: number; readonly end: number; readonly text: string } - }> - readonly skills?: ReadonlyArray<{ - readonly id: string - readonly mention?: { readonly start: number; readonly end: number; readonly text: string } - }> - readonly delivery?: ("steer" | "queue") | null - readonly resume?: boolean | null - }["agent"] - readonly model?: { - readonly id?: string | null - readonly command: string - readonly arguments?: string | null - readonly agent?: string | null - readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null - readonly files?: ReadonlyArray<{ - readonly uri: string - readonly name?: string - readonly description?: string - readonly mention?: { readonly start: number; readonly end: number; readonly text: string } - }> - readonly agents?: ReadonlyArray<{ - readonly name: string - readonly mention?: { readonly start: number; readonly end: number; readonly text: string } - }> - readonly skills?: ReadonlyArray<{ - readonly id: string - readonly mention?: { readonly start: number; readonly end: number; readonly text: string } - }> - readonly delivery?: ("steer" | "queue") | null - readonly resume?: boolean | null - }["model"] + }["text"] readonly files?: { - readonly id?: string | null readonly command: string - readonly arguments?: string | null - readonly agent?: string | null - readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null + readonly text: string readonly files?: ReadonlyArray<{ readonly uri: string readonly name?: string @@ -3659,14 +3572,10 @@ export type SessionCommandInput = { readonly mention?: { readonly start: number; readonly end: number; readonly text: string } }> readonly delivery?: ("steer" | "queue") | null - readonly resume?: boolean | null }["files"] readonly agents?: { - readonly id?: string | null readonly command: string - readonly arguments?: string | null - readonly agent?: string | null - readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null + readonly text: string readonly files?: ReadonlyArray<{ readonly uri: string readonly name?: string @@ -3682,14 +3591,10 @@ export type SessionCommandInput = { readonly mention?: { readonly start: number; readonly end: number; readonly text: string } }> readonly delivery?: ("steer" | "queue") | null - readonly resume?: boolean | null }["agents"] readonly skills?: { - readonly id?: string | null readonly command: string - readonly arguments?: string | null - readonly agent?: string | null - readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null + readonly text: string readonly files?: ReadonlyArray<{ readonly uri: string readonly name?: string @@ -3705,14 +3610,10 @@ export type SessionCommandInput = { readonly mention?: { readonly start: number; readonly end: number; readonly text: string } }> readonly delivery?: ("steer" | "queue") | null - readonly resume?: boolean | null }["skills"] readonly delivery?: { - readonly id?: string | null readonly command: string - readonly arguments?: string | null - readonly agent?: string | null - readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null + readonly text: string readonly files?: ReadonlyArray<{ readonly uri: string readonly name?: string @@ -3728,34 +3629,10 @@ export type SessionCommandInput = { readonly mention?: { readonly start: number; readonly end: number; readonly text: string } }> readonly delivery?: ("steer" | "queue") | null - readonly resume?: boolean | null }["delivery"] - readonly resume?: { - readonly id?: string | null - readonly command: string - readonly arguments?: string | null - readonly agent?: string | null - readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null - readonly files?: ReadonlyArray<{ - readonly uri: string - readonly name?: string - readonly description?: string - readonly mention?: { readonly start: number; readonly end: number; readonly text: string } - }> - readonly agents?: ReadonlyArray<{ - readonly name: string - readonly mention?: { readonly start: number; readonly end: number; readonly text: string } - }> - readonly skills?: ReadonlyArray<{ - readonly id: string - readonly mention?: { readonly start: number; readonly end: number; readonly text: string } - }> - readonly delivery?: ("steer" | "queue") | null - readonly resume?: boolean | null - }["resume"] } -export type SessionCommandOutput = { data: SessionInboxUser }["data"] +export type SessionCommandOutput = void export type SessionSkillInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] diff --git a/packages/core/src/command.ts b/packages/core/src/command.ts index 61eac9541c8..99f367e7e95 100644 --- a/packages/core/src/command.ts +++ b/packages/core/src/command.ts @@ -1,43 +1,32 @@ export * as Command from "./command.js" -import { makeLocationNode } from "@opencode-ai/util/effect/app-node" -import { Context, Effect, Layer, Schema, Types } from "effect" import { Command } from "@opencode-ai/schema/command" +import type { PromptInput } from "@opencode-ai/schema/prompt-input" import type { Session } from "@opencode-ai/schema/session" import type { SessionInbox } from "@opencode-ai/schema/session-inbox" -import { State } from "./state.js" -import { MCP } from "./mcp/index.js" +import { makeLocationNode } from "@opencode-ai/util/effect/app-node" +import { Cause, Context, Effect, Layer, Schema } from "effect" import { Bus } from "./bus.js" -import { AppProcess } from "@opencode-ai/util/process" -import { ChildProcess } from "effect/unstable/process" -import { Config } from "./config.js" -import { Location } from "./location.js" -import { ShellSelect } from "./shell/select.js" -import { Global } from "@opencode-ai/util/global" +import { State } from "./state.js" export const Info = Command.Info export type Info = Command.Info export { Event } from "@opencode-ai/schema/command" -export type Evaluation = { - readonly text: string -} - export interface Invocation { readonly sessionID: Session.ID - readonly arguments: string + readonly prompt: PromptInput.Prompt readonly delivery: SessionInbox.Delivery } export interface Definition { readonly name: string readonly description?: string - readonly run: (input: Invocation) => Effect.Effect + readonly execute: (input: Invocation) => Effect.Effect } -export type Data = { - commands: Map> - callbacks: Map +export type Draft = { + add: (definition: Definition) => void } export class NotFoundError extends Schema.TaggedError()("Command.NotFoundError", { @@ -45,264 +34,66 @@ export class NotFoundError extends Schema.TaggedError()("Command. message: Schema.String, }) {} -export class EvaluationError extends Schema.TaggedError()("Command.EvaluationError", { +export class ExecutionError extends Schema.TaggedError()("Command.ExecutionError", { command: Schema.String, message: Schema.String, }) {} -export type Draft = { - list: () => readonly Info[] - get: (name: string) => Info | undefined - add: (definition: Definition) => void - update: (name: string, update: (command: Types.DeepMutable) => void) => void - remove: (name: string) => void -} - export interface Interface extends State.Transformable { readonly get: (name: string) => Effect.Effect readonly list: () => Effect.Effect - readonly execute: (input: { readonly name: string; readonly invocation: Invocation }) => Effect.Effect - readonly evaluate: (input: { + readonly execute: (input: { readonly name: string - readonly arguments?: string - }) => Effect.Effect + readonly invocation: Invocation + }) => Effect.Effect } export class Service extends Context.Service()("@opencode/Command") {} -export const layer = (options?: ShellSelect.Options) => - Layer.effect( - Service, - Effect.gen(function* () { - const mcp = yield* MCP.Service - const bus = yield* Bus.Service - const processes = yield* AppProcess.Service - const config = yield* Config.Service - const location = yield* Location.Service - const global = yield* Global.Service - const state = State.create({ - name: "command", - initial: () => ({ commands: new Map(), callbacks: new Map() }), - draft: (draft) => ({ - list: () => Array.from(draft.commands.values()) as Info[], - get: (name) => draft.commands.get(name), - add: (definition) => { - draft.commands.set( - definition.name, - Info.make({ name: definition.name, template: "", description: definition.description }), - ) - draft.callbacks.set(definition.name, definition.run) - }, - update: (name, update) => { - const current = draft.commands.get(name) ?? ({ name, template: "" } as Types.DeepMutable) - if (!draft.commands.has(name)) draft.commands.set(name, current) - update(current) - current.name = name - }, - remove: (name) => { - draft.commands.delete(name) - draft.callbacks.delete(name) - }, - }), - finalize: () => bus.publish(Command.Event.Updated, {}).pipe(Effect.asVoid), - }) - const staticCommand = (name: string) => state.get().commands.get(name) as Info | undefined - const mcpCommands = Effect.fnUntraced(function* () { - return (yield* mcp.prompts()).map((prompt) => - Info.make({ - name: mcpCommandName(prompt.server, prompt.name), - template: "", - description: prompt.description, - }), - ) +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const bus = yield* Bus.Service + const state = State.create, Draft>({ + name: "command", + initial: () => new Map(), + draft: (draft) => ({ + add: (definition) => draft.set(definition.name, definition), + }), + finalize: () => bus.publish(Command.Event.Updated, {}).pipe(Effect.asVoid), + }) + const info = (definition: Definition) => + Info.make({ + name: definition.name, + description: definition.description, }) - return Service.of({ - reload: state.reload, - transform: state.transform, - get: Effect.fn("Command.get")(function* (name) { - const command = staticCommand(name) - if (command) return command - return (yield* mcpCommands()).find((command) => command.name === name) + return Service.of({ + reload: state.reload, + transform: state.transform, + get: Effect.fn("Command.get")((name) => + Effect.sync(() => { + const definition = state.get().get(name) + return definition ? info(definition) : undefined }), - list: Effect.fn("Command.list")(function* () { - const commands = Array.from(state.get().commands.values()) as Info[] - const names = new Set(commands.map((command) => command.name)) - return [...commands, ...(yield* mcpCommands()).filter((command) => !names.has(command.name))] - }), - execute: Effect.fn("Command.execute")(function* (input) { - const callback = state.get().callbacks.get(input.name) - if (callback) return yield* callback(input.invocation) + ), + list: Effect.fn("Command.list")(() => Effect.sync(() => Array.from(state.get().values(), info))), + execute: Effect.fn("Command.execute")(function* (input) { + const definition = state.get().get(input.name) + if (!definition) return yield* new NotFoundError({ command: input.name, message: `Command not found: ${input.name}` }) - }), - evaluate: Effect.fn("Command.evaluate")(function* (input) { - const command = staticCommand(input.name) - if (command) - return yield* evaluateTemplate(input.name, command.template, input.arguments ?? "", { - config, - location, - processes, - shell: options, - bin: global.bin, - }) - - const prompt = (yield* mcp.prompts()).find( - (prompt) => mcpCommandName(prompt.server, prompt.name) === input.name, - ) - if (!prompt) - return yield* new NotFoundError({ command: input.name, message: `Command not found: ${input.name}` }) - const result = yield* mcp - .prompt({ - server: prompt.server, - name: prompt.name, - args: Object.fromEntries( - (prompt.arguments ?? []).map((argument, index) => [ - argument.name, - parseArguments(input.arguments ?? "")[index] ?? "", - ]), - ), - }) - .pipe( - Effect.catchTag("MCP.NotFoundError", () => - Effect.fail( - new EvaluationError({ - command: input.name, - message: `MCP server could not be found while evaluating prompt: ${prompt.server}`, - }), - ), - ), - ) - if (!result) - return yield* new EvaluationError({ - command: input.name, - message: `MCP prompt could not be evaluated: ${prompt.server}:${prompt.name}`, - }) - return { - text: result.messages - .map((message) => promptMessageText(message.content)) - .join("\n") - .trim(), - } - }), - }) - }), - ) - -function evaluateTemplate( - command: string, - template: string, - input: string, - services: { - readonly config: Config.Interface - readonly location: Location.Info - readonly processes: AppProcess.Interface - readonly shell?: ShellSelect.Options - readonly bin: string - }, -) { - return Effect.gen(function* () { - const expanded = evaluateArguments(template, input) - return { text: yield* evaluateShell(command, expanded, services) } - }) -} - -function evaluateArguments(template: string, input: string) { - const args = parseArguments(input) - const placeholders = template.match(placeholderRegex) ?? [] - const last = Math.max(0, ...placeholders.map((item) => Number(item.slice(1)))) - const expanded = template.replaceAll(placeholderRegex, (_, index) => { - const position = Number(index) - const argIndex = position - 1 - if (argIndex >= args.length) return "" - if (position === last) return args.slice(argIndex).join(" ") - return args[argIndex] - }) - const withArguments = expanded.replaceAll("$ARGUMENTS", input) - if (placeholders.length === 0 && !template.includes("$ARGUMENTS") && input.trim()) - return `${withArguments}\n\n${input}`.trim() - return withArguments.trim() -} - -const evaluateShell = Effect.fnUntraced(function* ( - command: string, - text: string, - services: { - readonly config: Config.Interface - readonly location: Location.Info - readonly processes: AppProcess.Interface - readonly shell?: ShellSelect.Options - readonly bin: string - }, -) { - const matches = Array.from(text.matchAll(shellRegex)) - if (matches.length === 0) return text - const shell = ShellSelect.preferred( - Config.latest(yield* services.config.entries(), "shell"), - services.shell, - services.bin, - ) - const outputs = yield* Effect.forEach( - matches, - (match) => { - const source = match[1] ?? "" - return services.processes - .run( - ChildProcess.make(shell, ShellSelect.args(shell, source), { - cwd: services.location.directory, - stdin: "ignore", - }), - { - combineOutput: true, - }, - ) - .pipe( - Effect.map((result) => (result.output ?? Buffer.concat([result.stdout, result.stderr])).toString("utf8")), + return yield* definition.execute(input.invocation).pipe( Effect.mapError( - (error) => - new EvaluationError({ - command, - message: `Shell interpolation failed for ${JSON.stringify(source)}: ${error.message}`, - }), + (error) => new ExecutionError({ command: input.name, message: Cause.pretty(Cause.fail(error)) }), ), ) - }, - { concurrency: 2 }, - ) - const iterator = outputs[Symbol.iterator]() - return text.replace(shellRegex, () => iterator.next().value ?? "") + }), + }) + }), +) + +export const node = makeLocationNode({ + service: Service, + layer, + deps: [Bus.node], }) - -function parseArguments(input: string) { - return (input.match(argsRegex) ?? []).map((arg) => arg.replace(quoteTrimRegex, "")) -} - -function promptMessageText(content: unknown) { - if (typeof content === "string") return content - if (!content || typeof content !== "object") return "" - if (!("type" in content) || content.type !== "text") return "" - if (!("text" in content) || typeof content.text !== "string") return "" - return content.text -} - -function mcpCommandName(server: string, prompt: string) { - return `${sanitize(server)}:${sanitize(prompt)}` -} - -function sanitize(value: string) { - return value.replace(/[^a-zA-Z0-9_-]/g, "_") -} - -const argsRegex = /(?:\[Image\s+\d+\]|"[^"]*"|'[^']*'|[^\s"']+)/gi -const placeholderRegex = /\$(\d+)/g -const quoteTrimRegex = /^["']|["']$/g -const shellRegex = /!`([^`]+)`/g - -export function configured(options?: ShellSelect.Options) { - return makeLocationNode({ - service: Service, - layer: layer(options), - deps: [MCP.node, Bus.node, AppProcess.node, Config.node, Location.node, Global.node], - }) -} - -export const node = configured() diff --git a/packages/core/src/config/plugin/command.ts b/packages/core/src/config/plugin/command.ts index e94a1112a3b..bcc0e2815c1 100644 --- a/packages/core/src/config/plugin/command.ts +++ b/packages/core/src/config/plugin/command.ts @@ -1,15 +1,19 @@ export * as ConfigCommandPlugin from "./command.js" import { define } from "@opencode-ai/plugin/effect/plugin" +import { Agent } from "@opencode-ai/schema/agent" import { Info, type Entry } from "@opencode-ai/schema/config" import { ConfigCommand } from "@opencode-ai/schema/config/command" -import { Agent } from "@opencode-ai/schema/agent" import { Model } from "@opencode-ai/schema/model" import { Provider } from "@opencode-ai/schema/provider" +import { Global } from "@opencode-ai/util/global" +import { AppProcess } from "@opencode-ai/util/process" import path from "path" import { Effect, Option, Schema, Stream } from "effect" -import { Command } from "../../command.js" +import { ChildProcess } from "effect/unstable/process" import { Config } from "../../config.js" +import { Location } from "../../location.js" +import { ShellSelect } from "../../shell/select.js" import { FSUtil } from "@opencode-ai/util/fs-util" import { ConfigMarkdown } from "../markdown.js" @@ -20,7 +24,9 @@ export const Plugin = define({ effect: Effect.fn(function* (ctx) { const config = yield* Config.Service const fs = yield* FSUtil.Service - const commands = yield* Command.Service + const global = yield* Global.Service + const location = yield* Location.Service + const processes = yield* AppProcess.Service const load = Effect.fn("ConfigCommandPlugin.load")(function* () { return yield* Effect.forEach(yield* config.entries(), (entry) => { if (entry.type === "document") return Effect.succeed([{ commands: entry.info.commands }]) @@ -54,20 +60,42 @@ export const Plugin = define({ Effect.forkScoped({ startImmediately: true }), ) loaded.documents = yield* load() - yield* commands.transform((draft) => { + yield* ctx.command.transform((draft) => { for (const document of loaded.documents) { for (const [name, command] of Object.entries(document.commands ?? {})) { - draft.update(name, (item) => { - item.template = command.template - if (command.description !== undefined) item.description = command.description - if (command.agent !== undefined) item.agent = Agent.ID.make(command.agent) - if (command.model !== undefined) - item.model = { - id: Model.ID.make(command.model.model), - providerID: Provider.ID.make(command.model.providerID), - ...(command.model.variant === undefined ? {} : { variant: Model.VariantID.make(command.model.variant) }), - } - if (command.subtask !== undefined) item.subtask = command.subtask + draft.add({ + name, + description: command.description, + execute: (input) => + Effect.gen(function* () { + const agent = command.agent === undefined ? undefined : Agent.ID.make(command.agent) + const session = yield* ctx.session.get({ sessionID: input.sessionID }) + if (agent !== undefined && session.agent !== agent) + yield* ctx.session.switchAgent({ sessionID: input.sessionID, agent }) + const commandAgent = agent === undefined ? undefined : (yield* ctx.agent.get({ agentID: agent })).data + const model = + command.model === undefined + ? commandAgent?.model + : { + id: Model.ID.make(command.model.model), + providerID: Provider.ID.make(command.model.providerID), + ...(command.model.variant === undefined + ? {} + : { variant: Model.VariantID.make(command.model.variant) }), + } + if (model !== undefined) yield* ctx.session.switchModel({ sessionID: input.sessionID, model }) + yield* ctx.session.prompt({ + ...input.prompt, + sessionID: input.sessionID, + text: yield* evaluateTemplate(command.template, input.prompt.text, { + config, + location, + processes, + bin: global.bin, + }), + delivery: input.delivery, + }) + }).pipe(Effect.asVoid), }) } } @@ -120,3 +148,62 @@ function decode(directory: string, filepath: string, content: string) { info, } } + +function evaluateTemplate( + template: string, + input: string, + services: { + readonly config: Config.Interface + readonly location: Location.Info + readonly processes: AppProcess.Interface + readonly bin: string + }, +) { + return Effect.gen(function* () { + const args = parseArguments(input) + const placeholders = template.match(placeholderRegex) ?? [] + const last = Math.max(0, ...placeholders.map((item) => Number(item.slice(1)))) + const expanded = template.replaceAll(placeholderRegex, (_, index) => { + const position = Number(index) + const argIndex = position - 1 + if (argIndex >= args.length) return "" + if (position === last) return args.slice(argIndex).join(" ") + return args[argIndex] + }) + const withArguments = expanded.replaceAll("$ARGUMENTS", input) + const text = + placeholders.length === 0 && !template.includes("$ARGUMENTS") && input.trim() + ? `${withArguments}\n\n${input}`.trim() + : withArguments.trim() + const matches = Array.from(text.matchAll(shellRegex)) + if (matches.length === 0) return text + const shell = ShellSelect.preferred(Config.latest(yield* services.config.entries(), "shell"), undefined, services.bin) + const outputs = yield* Effect.forEach( + matches, + (match) => + services.processes + .run( + ChildProcess.make(shell, ShellSelect.args(shell, match[1] ?? ""), { + cwd: services.location.directory, + stdin: "ignore", + }), + { combineOutput: true }, + ) + .pipe( + Effect.map((result) => (result.output ?? Buffer.concat([result.stdout, result.stderr])).toString("utf8")), + ), + { concurrency: 2 }, + ) + const iterator = outputs[Symbol.iterator]() + return text.replace(shellRegex, () => iterator.next().value ?? "") + }) +} + +function parseArguments(input: string) { + return (input.match(argsRegex) ?? []).map((arg) => arg.replace(quoteTrimRegex, "")) +} + +const argsRegex = /(?:\[Image\s+\d+\]|"[^"]*"|'[^']*'|[^\s"']+)/gi +const placeholderRegex = /\$(\d+)/g +const quoteTrimRegex = /^["']|["']$/g +const shellRegex = /!`([^`]+)`/g diff --git a/packages/core/src/mcp/index.ts b/packages/core/src/mcp/index.ts index ba8aae073ce..05e156a705f 100644 --- a/packages/core/src/mcp/index.ts +++ b/packages/core/src/mcp/index.ts @@ -2,7 +2,6 @@ export * as MCP from "./index.js" import { Mcp } from "@opencode-ai/schema/mcp" import { McpEvent } from "@opencode-ai/schema/mcp-event" -import { Command } from "@opencode-ai/schema/command" import { createHash } from "node:crypto" import { isDeepStrictEqual } from "node:util" import { Cause, Context, Deferred, Effect, Exit, FiberSet, Layer, Schema, Scope, Stream, Types } from "effect" @@ -428,7 +427,7 @@ export const layer = (options?: Options) => Effect.map((defs) => { entry.prompts = defs.map((def) => toPrompt(name, def)) }), - Effect.andThen(bus.publish(Command.Event.Updated, {})), + Effect.andThen(bus.publish(McpEvent.PromptsChanged, { server: name })), ) // Runs a connection callback under the server lock, dropping it if the connection is no longer @@ -547,7 +546,7 @@ export const layer = (options?: Options) => yield* Scope.close(scope, Exit.void) yield* bus.publish(McpEvent.ToolsChanged, { server: name }).pipe(Effect.ignore) yield* bus.publish(McpEvent.ResourcesChanged, { server: name }).pipe(Effect.ignore) - yield* bus.publish(Command.Event.Updated, {}).pipe(Effect.ignore) + yield* bus.publish(McpEvent.PromptsChanged, { server: name }).pipe(Effect.ignore) }) const disposeServer = Effect.fnUntraced(function* (name: ServerName, entry: ServerEntry) { diff --git a/packages/core/src/plugin/command.ts b/packages/core/src/plugin/command.ts index 4a370efeddc..e835812136a 100644 --- a/packages/core/src/plugin/command.ts +++ b/packages/core/src/plugin/command.ts @@ -1,26 +1,116 @@ export * as CommandPlugin from "./command.js" import { define } from "@opencode-ai/plugin/effect/plugin" -import { Effect } from "effect" +import { McpEvent } from "@opencode-ai/schema/mcp-event" +import { Effect, Stream } from "effect" +import { Bus } from "../bus.js" import { Location } from "../location.js" -import { Command } from "../command.js" +import { MCP } from "../mcp/index.js" import PROMPT_INITIALIZE from "./command/initialize.txt" import PROMPT_REVIEW from "./command/review.txt" export const Plugin = define({ id: "opencode.command", - effect: Effect.fn(function* () { + effect: Effect.fn(function* (ctx) { const location = yield* Location.Service - const commands = yield* Command.Service - yield* commands.transform((draft) => { - draft.update("init", (command) => { - command.template = PROMPT_INITIALIZE.replace("${path}", location.project.directory) - command.description = "guided AGENTS.md setup" + const mcp = yield* MCP.Service + const bus = yield* Bus.Service + const loaded = { prompts: [] as MCP.Prompt[] } + yield* bus + .subscribe(McpEvent.PromptsChanged) + .pipe( + Stream.runForEach(() => + mcp.prompts().pipe( + Effect.tap((prompts) => Effect.sync(() => (loaded.prompts = prompts))), + Effect.andThen(ctx.command.reload()), + ), + ), + Effect.forkScoped({ startImmediately: true }), + ) + loaded.prompts = yield* mcp.prompts() + yield* ctx.command.transform((draft) => { + draft.add({ + name: "init", + description: "guided AGENTS.md setup", + execute: (input) => + ctx.session + .prompt({ + ...input.prompt, + sessionID: input.sessionID, + text: append(PROMPT_INITIALIZE.replace("${path}", location.project.directory), input.prompt.text), + delivery: input.delivery, + }) + .pipe(Effect.asVoid), }) - draft.update("review", (command) => { - command.template = PROMPT_REVIEW.replace("${path}", location.project.directory) - command.description = "review changes [commit|branch|pr], defaults to uncommitted" + draft.add({ + name: "review", + description: "review changes [commit|branch|pr], defaults to uncommitted", + execute: (input) => + ctx.session + .prompt({ + ...input.prompt, + sessionID: input.sessionID, + text: append(PROMPT_REVIEW.replace("${path}", location.project.directory), input.prompt.text), + delivery: input.delivery, + }) + .pipe(Effect.asVoid), }) + for (const prompt of loaded.prompts) { + draft.add({ + name: mcpCommandName(prompt.server, prompt.name), + description: prompt.description, + execute: (input) => + Effect.gen(function* () { + const result = yield* mcp.prompt({ + server: prompt.server, + name: prompt.name, + args: Object.fromEntries( + (prompt.arguments ?? []).map((argument, index) => [ + argument.name, + parseArguments(input.prompt.text)[index] ?? "", + ]), + ), + }) + if (!result) return yield* Effect.fail(new Error(`MCP prompt not found: ${prompt.server}:${prompt.name}`)) + yield* ctx.session.prompt({ + ...input.prompt, + sessionID: input.sessionID, + text: result.messages + .map((message) => promptMessageText(message.content)) + .join("\n") + .trim(), + delivery: input.delivery, + }) + }).pipe(Effect.asVoid), + }) + } }) }), }) + +function append(template: string, input: string) { + return [template, input.trim()].filter(Boolean).join("\n\n") +} + +function parseArguments(input: string) { + return (input.match(argsRegex) ?? []).map((argument) => argument.replace(quoteTrimRegex, "")) +} + +function promptMessageText(content: unknown) { + if (typeof content === "string") return content + if (!content || typeof content !== "object") return "" + if (!("type" in content) || content.type !== "text") return "" + if (!("text" in content) || typeof content.text !== "string") return "" + return content.text +} + +function mcpCommandName(server: string, prompt: string) { + return `${sanitize(server)}:${sanitize(prompt)}` +} + +function sanitize(value: string) { + return value.replace(/[^a-zA-Z0-9_-]/g, "_") +} + +const argsRegex = /(?:\[Image\s+\d+\]|"[^"]*"|'[^']*'|[^\s"']+)/gi +const quoteTrimRegex = /^["']|["']$/g diff --git a/packages/core/src/plugin/host.ts b/packages/core/src/plugin/host.ts index 0e9f780cc66..776976b503d 100644 --- a/packages/core/src/plugin/host.ts +++ b/packages/core/src/plugin/host.ts @@ -395,6 +395,8 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: import("../p input?.location ?? Location.Ref.make({ directory: location.directory, workspaceID: location.workspaceID }), }), get: (input) => runtime.session.get(input.sessionID), + switchAgent: runtime.session.switchAgent, + switchModel: runtime.session.switchModel, prompt: runtime.session.prompt, generate: (input) => runtime.session.generate(input).pipe(Effect.map((text) => ({ text }))), command: runtime.session.command, diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index a7df3882500..29f1b01a9c6 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -235,26 +235,14 @@ export interface Interface { prompt: string }) => Effect.Effect readonly command: (input: { - id?: SessionMessage.ID sessionID: SessionSchema.ID command: string - arguments?: string - agent?: Agent.ID - model?: Model.Ref + text: string files?: PromptInput.Prompt["files"] agents?: PromptInput.Prompt["agents"] skills?: PromptInput.Prompt["skills"] delivery?: SessionInbox.Delivery - resume?: boolean - }) => Effect.Effect< - SessionInbox.User, - | NotFoundError - | PromptConflictError - | AttachmentError - | SkillNotFoundError - | Command.NotFoundError - | Command.EvaluationError - > + }) => Effect.Effect readonly shell: (input: { id?: Event.ID sessionID: SessionSchema.ID @@ -629,35 +617,19 @@ const layer = Layer.effect( command: Effect.fn("Session.command")(function* (input) { const session = yield* result.get(input.sessionID) const commands = yield* Command.Service.pipe(Effect.provide(locations.get(session.location))) - const command = yield* commands.get(input.command) - if (!command) - return yield* new Command.NotFoundError({ - command: input.command, - message: `Command not found: ${input.command}`, - }) - const evaluated = yield* commands.evaluate({ name: input.command, arguments: input.arguments }) - - // TODO(v2 commands): decide whether command-level subtask/background execution belongs in v2 commands. - const agent = command.agent ?? input.agent - const commandAgent = yield* Effect.gen(function* () { - if (!command.agent) return undefined - const agents = yield* Agent.Service.pipe(Effect.provide(locations.get(session.location))) - return yield* agents.get(Agent.ID.make(command.agent)) - }) - const model = command.model ?? commandAgent?.model ?? input.model - if (agent !== undefined && session.agent !== Agent.ID.make(agent)) - yield* result.switchAgent({ sessionID: input.sessionID, agent: Agent.ID.make(agent) }) - if (model !== undefined) yield* result.switchModel({ sessionID: input.sessionID, model }) - - return yield* result.prompt({ - id: input.id, - sessionID: input.sessionID, - text: evaluated.text, - files: input.files, - agents: input.agents, - skills: input.skills, - delivery: input.delivery, - resume: input.resume, + const delivery = input.delivery ?? "steer" + yield* commands.execute({ + name: input.command, + invocation: { + sessionID: input.sessionID, + prompt: { + text: input.text, + files: input.files, + agents: input.agents, + skills: input.skills, + }, + delivery, + }, }) }), shell: Effect.fn("Session.shell")(function* (input) { diff --git a/packages/core/test/command.test.ts b/packages/core/test/command.test.ts index 3b91edf0873..0cd9f1f9346 100644 --- a/packages/core/test/command.test.ts +++ b/packages/core/test/command.test.ts @@ -1,23 +1,11 @@ import { describe, expect } from "bun:test" -import { Effect } from "effect" import { Command } from "@opencode-ai/core/command" -import { Config } from "@opencode-ai/core/config" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" -import { Location } from "@opencode-ai/core/location" -import { MCP } from "@opencode-ai/core/mcp/index" -import { Model } from "@opencode-ai/core/model" -import { Provider } from "@opencode-ai/core/provider" import { Session } from "@opencode-ai/schema/session" -import { emptyConfigLayer, emptyMcpLayer, testLocationLayer } from "./fixture/mcp" +import { Effect } from "effect" import { testEffect } from "./lib/effect" -const it = testEffect( - AppNodeBuilder.build(Command.node, [ - [MCP.node, emptyMcpLayer], - [Config.node, emptyConfigLayer], - [Location.node, testLocationLayer], - ]), -) +const it = testEffect(AppNodeBuilder.build(Command.node)) describe("Command", () => { it.effect("registers and executes callback commands", () => @@ -28,74 +16,32 @@ describe("Command", () => { draft.add({ name: "goal", description: "Manage the session goal", - run: (input) => Effect.sync(() => calls.push(input)), + execute: (input) => Effect.sync(() => calls.push(input)), }) }) expect(yield* command.get("goal")).toEqual( - Command.Info.make({ name: "goal", template: "", description: "Manage the session goal" }), + Command.Info.make({ name: "goal", description: "Manage the session goal" }), ) - const invocation = { sessionID: Session.ID.make("ses_test"), arguments: "ship it", delivery: "steer" as const } + const invocation = { + sessionID: Session.ID.make("ses_test"), + prompt: { text: "ship it", files: [{ uri: "file:///tmp/plan.md" }] }, + delivery: "steer" as const, + } yield* command.execute({ name: "goal", invocation }) expect(calls).toEqual([invocation]) }), ) - it.effect("applies command transforms and preserves later overrides", () => + it.effect("replaces commands with later definitions", () => Effect.gen(function* () { const command = yield* Command.Service - yield* command.transform((editor) => { - editor.update("review", (command) => { - command.template = "First" - command.description = "Review code" - }) - editor.update("review", (command) => { - command.template = "Second" - command.model = { - id: Model.ID.make("claude"), - providerID: Provider.ID.make("anthropic"), - variant: Model.VariantID.make("high"), - } - }) + yield* command.transform((draft) => { + draft.add({ name: "goal", description: "First", execute: () => Effect.void }) + draft.add({ name: "goal", description: "Second", execute: () => Effect.void }) }) - expect(yield* command.get("review")).toEqual( - Command.Info.make({ - name: "review", - template: "Second", - description: "Review code", - model: { - id: Model.ID.make("claude"), - providerID: Provider.ID.make("anthropic"), - variant: Model.VariantID.make("high"), - }, - }), - ) - expect(yield* command.list()).toEqual([ - Command.Info.make({ - name: "review", - template: "Second", - description: "Review code", - model: { - id: Model.ID.make("claude"), - providerID: Provider.ID.make("anthropic"), - variant: Model.VariantID.make("high"), - }, - }), - ]) - }), - ) - - it.effect("evaluates command template shell blocks", () => - Effect.gen(function* () { - const command = yield* Command.Service - yield* command.transform((editor) => { - editor.update("review", (command) => { - command.template = "Output: !`echo command-output`" - }) - }) - - expect((yield* command.evaluate({ name: "review" })).text.replace(/\r?\n$/, "")).toEqual("Output: command-output") + expect(yield* command.list()).toEqual([Command.Info.make({ name: "goal", description: "Second" })]) }), ) }) diff --git a/packages/core/test/config/command.test.ts b/packages/core/test/config/command.test.ts index c79ce451b42..d1f598eca58 100644 --- a/packages/core/test/config/command.test.ts +++ b/packages/core/test/config/command.test.ts @@ -5,7 +5,6 @@ import { Deferred, Effect, Fiber, Layer, Option, PubSub, Schema, Stream } from " import { advance, drain } from "../lib/clock" import { Directory, Document, Event, Info } from "@opencode-ai/schema/config" import { Command } from "@opencode-ai/core/command" -import { Agent } from "@opencode-ai/core/agent" import { Config } from "@opencode-ai/core/config" import { ConfigCommandPlugin } from "@opencode-ai/core/config/plugin/command" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" @@ -15,10 +14,9 @@ import { Bus } from "@opencode-ai/core/bus" import { Credential } from "@opencode-ai/core/credential" import { WellKnown } from "@opencode-ai/core/wellknown" import { Global } from "@opencode-ai/util/global" +import { AppProcess } from "@opencode-ai/util/process" import { Location } from "@opencode-ai/core/location" import { MCP } from "@opencode-ai/core/mcp/index" -import { Model } from "@opencode-ai/core/model" -import { Provider } from "@opencode-ai/core/provider" import { AbsolutePath } from "@opencode-ai/core/schema" import { Watcher } from "@opencode-ai/core/filesystem/watcher" import { emptyCredentialNode, emptyWellknownNode } from "../fixture/config-nodes" @@ -29,11 +27,14 @@ import { testEffect } from "../lib/effect" import { host } from "../plugin/host" const it = testEffect( - AppNodeBuilder.build(LayerNode.group([Command.node, Bus.node, FSUtil.node]), [ + AppNodeBuilder.build( + LayerNode.group([Command.node, Bus.node, FSUtil.node, AppProcess.node, Global.node, Location.node]), + [ [MCP.node, emptyMcpLayer], [Config.node, emptyConfigLayer], [Location.node, testLocationLayer], - ]), + ], + ), ) const decode = Schema.decodeUnknownSync(Info) @@ -89,28 +90,22 @@ Review files`, expect(yield* command.list()).toEqual([ Command.Info.make({ name: "review", - template: "Review files", description: "File review", - agent: Agent.ID.make("reviewer"), - model: { - providerID: Provider.ID.make("anthropic"), - id: Model.ID.make("claude"), - variant: Model.VariantID.make("high"), - }, - subtask: true, }), - Command.Info.make({ name: "empty", template: "" }), - Command.Info.make({ name: "nested/docs", template: "Write docs" }), + Command.Info.make({ name: "empty" }), + Command.Info.make({ name: "nested/docs" }), ]) - yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, "commands", "review.md"), "Review again")) + yield* Effect.promise(() => + fs.writeFile(path.join(tmp.path, "commands", "review.md"), markdown("Review again", "Review again")), + ) yield* Effect.sleep("10 millis") yield* PubSub.publish(updates, update) for (let attempt = 0; attempt < 100; attempt++) { - if ((yield* command.get("review"))?.template === "Review again") break + if ((yield* command.get("review"))?.description === "Review again") break yield* Effect.sleep("10 millis") } - expect((yield* command.get("review"))?.template).toBe("Review again") + expect((yield* command.get("review"))?.description).toBe("Review again") }), ), ), @@ -193,11 +188,13 @@ Review files`, yield* advance(() => reloads >= 1) expect(reloads).toBe(1) - yield* Effect.promise(() => fs.writeFile(path.join(directory, "review.md"), "Review twice")) + yield* Effect.promise(() => + fs.writeFile(path.join(directory, "review.md"), markdown("Review twice", "Review twice")), + ) yield* configTest.emitChange({ type: "update", path: path.join(directory, "review.md") }) yield* advance(() => reloads >= 2) expect(reloads).toBe(2) - expect((yield* command.get("review"))?.template).toBe("Review twice") + expect((yield* command.get("review"))?.description).toBe("Review twice") }).pipe(Effect.provide(Config.testLayer([directoryEntry(tmp.path)]))), ), ), @@ -232,10 +229,12 @@ Review files`, expect(reloads).toBe(0) // The feed stays live after unrelated updates. - yield* Effect.promise(() => fs.writeFile(path.join(directory, "review.md"), "Review related")) + yield* Effect.promise(() => + fs.writeFile(path.join(directory, "review.md"), markdown("Review related", "Review related")), + ) yield* configTest.emitChange({ type: "create", path: path.join(directory, "review.md") }) yield* advance(() => reloads >= 1) - expect((yield* command.get("review"))?.template).toBe("Review related") + expect((yield* command.get("review"))?.description).toBe("Review related") }).pipe(Effect.provide(Config.testLayer([directoryEntry(tmp.path)]))), ), ), @@ -272,17 +271,33 @@ describeNative("ConfigCommandPlugin native watcher", () => { yield* watchReady(config, global) const created = yield* nextCommandUpdate(bus) - yield* fs.writeFileString(path.join(global, "commands", "review.md"), "Review native") + yield* fs.writeFileString( + path.join(global, "commands", "review.md"), + markdown("Review native", "Review native"), + ) yield* Fiber.join(created).pipe(Effect.timeout("10 seconds")) - expect((yield* command.get("review"))?.template).toBe("Review native") + expect((yield* command.get("review"))?.description).toBe("Review native") const updated = yield* nextCommandUpdate(bus) - yield* fs.writeFileString(path.join(global, "commands", "review.md"), "Review native again") + yield* fs.writeFileString( + path.join(global, "commands", "review.md"), + markdown("Review native again", "Review native again"), + ) yield* Fiber.join(updated).pipe(Effect.timeout("10 seconds")) - expect((yield* command.get("review"))?.template).toBe("Review native again") + expect((yield* command.get("review"))?.description).toBe("Review native again") }).pipe( Effect.provide( - AppNodeBuilder.build(LayerNode.group([Command.node, Config.node, Bus.node, FSUtil.node]), [ + AppNodeBuilder.build( + LayerNode.group([ + Command.node, + Config.node, + Bus.node, + FSUtil.node, + AppProcess.node, + Global.node, + Location.node, + ]), + [ [ Location.node, Layer.succeed( @@ -293,7 +308,8 @@ describeNative("ConfigCommandPlugin native watcher", () => { [Global.node, Global.layerWith({ config: global, home: path.join(global, "home") })], [Credential.node, emptyCredentialNode], [WellKnown.node, emptyWellknownNode], - ]), + ], + ), ), ) }), @@ -337,6 +353,10 @@ function directoryEntry(directory: string) { return new Directory({ type: "directory", path: AbsolutePath.make(directory) }) } +function markdown(description: string, template: string) { + return `---\ndescription: ${description}\n---\n${template}` +} + function sourceCases() { return [ { @@ -345,33 +365,37 @@ function sourceCases() { mutate: (directory: string) => Effect.promise(async () => { const file = path.join(directory, "review.md") - await fs.writeFile(file, "Review created") + await fs.writeFile(file, markdown("Review created", "Review created")) return [{ type: "create" as const, path: file }] }), verify: (command: Command.Interface) => Effect.gen(function* () { - expect((yield* command.get("review"))?.template).toBe("Review created") + expect((yield* command.get("review"))?.description).toBe("Review created") }), }, { name: "updated", prepare: (directory: string) => - Effect.promise(() => fs.writeFile(path.join(directory, "review.md"), "Review first")), + Effect.promise(() => + fs.writeFile(path.join(directory, "review.md"), markdown("Review first", "Review first")), + ), mutate: (directory: string) => Effect.promise(async () => { const file = path.join(directory, "review.md") - await fs.writeFile(file, "Review updated") + await fs.writeFile(file, markdown("Review updated", "Review updated")) return [{ type: "update" as const, path: file }] }), verify: (command: Command.Interface) => Effect.gen(function* () { - expect((yield* command.get("review"))?.template).toBe("Review updated") + expect((yield* command.get("review"))?.description).toBe("Review updated") }), }, { name: "renamed", prepare: (directory: string) => - Effect.promise(() => fs.writeFile(path.join(directory, "review.md"), "Review renamed")), + Effect.promise(() => + fs.writeFile(path.join(directory, "review.md"), markdown("Review renamed", "Review renamed")), + ), mutate: (directory: string) => Effect.promise(async () => { const previous = path.join(directory, "review.md") @@ -385,7 +409,7 @@ function sourceCases() { verify: (command: Command.Interface) => Effect.gen(function* () { expect(yield* command.get("review")).toBeUndefined() - expect((yield* command.get("release"))?.template).toBe("Review renamed") + expect((yield* command.get("release"))?.description).toBe("Review renamed") }), }, { diff --git a/packages/core/test/plugin/command.test.ts b/packages/core/test/plugin/command.test.ts index f5dc96a8cea..d1f60aa1b08 100644 --- a/packages/core/test/plugin/command.test.ts +++ b/packages/core/test/plugin/command.test.ts @@ -1,10 +1,18 @@ import { describe, expect } from "bun:test" import { Effect, Layer } from "effect" import { Command } from "@opencode-ai/core/command" +import { Bus } from "@opencode-ai/core/bus" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { Location } from "@opencode-ai/core/location" +import { MCP } from "@opencode-ai/core/mcp/index" import { CommandPlugin } from "@opencode-ai/core/plugin/command" import { AbsolutePath } from "@opencode-ai/core/schema" +import { Session } from "@opencode-ai/schema/session" +import { SessionInbox } from "@opencode-ai/schema/session-inbox" +import { SessionMessage } from "@opencode-ai/schema/session-message" +import { LayerNode } from "@opencode-ai/util/effect/layer-node" +import { DateTime } from "effect" +import { emptyMcpLayer } from "../fixture/mcp" import { location } from "../fixture/location" import { testEffect } from "../lib/effect" import { host } from "./host" @@ -15,12 +23,18 @@ const locationLayer = Layer.succeed( Location.Service, Location.Service.of(location({ directory }, { projectDirectory: project })), ) -const it = testEffect(AppNodeBuilder.build(Command.node, [[Location.node, locationLayer]])) +const it = testEffect( + AppNodeBuilder.build(LayerNode.group([Command.node, MCP.node, Bus.node]), [ + [MCP.node, emptyMcpLayer], + [Location.node, locationLayer], + ]), +) describe("CommandPlugin.Plugin", () => { it.effect("registers built-in init and review commands", () => Effect.gen(function* () { const command = yield* Command.Service + const prompts: { text: string; files?: readonly { readonly uri: string }[] }[] = [] yield* CommandPlugin.Plugin.effect( host({ command: { @@ -28,6 +42,20 @@ describe("CommandPlugin.Plugin", () => { transform: command.transform, reload: command.reload, }, + session: { + prompt: (input) => + Effect.sync(() => { + prompts.push({ text: input.text, files: input.files }) + return SessionInbox.User.make({ + id: SessionMessage.ID.make("msg_test"), + sessionID: input.sessionID, + timeCreated: DateTime.makeUnsafe(0), + type: "user", + payload: { text: input.text }, + delivery: input.delivery ?? "steer", + }) + }), + }, }), ).pipe( Effect.provideService( @@ -40,11 +68,24 @@ describe("CommandPlugin.Plugin", () => { name: "init", description: "guided AGENTS.md setup", }) - expect((yield* command.get("init"))?.template).toContain("`/repo`") expect(yield* command.get("review")).toMatchObject({ name: "review", description: "review changes [commit|branch|pr], defaults to uncommitted", }) + yield* command.execute({ + name: "init", + invocation: { + sessionID: Session.ID.make("ses_test"), + prompt: { text: "extra context", files: [{ uri: "file:///tmp/context.md" }] }, + delivery: "queue", + }, + }) + expect(prompts).toEqual([ + { + text: expect.stringContaining("extra context"), + files: [{ uri: "file:///tmp/context.md" }], + }, + ]) }), ) }) diff --git a/packages/core/test/plugin/host.ts b/packages/core/test/plugin/host.ts index f6d5920fd4d..592e8acdaf5 100644 --- a/packages/core/test/plugin/host.ts +++ b/packages/core/test/plugin/host.ts @@ -111,6 +111,8 @@ export function host(overrides: Overrides = {}): Plugin.Context { hook: overrides.session?.hook ?? (() => Effect.die("unused session.hook")), create: overrides.session?.create ?? (() => Effect.die("unused session.create")), get: overrides.session?.get ?? (() => Effect.die("unused session.get")), + switchAgent: overrides.session?.switchAgent ?? (() => Effect.die("unused session.switchAgent")), + switchModel: overrides.session?.switchModel ?? (() => Effect.die("unused session.switchModel")), prompt: overrides.session?.prompt ?? (() => Effect.die("unused session.prompt")), generate: overrides.session?.generate ?? (() => Effect.die("unused session.generate")), command: overrides.session?.command ?? (() => Effect.die("unused session.command")), diff --git a/packages/plugin/src/effect/command.ts b/packages/plugin/src/effect/command.ts index 156c6ce6ba3..ebfa3df109a 100644 --- a/packages/plugin/src/effect/command.ts +++ b/packages/plugin/src/effect/command.ts @@ -1,4 +1,5 @@ import type { CommandApi } from "@opencode-ai/client/effect/api" +import type { PromptInput } from "@opencode-ai/schema/prompt-input" import type { Session } from "@opencode-ai/schema/session" import type { SessionInbox } from "@opencode-ai/schema/session-inbox" import type { Effect } from "effect" @@ -6,14 +7,14 @@ import type { Transform } from "./registration.js" export interface CommandInvocation { readonly sessionID: Session.ID - readonly arguments: string + readonly prompt: PromptInput.Prompt readonly delivery: SessionInbox.Delivery } export interface CommandDefinition { readonly name: string readonly description?: string - readonly run: (input: CommandInvocation) => Effect.Effect + readonly execute: (input: CommandInvocation) => Effect.Effect } export interface CommandDraft { diff --git a/packages/plugin/src/effect/session.ts b/packages/plugin/src/effect/session.ts index 41ea2918903..965aaea3508 100644 --- a/packages/plugin/src/effect/session.ts +++ b/packages/plugin/src/effect/session.ts @@ -47,7 +47,17 @@ export interface SessionHooks { export type SessionDomain = Pick< SessionApi, - "create" | "get" | "prompt" | "generate" | "command" | "synthetic" | "interrupt" | "rename" | "wait" + | "create" + | "get" + | "switchAgent" + | "switchModel" + | "prompt" + | "generate" + | "command" + | "synthetic" + | "interrupt" + | "rename" + | "wait" > & { readonly hook: ModelHooks } diff --git a/packages/plugin/src/promise/adapter.ts b/packages/plugin/src/promise/adapter.ts index 5975c9209bd..c1531d6786e 100644 --- a/packages/plugin/src/promise/adapter.ts +++ b/packages/plugin/src/promise/adapter.ts @@ -156,7 +156,8 @@ export function fromPromise(plugin: Plugin) { add: (definition) => draft.add({ ...definition, - run: (input) => Effect.promise(() => definition.run(input)), + execute: (input) => + Effect.tryPromise({ try: () => definition.execute(input), catch: (cause) => cause }), }), }), ), @@ -314,6 +315,8 @@ export function fromPromise(plugin: Plugin) { ), create: adaptApiMethod(SessionEndpoints["session.create"], host.session.create), get: adaptApiMethod(SessionEndpoints["session.get"], host.session.get), + switchAgent: adaptApiMethod(SessionEndpoints["session.switchAgent"], host.session.switchAgent), + switchModel: adaptApiMethod(SessionEndpoints["session.switchModel"], host.session.switchModel), prompt: adaptApiMethod(SessionEndpoints["session.prompt"], host.session.prompt), generate: adaptApiMethod(SessionEndpoints["session.generate"], host.session.generate), command: adaptApiMethod(SessionEndpoints["session.command"], host.session.command), diff --git a/packages/plugin/src/promise/command.ts b/packages/plugin/src/promise/command.ts index 58154529b70..bff26c1c5ff 100644 --- a/packages/plugin/src/promise/command.ts +++ b/packages/plugin/src/promise/command.ts @@ -1,18 +1,19 @@ import type { CommandApi } from "@opencode-ai/client/promise/api" +import type { PromptInput } from "@opencode-ai/schema/prompt-input" import type { Session } from "@opencode-ai/schema/session" import type { SessionInbox } from "@opencode-ai/schema/session-inbox" import type { Transform } from "./registration.js" export interface CommandInvocation { readonly sessionID: Session.ID - readonly arguments: string + readonly prompt: PromptInput.Prompt readonly delivery: SessionInbox.Delivery } export interface CommandDefinition { readonly name: string readonly description?: string - readonly run: (input: CommandInvocation) => Promise + readonly execute: (input: CommandInvocation) => Promise } export interface CommandDraft { diff --git a/packages/plugin/src/promise/session.ts b/packages/plugin/src/promise/session.ts index d4ea141154a..e87a5e0441f 100644 --- a/packages/plugin/src/promise/session.ts +++ b/packages/plugin/src/promise/session.ts @@ -47,7 +47,17 @@ export interface SessionHooks { export type SessionDomain = Pick< SessionApi, - "create" | "get" | "prompt" | "generate" | "command" | "synthetic" | "interrupt" | "rename" | "wait" + | "create" + | "get" + | "switchAgent" + | "switchModel" + | "prompt" + | "generate" + | "command" + | "synthetic" + | "interrupt" + | "rename" + | "wait" > & { readonly hook: ModelHooks } diff --git a/packages/protocol/src/errors.ts b/packages/protocol/src/errors.ts index f3240fa5c54..6380647efe4 100644 --- a/packages/protocol/src/errors.ts +++ b/packages/protocol/src/errors.ts @@ -117,8 +117,8 @@ export class CommandNotFoundError extends Schema.TaggedError()( - "CommandEvaluationError", +export class CommandExecutionError extends Schema.TaggedError()( + "CommandExecutionError", { command: Schema.String, message: Schema.String, diff --git a/packages/protocol/src/groups/session.ts b/packages/protocol/src/groups/session.ts index 5c6c9eb18b8..19392b782ff 100644 --- a/packages/protocol/src/groups/session.ts +++ b/packages/protocol/src/groups/session.ts @@ -12,7 +12,7 @@ import { Context, Effect, Encoding, Result, Schema, SchemaGetter, Struct } from import { HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" import { ConflictError, - CommandEvaluationError, + CommandExecutionError, CommandNotFoundError, InvalidCursorError, InvalidRequestError, @@ -338,27 +338,19 @@ export const makeSessionGroup = (sessionLo HttpApiEndpoint.post("session.command", "/api/session/:sessionID/command", { params: { sessionID: Session.ID }, payload: Schema.Struct({ - id: SessionMessage.ID.pipe(Schema.optional), command: Schema.String, - arguments: Schema.String.pipe(Schema.optional), - agent: Agent.ID.pipe(Schema.optional), - model: Model.Ref.pipe(Schema.optional), - files: PromptInput.Prompt.fields.files, - agents: PromptInput.Prompt.fields.agents, - skills: PromptInput.Prompt.fields.skills, + ...PromptInput.Prompt.fields, delivery: SessionInbox.Delivery.pipe(Schema.optional), - resume: Schema.Boolean.pipe(Schema.optional), }), - success: Schema.Struct({ data: SessionInbox.User }), - error: [ConflictError, InvalidRequestError, SessionNotFoundError, CommandNotFoundError, CommandEvaluationError], + success: HttpApiSchema.NoContent, + error: [SessionNotFoundError, CommandNotFoundError, CommandExecutionError], }) .middleware(sessionLocationMiddleware) .annotateMerge( OpenApi.annotations({ identifier: "v2.session.command", summary: "Run command", - description: - "Resolve a slash command into prompt input, admit it durably, and schedule execution unless resume is false.", + description: "Execute a slash command callback immediately.", }), ), ) diff --git a/packages/schema/src/command.ts b/packages/schema/src/command.ts index ef32acb8211..e1984a0e6af 100644 --- a/packages/schema/src/command.ts +++ b/packages/schema/src/command.ts @@ -3,19 +3,13 @@ export * as Command from "./command.js" import { Schema } from "effect" import { ephemeral, inventory } from "./event.js" import { optional } from "./schema.js" -import { Model } from "./model.js" -import { Agent } from "./agent.js" const Updated = ephemeral({ type: "command.updated", schema: {} }) export interface Info extends Schema.Schema.Type {} export const Info = Schema.Struct({ name: Schema.String, - template: Schema.String, description: Schema.String.pipe(optional), - agent: Agent.ID.pipe(optional), - model: Model.Ref.pipe(optional), - subtask: Schema.Boolean.pipe(optional), }).annotate({ identifier: "Command.Info" }) export const Event = { diff --git a/packages/schema/src/mcp-event.ts b/packages/schema/src/mcp-event.ts index 5e53ce0c78b..5b95d5d6d01 100644 --- a/packages/schema/src/mcp-event.ts +++ b/packages/schema/src/mcp-event.ts @@ -17,6 +17,13 @@ export const ResourcesChanged = Event.ephemeral({ }, }) +export const PromptsChanged = Event.ephemeral({ + type: "mcp.prompts.changed", + schema: { + server: Schema.String, + }, +}) + // Emitted whenever a server's connection status settles (connected, failed, needs_auth, closed) so // observers can refresh status without polling. export const StatusChanged = Event.ephemeral({ @@ -26,4 +33,4 @@ export const StatusChanged = Event.ephemeral({ }, }) -export const Definitions = Event.inventory(ToolsChanged, ResourcesChanged, StatusChanged) +export const Definitions = Event.inventory(ToolsChanged, ResourcesChanged, PromptsChanged, StatusChanged) diff --git a/packages/schema/test/event-manifest.test.ts b/packages/schema/test/event-manifest.test.ts index d61334c5518..0b3a5d42708 100644 --- a/packages/schema/test/event-manifest.test.ts +++ b/packages/schema/test/event-manifest.test.ts @@ -66,7 +66,12 @@ describe("public event manifest", () => { expect(Form.Event.Definitions).toEqual([Form.Event.Created, Form.Event.Replied, Form.Event.Cancelled]) expect(Reference.Event.Definitions).toEqual([Reference.Event.Updated]) expect(Plugin.Event.Definitions).toEqual([Plugin.Event.Added, Plugin.Event.Updated]) - expect(McpEvent.Definitions).toEqual([McpEvent.ToolsChanged, McpEvent.ResourcesChanged, McpEvent.StatusChanged]) + expect(McpEvent.Definitions).toEqual([ + McpEvent.ToolsChanged, + McpEvent.ResourcesChanged, + McpEvent.PromptsChanged, + McpEvent.StatusChanged, + ]) expect(EventManifest.Latest.has("mcp.browser.open.failed")).toBe(false) expect(EventManifest.Latest.has("ide.installed")).toBe(false) expect(IdeEvent.Definitions).toEqual([IdeEvent.Installed]) diff --git a/packages/server/src/handlers/session.ts b/packages/server/src/handlers/session.ts index e5e71eef312..c3c798d150b 100644 --- a/packages/server/src/handlers/session.ts +++ b/packages/server/src/handlers/session.ts @@ -7,7 +7,7 @@ import { Api } from "../api" import { SessionsCursor } from "@opencode-ai/protocol/groups/session" import { ConflictError, - CommandEvaluationError, + CommandExecutionError, CommandNotFoundError, InvalidRequestError, InvalidCursorError, @@ -365,62 +365,42 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl .handle( "session.command", Effect.fn(function* (ctx) { - return { - data: yield* session - .command({ - sessionID: ctx.params.sessionID, - id: ctx.payload.id, - command: ctx.payload.command, - arguments: ctx.payload.arguments, - agent: ctx.payload.agent, - model: ctx.payload.model, - files: ctx.payload.files, - agents: ctx.payload.agents, - skills: ctx.payload.skills, - delivery: ctx.payload.delivery, - resume: ctx.payload.resume, - }) - .pipe( - Effect.catchTag("Session.NotFoundError", (error) => - Effect.fail( - new SessionNotFoundError({ - sessionID: error.sessionID, - message: `Session not found: ${error.sessionID}`, - }), - ), - ), - Effect.catchTag("Command.NotFoundError", (error) => - Effect.fail( - new CommandNotFoundError({ - command: error.command, - message: error.message, - }), - ), - ), - Effect.catchTag("Command.EvaluationError", (error) => - Effect.fail( - new CommandEvaluationError({ - command: error.command, - message: error.message, - }), - ), - ), - Effect.catchTag("Session.PromptConflictError", (error) => - Effect.fail( - new ConflictError({ - message: `Prompt message ID conflicts with an existing durable record: ${error.messageID}`, - resource: error.messageID, - }), - ), - ), - Effect.catchTag("Session.AttachmentError", (error) => - Effect.fail(new InvalidRequestError({ message: error.message, field: "files" })), - ), - Effect.catchTag("Session.SkillNotFoundError", (error) => - Effect.fail(new InvalidRequestError({ message: `Skill not found: ${error.skill}`, field: "skills" })), + yield* session + .command({ + sessionID: ctx.params.sessionID, + command: ctx.payload.command, + text: ctx.payload.text, + files: ctx.payload.files, + agents: ctx.payload.agents, + skills: ctx.payload.skills, + delivery: ctx.payload.delivery, + }) + .pipe( + Effect.catchTag("Session.NotFoundError", (error) => + Effect.fail( + new SessionNotFoundError({ + sessionID: error.sessionID, + message: `Session not found: ${error.sessionID}`, + }), ), ), - } + Effect.catchTag("Command.NotFoundError", (error) => + Effect.fail( + new CommandNotFoundError({ + command: error.command, + message: error.message, + }), + ), + ), + Effect.catchTag("Command.ExecutionError", (error) => + Effect.fail( + new CommandExecutionError({ + command: error.command, + message: error.message, + }), + ), + ), + ) }), ) .handle( diff --git a/packages/server/src/routes.ts b/packages/server/src/routes.ts index 26e35d4322e..0744e83d860 100644 --- a/packages/server/src/routes.ts +++ b/packages/server/src/routes.ts @@ -115,7 +115,6 @@ function makeRoutes( }), ], [InstructionDiscovery.node, InstructionDiscovery.configured({ project: options.config?.project })], - [Command.node, Command.configured({ gitbash: options.windows?.gitbash })], [Pty.node, Pty.configured({ gitbash: options.windows?.gitbash })], [Shell.node, Shell.configured({ gitbash: options.windows?.gitbash })], [ diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index e3e37358a8d..e707ccae210 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -1234,22 +1234,31 @@ export function Prompt(props: PromptProps) { const model = { providerID: selection.providerID, id: selection.modelID, variant } const cancelCommit = local.model.trackSessionCommit(sessionID, model) - void client.api.session - .command({ + void (async () => { + if (!session) { + await data.session.sync(sessionID) + session = data.session.get(sessionID) + } + if (session?.agent !== agent.id) await client.api.session.switchAgent({ sessionID, agent: agent.id }) + if ( + session?.model?.providerID !== model.providerID || + session.model.id !== model.id || + session.model.variant !== model.variant + ) + await client.api.session.switchModel({ sessionID, model }) + await client.api.session.command({ sessionID, command: slashHead.name, - arguments: slashHead.arguments, - agent: agent.id, - model, + text: slashHead.arguments, files: store.prompt.files, agents: store.prompt.agents, skills: store.prompt.skills?.length ? store.prompt.skills : undefined, delivery, }) - .catch((error) => { - cancelCommit() - toast.show({ title: "Failed to run command", message: errorMessage(error), variant: "error" }) - }) + })().catch((error) => { + cancelCommit() + toast.show({ title: "Failed to run command", message: errorMessage(error), variant: "error" }) + }) } else if (isSkill) { move.startSubmit() void client.api.session.skill({ diff --git a/packages/tui/src/mini/stream-v2.transport.ts b/packages/tui/src/mini/stream-v2.transport.ts index 8756bd05457..38fdee36bc3 100644 --- a/packages/tui/src/mini/stream-v2.transport.ts +++ b/packages/tui/src/mini/stream-v2.transport.ts @@ -1649,15 +1649,16 @@ export async function createSessionTransport(input: StreamInput): Promise { data: { sessionID: "ses_1" }, }) }) - return ok({ - id: input.id ?? "msg_cmd", - sessionID: "ses_1", - type: "user" as const, - payload: { text: "evaluated template" }, - delivery: "steer" as const, - timeCreated: 2, - }) + return ok(undefined) }) await transport.runPromptTurn({ @@ -2852,11 +2845,8 @@ describe("V2 mini transport", () => { expect(request).toMatchObject({ sessionID: "ses_1", - id: "msg_cmd", command: "deploy", - arguments: "prod", - agent: "build", - model: { providerID: "test", id: "model" }, + text: "prod", files: [ { uri: "file:///tmp/context.txt", name: "context.txt" }, { @@ -2868,9 +2858,14 @@ describe("V2 mini transport", () => { skills: [{ id: "api-design", mention: { start: 13, end: 24, text: "/api-design" } }], delivery: "steer", }) - // Selection rides the command payload; no separate client-side switch. - expect(client.session.switchAgent).not.toHaveBeenCalled() - expect(client.session.switchModel).not.toHaveBeenCalled() + expect(client.session.switchAgent).toHaveBeenCalledWith( + { sessionID: "ses_1", agent: "build" }, + expect.anything(), + ) + expect(client.session.switchModel).toHaveBeenCalledWith( + { sessionID: "ses_1", model: { providerID: "test", id: "model", variant: undefined } }, + expect.anything(), + ) await transport.close() })