mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-22 17:43:44 -04:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ed3154df2a | |||
| 34c29df60d | |||
| 2efe5d1034 | |||
| 64f84c1475 | |||
| 2ada79018e |
@@ -67,32 +67,18 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
|
||||
cmd &&
|
||||
input.data.location.command.list({ directory: input.draft.sessionDirectory })?.some((item) => item.name === cmd)
|
||||
) {
|
||||
setBusy()
|
||||
try {
|
||||
const messageID = Identifier.ascending("message")
|
||||
await input.api.command({
|
||||
sessionID: input.draft.sessionID,
|
||||
id: messageID,
|
||||
command: cmd,
|
||||
arguments: tail.join(" "),
|
||||
agent: input.draft.agent,
|
||||
model: {
|
||||
id: input.draft.model.modelID,
|
||||
providerID: input.draft.model.providerID,
|
||||
variant: input.draft.variant,
|
||||
},
|
||||
files: await Promise.all(
|
||||
images.map(async (attachment) => ({
|
||||
uri: await blobDataUrl(attachment.blob, attachment.mime),
|
||||
name: attachment.filename,
|
||||
})),
|
||||
),
|
||||
})
|
||||
return true
|
||||
} catch (err) {
|
||||
setIdle()
|
||||
throw err
|
||||
}
|
||||
await input.api.command({
|
||||
sessionID: input.draft.sessionID,
|
||||
command: cmd,
|
||||
text: tail.join(" "),
|
||||
files: await Promise.all(
|
||||
images.map(async (attachment) => ({
|
||||
uri: await blobDataUrl(attachment.blob, attachment.mime),
|
||||
name: attachment.filename,
|
||||
})),
|
||||
),
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
const messageID = input.messageID ?? Identifier.ascending("message")
|
||||
@@ -446,16 +432,11 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
?.find((command) => command.name === commandName)
|
||||
if (customCommand) {
|
||||
clearInput()
|
||||
const messageID = Identifier.ascending("message")
|
||||
submissionData.session.setStatus(session.id, "running")
|
||||
void submissionServerSDK.api.session
|
||||
.command({
|
||||
sessionID: session.id,
|
||||
id: messageID,
|
||||
command: commandName,
|
||||
arguments: args.join(" "),
|
||||
agent,
|
||||
model: { id: model.modelID, providerID: model.providerID, variant },
|
||||
text: args.join(" "),
|
||||
files: await Promise.all(
|
||||
images.map(async (attachment) => ({
|
||||
uri: await blobDataUrl(attachment.blob, attachment.mime),
|
||||
@@ -464,7 +445,6 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
),
|
||||
})
|
||||
.catch((err) => {
|
||||
submissionData.session.setStatus(session.id, "idle")
|
||||
showToast({
|
||||
title: language.t("prompt.toast.commandSendFailed.title"),
|
||||
description: formatServerError(err, language.t, language.t("common.requestFailed")),
|
||||
|
||||
@@ -76,6 +76,7 @@ export async function streamTurn(input: {
|
||||
readonly cwd: string
|
||||
readonly start: TurnStart
|
||||
readonly writeTextFile: boolean
|
||||
readonly action?: boolean
|
||||
readonly submit: (signal: AbortSignal) => Promise<unknown>
|
||||
readonly control: TurnControl
|
||||
readonly childSessionUpdate?: (update: ChildSessionUpdate) => Promise<void>
|
||||
@@ -345,6 +346,11 @@ export async function streamTurn(input: {
|
||||
await input.submit(control.admission.signal).catch((error) => {
|
||||
if (!control.cancelled) throw error
|
||||
})
|
||||
if (input.action) {
|
||||
streamController.abort()
|
||||
await completed.catch(() => {})
|
||||
return response(undefined, undefined, "succeeded", control.cancelled, undefined)
|
||||
}
|
||||
if (control.cancelled) {
|
||||
await input.client.session.interrupt({ sessionID: input.sessionID }).catch(() => {})
|
||||
if (!started) {
|
||||
|
||||
@@ -326,6 +326,7 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
|
||||
cwd: state.cwd,
|
||||
start: prepared.start,
|
||||
writeTextFile: capabilities.writeTextFile,
|
||||
action: prepared.command !== undefined,
|
||||
control,
|
||||
connectionSignal: input.connection.signal,
|
||||
sessionSignal: state.abort.signal,
|
||||
@@ -377,9 +378,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",
|
||||
},
|
||||
|
||||
@@ -121,3 +121,42 @@ test("acp prompt resolves after ordered turn updates", async () => {
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`))
|
||||
}
|
||||
})
|
||||
|
||||
test("acp action resolves without prompt lifecycle events", async () => {
|
||||
const encoder = new TextEncoder()
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
fetch(request) {
|
||||
if (new URL(request.url).pathname !== "/api/event") return new Response(null, { status: 404 })
|
||||
return new Response(
|
||||
new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: "server.connected", data: {} })}\n\n`))
|
||||
},
|
||||
}),
|
||||
{ headers: { "content-type": "text/event-stream" } },
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
const response = await streamTurn({
|
||||
client: OpenCode.make({ baseUrl: server.url.toString() }),
|
||||
connection: {
|
||||
sessionUpdate: async () => {},
|
||||
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
|
||||
},
|
||||
sessionID: "ses_test",
|
||||
cwd: "/workspace",
|
||||
start: { type: "input", id: "msg_action" },
|
||||
writeTextFile: false,
|
||||
action: true,
|
||||
control: { cancelled: false, admission: new AbortController() },
|
||||
submit: async () => {},
|
||||
})
|
||||
|
||||
expect(response).toMatchObject({ stopReason: "end_turn" })
|
||||
} finally {
|
||||
await server.stop(true)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -87,7 +87,6 @@ export const planAgent = {
|
||||
export const reviewCommand = {
|
||||
name: "review",
|
||||
description: "Review changes",
|
||||
template: "",
|
||||
} satisfies CommandInfo
|
||||
|
||||
export const verifySkill = {
|
||||
|
||||
@@ -184,18 +184,14 @@ export type SessionPromptOperation<E = never> = (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<PromptInput.FileAttachment> | undefined
|
||||
readonly agents?: ReadonlyArray<AgentAttachment> | undefined
|
||||
readonly skills?: ReadonlyArray<PromptInput.SkillAttachment> | undefined
|
||||
readonly delivery?: SessionInbox.Delivery | undefined
|
||||
readonly resume?: boolean | undefined
|
||||
}
|
||||
export type Endpoint5_13Output = SessionInbox.User
|
||||
export type Endpoint5_13Output = void
|
||||
export type SessionCommandOperation<E = never> = (input: Endpoint5_13Input) => Effect.Effect<Endpoint5_13Output, E>
|
||||
|
||||
export type Endpoint5_14Input = {
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -607,28 +607,24 @@ export function make(options: ClientOptions) {
|
||||
requestOptions,
|
||||
).then((value) => value.data),
|
||||
command: (input: SessionCommandInput, requestOptions?: RequestOptions) =>
|
||||
request<{ readonly data: SessionCommandOutput }>(
|
||||
request<SessionCommandOutput>(
|
||||
{
|
||||
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<SessionSkillOutput>(
|
||||
{
|
||||
|
||||
@@ -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"]
|
||||
|
||||
+69
-241
@@ -1,28 +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 { State } from "./state.js"
|
||||
import { MCP } from "./mcp/index.js"
|
||||
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 { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { 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 prompt: PromptInput.Prompt
|
||||
readonly delivery: SessionInbox.Delivery
|
||||
}
|
||||
|
||||
export type Data = {
|
||||
commands: Map<string, Types.DeepMutable<Info>>
|
||||
export interface Definition {
|
||||
readonly name: string
|
||||
readonly description?: string
|
||||
readonly execute: (input: Invocation) => Effect.Effect<void, unknown>
|
||||
}
|
||||
|
||||
export type Draft = {
|
||||
add: (definition: Definition) => void
|
||||
}
|
||||
|
||||
export class NotFoundError extends Schema.TaggedError<NotFoundError>()("Command.NotFoundError", {
|
||||
@@ -30,249 +34,73 @@ export class NotFoundError extends Schema.TaggedError<NotFoundError>()("Command.
|
||||
message: Schema.String,
|
||||
}) {}
|
||||
|
||||
export class EvaluationError extends Schema.TaggedError<EvaluationError>()("Command.EvaluationError", {
|
||||
export class ExecutionError extends Schema.TaggedError<ExecutionError>()("Command.ExecutionError", {
|
||||
command: Schema.String,
|
||||
message: Schema.String,
|
||||
}) {}
|
||||
|
||||
export type Draft = {
|
||||
list: () => readonly Info[]
|
||||
get: (name: string) => Info | undefined
|
||||
update: (name: string, update: (command: Types.DeepMutable<Info>) => void) => void
|
||||
remove: (name: string) => void
|
||||
}
|
||||
|
||||
export interface Interface extends State.Transformable<Draft> {
|
||||
readonly get: (name: string) => Effect.Effect<Info | undefined>
|
||||
readonly list: () => Effect.Effect<Info[]>
|
||||
readonly evaluate: (input: {
|
||||
readonly execute: (input: {
|
||||
readonly name: string
|
||||
readonly arguments?: string
|
||||
}) => Effect.Effect<Evaluation, NotFoundError | EvaluationError>
|
||||
readonly invocation: Invocation
|
||||
}) => Effect.Effect<void, NotFoundError | ExecutionError>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@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<Data, Draft>({
|
||||
name: "command",
|
||||
initial: () => ({ commands: new Map() }),
|
||||
draft: (draft) => ({
|
||||
list: () => Array.from(draft.commands.values()) as Info[],
|
||||
get: (name) => draft.commands.get(name),
|
||||
update: (name, update) => {
|
||||
const current = draft.commands.get(name) ?? ({ name, template: "" } as Types.DeepMutable<Info>)
|
||||
if (!draft.commands.has(name)) draft.commands.set(name, current)
|
||||
update(current)
|
||||
current.name = name
|
||||
},
|
||||
remove: (name) => {
|
||||
draft.commands.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<Map<string, Definition>, 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))]
|
||||
}),
|
||||
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,
|
||||
},
|
||||
),
|
||||
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}` })
|
||||
return yield* definition.execute(input.invocation).pipe(
|
||||
Effect.tapError((error) => Effect.logError("command execution failed", { command: input.name, error })),
|
||||
Effect.mapError((error) => new ExecutionError({ command: input.name, message: errorMessage(error) })),
|
||||
)
|
||||
.pipe(
|
||||
Effect.map((result) => (result.output ?? Buffer.concat([result.stdout, result.stderr])).toString("utf8")),
|
||||
Effect.mapError(
|
||||
(error) =>
|
||||
new EvaluationError({
|
||||
command,
|
||||
message: `Shell interpolation failed for ${JSON.stringify(source)}: ${error.message}`,
|
||||
}),
|
||||
),
|
||||
)
|
||||
},
|
||||
{ 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 errorMessage(error: unknown) {
|
||||
if (error instanceof Error) return error.message
|
||||
if (typeof error === "string") return error
|
||||
if (error && typeof error === "object" && "message" in error && typeof error.message === "string")
|
||||
return error.message
|
||||
return "Command execution failed"
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
@@ -1,12 +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 { Model } from "@opencode-ai/schema/model"
|
||||
import { Provider } from "@opencode-ai/schema/provider"
|
||||
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 { Shell } from "../../shell.js"
|
||||
import { ShellSelect } from "../../shell/select.js"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { ConfigMarkdown } from "../markdown.js"
|
||||
|
||||
@@ -17,6 +24,9 @@ export const Plugin = define({
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const config = yield* Config.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const location = yield* Location.Service
|
||||
const processes = yield* AppProcess.Service
|
||||
const shell = yield* Shell.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 }])
|
||||
@@ -53,17 +63,41 @@ export const Plugin = define({
|
||||
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 = command.agent
|
||||
if (command.model !== undefined)
|
||||
item.model = {
|
||||
id: command.model.model,
|
||||
providerID: command.model.providerID,
|
||||
...(command.model.variant === undefined ? {} : { variant: 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 commandAgent = yield* Effect.gen(function* () {
|
||||
if (agent === undefined) return
|
||||
const session = yield* ctx.session.get({ sessionID: input.sessionID })
|
||||
if (session.agent !== agent) yield* ctx.session.switchAgent({ sessionID: input.sessionID, agent })
|
||||
return (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,
|
||||
shell,
|
||||
}),
|
||||
delivery: input.delivery,
|
||||
})
|
||||
}).pipe(Effect.asVoid),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -116,3 +150,67 @@ 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 shell: Shell.Interface
|
||||
},
|
||||
) {
|
||||
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 = yield* services.shell.name()
|
||||
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")),
|
||||
Effect.mapError((error) =>
|
||||
new Error(`Shell interpolation failed for ${JSON.stringify(source)}: ${error.message}`),
|
||||
),
|
||||
)
|
||||
},
|
||||
{ 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
|
||||
|
||||
@@ -2,7 +2,7 @@ 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 { ephemeral } from "@opencode-ai/schema/event"
|
||||
import { createHash } from "node:crypto"
|
||||
import { isDeepStrictEqual } from "node:util"
|
||||
import { Cause, Context, Deferred, Effect, Exit, FiberSet, Layer, Schema, Scope, Stream, Types } from "effect"
|
||||
@@ -19,6 +19,7 @@ import { State } from "../state.js"
|
||||
import type { MCPClient } from "./client.js"
|
||||
|
||||
export const ServerName = Schema.String.pipe(Schema.brand("MCP.ServerName"))
|
||||
export const PromptsChanged = ephemeral({ type: "mcp.prompts.changed", schema: { server: Schema.String } })
|
||||
export type ServerName = typeof ServerName.Type
|
||||
|
||||
// The status union is a public wire contract, so it lives in @opencode-ai/schema and is re-exported here.
|
||||
@@ -428,7 +429,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(PromptsChanged, { server: name })),
|
||||
)
|
||||
|
||||
// Runs a connection callback under the server lock, dropping it if the connection is no longer
|
||||
@@ -547,7 +548,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(PromptsChanged, { server: name }).pipe(Effect.ignore)
|
||||
})
|
||||
|
||||
const disposeServer = Effect.fnUntraced(function* (name: ServerName, entry: ServerEntry) {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
export * as CommandPlugin from "./command.js"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Effect } from "effect"
|
||||
import { Effect, Stream } from "effect"
|
||||
import { Bus } from "../bus.js"
|
||||
import { Location } from "../location.js"
|
||||
import { MCP } from "../mcp/index.js"
|
||||
import PROMPT_INITIALIZE from "./command/initialize.txt"
|
||||
import PROMPT_REVIEW from "./command/review.txt"
|
||||
|
||||
@@ -10,15 +12,104 @@ export const Plugin = define({
|
||||
id: "opencode.command",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const location = yield* Location.Service
|
||||
const mcp = yield* MCP.Service
|
||||
const bus = yield* Bus.Service
|
||||
const loaded = { prompts: [] as MCP.Prompt[] }
|
||||
yield* bus
|
||||
.subscribe(MCP.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.update("init", (command) => {
|
||||
command.template = PROMPT_INITIALIZE.replace("${path}", location.project.directory)
|
||||
command.description = "guided AGENTS.md setup"
|
||||
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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -235,26 +235,14 @@ export interface Interface {
|
||||
prompt: string
|
||||
}) => Effect.Effect<string, NotFoundError | SessionGenerate.Error>
|
||||
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<void, NotFoundError | Command.NotFoundError | Command.ExecutionError>
|
||||
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) {
|
||||
|
||||
@@ -1,79 +1,71 @@
|
||||
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 { emptyConfigLayer, emptyMcpLayer, testLocationLayer } from "./fixture/mcp"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
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("applies command transforms and preserves later overrides", () =>
|
||||
it.effect("registers and executes callback commands", () =>
|
||||
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"),
|
||||
}
|
||||
const calls: Command.Invocation[] = []
|
||||
yield* command.transform((draft) => {
|
||||
draft.add({
|
||||
name: "goal",
|
||||
description: "Manage the session goal",
|
||||
execute: (input) => Effect.sync(() => calls.push(input)),
|
||||
})
|
||||
})
|
||||
|
||||
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.get("goal")).toEqual(
|
||||
Command.Info.make({ name: "goal", description: "Manage the session goal" }),
|
||||
)
|
||||
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"),
|
||||
},
|
||||
}),
|
||||
])
|
||||
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("evaluates command template shell blocks", () =>
|
||||
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 = "Output: !`echo command-output`"
|
||||
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.list()).toEqual([Command.Info.make({ name: "goal", description: "Second" })])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("returns callback error messages without stack traces", () =>
|
||||
Effect.gen(function* () {
|
||||
const command = yield* Command.Service
|
||||
yield* command.transform((draft) => {
|
||||
draft.add({
|
||||
name: "fail",
|
||||
execute: () => Effect.fail(new Error("command failed")),
|
||||
})
|
||||
})
|
||||
|
||||
expect((yield* command.evaluate({ name: "review" })).text.replace(/\r?\n$/, "")).toEqual("Output: command-output")
|
||||
const error = yield* command
|
||||
.execute({
|
||||
name: "fail",
|
||||
invocation: {
|
||||
sessionID: Session.ID.make("ses_test"),
|
||||
prompt: { text: "" },
|
||||
delivery: "steer",
|
||||
},
|
||||
})
|
||||
.pipe(Effect.flip)
|
||||
expect(error).toMatchObject({ _tag: "Command.ExecutionError", message: "command failed" })
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Deferred, Effect, Fiber, Layer, Option, PubSub, Schema, Stream } from "effect"
|
||||
import { DateTime, Deferred, Effect, Fiber, Layer, Option, PubSub, Schema, Stream } from "effect"
|
||||
import { advance, drain } from "../lib/clock"
|
||||
import { Directory, Document, Event, Info } from "@opencode-ai/schema/config"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
import { SessionInbox } from "@opencode-ai/schema/session-inbox"
|
||||
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
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,11 +17,11 @@ 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 { Shell } from "@opencode-ai/core/shell"
|
||||
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
|
||||
import { emptyCredentialNode, emptyWellknownNode } from "../fixture/config-nodes"
|
||||
import { emptyConfigLayer, emptyMcpLayer, testLocationLayer } from "../fixture/mcp"
|
||||
@@ -28,12 +30,30 @@ import { tmpdir } from "../fixture/tmpdir"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { host } from "../plugin/host"
|
||||
|
||||
const shellLayer = Layer.succeed(
|
||||
Shell.Service,
|
||||
Shell.Service.of({
|
||||
name: () => Effect.succeed("sh"),
|
||||
create: () => Effect.die("unused shell.create"),
|
||||
list: () => Effect.die("unused shell.list"),
|
||||
get: () => Effect.die("unused shell.get"),
|
||||
wait: () => Effect.die("unused shell.wait"),
|
||||
timeout: () => Effect.die("unused shell.timeout"),
|
||||
output: () => Effect.die("unused shell.output"),
|
||||
remove: () => Effect.die("unused shell.remove"),
|
||||
}),
|
||||
)
|
||||
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([Command.node, Bus.node, FSUtil.node]), [
|
||||
[MCP.node, emptyMcpLayer],
|
||||
[Config.node, emptyConfigLayer],
|
||||
[Location.node, testLocationLayer],
|
||||
]),
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([Command.node, Bus.node, FSUtil.node, AppProcess.node, Location.node, Shell.node]),
|
||||
[
|
||||
[MCP.node, emptyMcpLayer],
|
||||
[Config.node, emptyConfigLayer],
|
||||
[Location.node, testLocationLayer],
|
||||
[Shell.node, shellLayer],
|
||||
],
|
||||
),
|
||||
)
|
||||
const decode = Schema.decodeUnknownSync(Info)
|
||||
|
||||
@@ -65,6 +85,7 @@ Review files`,
|
||||
const bus = yield* Bus.Service
|
||||
const update = yield* bus.publish(Event.Updated, {})
|
||||
const updates = yield* PubSub.unbounded<typeof update>()
|
||||
const prompts: { text: string; files?: readonly { readonly uri: string }[]; delivery?: string }[] = []
|
||||
yield* ConfigCommandPlugin.Plugin.effect(
|
||||
host({
|
||||
command: {
|
||||
@@ -73,6 +94,20 @@ Review files`,
|
||||
reload: command.reload,
|
||||
},
|
||||
event: { subscribe: () => Stream.fromPubSub(updates) },
|
||||
session: {
|
||||
prompt: (input) =>
|
||||
Effect.sync(() => {
|
||||
prompts.push({ text: input.text, files: input.files, delivery: input.delivery })
|
||||
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.provide(
|
||||
@@ -89,28 +124,46 @@ 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* command.execute({
|
||||
name: "nested/docs",
|
||||
invocation: {
|
||||
sessionID: Session.ID.make("ses_test"),
|
||||
prompt: { text: "details", files: [{ uri: "file:///tmp/context.md" }] },
|
||||
delivery: "queue",
|
||||
},
|
||||
})
|
||||
expect(prompts).toEqual([
|
||||
{
|
||||
text: "Write docs\n\ndetails",
|
||||
files: [{ uri: "file:///tmp/context.md" }],
|
||||
delivery: "queue",
|
||||
},
|
||||
])
|
||||
|
||||
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")
|
||||
yield* command.execute({
|
||||
name: "review",
|
||||
invocation: {
|
||||
sessionID: Session.ID.make("ses_test"),
|
||||
prompt: { text: "latest" },
|
||||
delivery: "steer",
|
||||
},
|
||||
})
|
||||
expect(prompts.at(-1)?.text).toBe("Review again\n\nlatest")
|
||||
}),
|
||||
),
|
||||
),
|
||||
@@ -193,11 +246,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 +287,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,28 +329,47 @@ 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,
|
||||
Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(location({ directory: AbsolutePath.make(path.join(tmp, "project")) })),
|
||||
),
|
||||
Shell.node,
|
||||
]),
|
||||
[
|
||||
[
|
||||
Location.node,
|
||||
Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(location({ directory: AbsolutePath.make(path.join(tmp, "project")) })),
|
||||
),
|
||||
],
|
||||
[Global.node, Global.layerWith({ config: global, home: path.join(global, "home") })],
|
||||
[Shell.node, shellLayer],
|
||||
[Credential.node, emptyCredentialNode],
|
||||
[WellKnown.node, emptyWellknownNode],
|
||||
],
|
||||
[Global.node, Global.layerWith({ config: global, home: path.join(global, "home") })],
|
||||
[Credential.node, emptyCredentialNode],
|
||||
[WellKnown.node, emptyWellknownNode],
|
||||
]),
|
||||
),
|
||||
),
|
||||
)
|
||||
}),
|
||||
@@ -337,6 +413,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 +425,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 +469,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")
|
||||
}),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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" }],
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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")),
|
||||
|
||||
@@ -1,16 +1,27 @@
|
||||
import type { CommandApi } from "@opencode-ai/client/effect/api"
|
||||
import type { CommandInfo } from "@opencode-ai/client"
|
||||
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"
|
||||
import type { Transform } from "./registration.js"
|
||||
|
||||
export interface CommandDraft {
|
||||
list(): readonly CommandInfo[]
|
||||
get(name: string): CommandInfo | undefined
|
||||
update(name: string, update: (command: CommandInfo) => void): void
|
||||
remove(name: string): void
|
||||
export interface CommandInvocation {
|
||||
readonly sessionID: Session.ID
|
||||
readonly prompt: PromptInput.Prompt
|
||||
readonly delivery: SessionInbox.Delivery
|
||||
}
|
||||
|
||||
export interface CommandDomain extends CommandApi<unknown> {
|
||||
export interface CommandDefinition {
|
||||
readonly name: string
|
||||
readonly description?: string
|
||||
readonly execute: (input: CommandInvocation) => Effect.Effect<void, unknown>
|
||||
}
|
||||
|
||||
export interface CommandDraft {
|
||||
add(definition: CommandDefinition): void
|
||||
}
|
||||
|
||||
export interface CommandDomain extends Pick<CommandApi<unknown>, "list"> {
|
||||
readonly transform: Transform<CommandDraft>
|
||||
readonly reload: () => Effect.Effect<void>
|
||||
}
|
||||
|
||||
@@ -47,7 +47,17 @@ export interface SessionHooks {
|
||||
|
||||
export type SessionDomain = Pick<
|
||||
SessionApi<unknown>,
|
||||
"create" | "get" | "prompt" | "generate" | "command" | "synthetic" | "interrupt" | "rename" | "wait"
|
||||
| "create"
|
||||
| "get"
|
||||
| "switchAgent"
|
||||
| "switchModel"
|
||||
| "prompt"
|
||||
| "generate"
|
||||
| "command"
|
||||
| "synthetic"
|
||||
| "interrupt"
|
||||
| "rename"
|
||||
| "wait"
|
||||
> & {
|
||||
readonly hook: ModelHooks<SessionHooks>
|
||||
}
|
||||
|
||||
@@ -149,7 +149,19 @@ export function fromPromise(plugin: Plugin) {
|
||||
},
|
||||
command: {
|
||||
list: adaptApiMethod(CommandEndpoints["command.list"], host.command.list),
|
||||
transform: transform(host.command),
|
||||
transform: (callback) =>
|
||||
register(
|
||||
host.command.transform((draft) =>
|
||||
callback({
|
||||
add: (definition) =>
|
||||
draft.add({
|
||||
...definition,
|
||||
execute: (input) =>
|
||||
Effect.tryPromise({ try: () => definition.execute(input), catch: (cause) => cause }),
|
||||
}),
|
||||
}),
|
||||
),
|
||||
),
|
||||
reload: () => run(host.command.reload()),
|
||||
},
|
||||
event: {
|
||||
@@ -303,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),
|
||||
|
||||
@@ -1,15 +1,26 @@
|
||||
import type { CommandApi } from "@opencode-ai/client/promise/api"
|
||||
import type { CommandInfo } from "@opencode-ai/client"
|
||||
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 CommandDraft {
|
||||
list(): readonly CommandInfo[]
|
||||
get(name: string): CommandInfo | undefined
|
||||
update(name: string, update: (command: CommandInfo) => void): void
|
||||
remove(name: string): void
|
||||
export interface CommandInvocation {
|
||||
readonly sessionID: Session.ID
|
||||
readonly prompt: PromptInput.Prompt
|
||||
readonly delivery: SessionInbox.Delivery
|
||||
}
|
||||
|
||||
export interface CommandDomain extends CommandApi {
|
||||
export interface CommandDefinition {
|
||||
readonly name: string
|
||||
readonly description?: string
|
||||
readonly execute: (input: CommandInvocation) => Promise<void>
|
||||
}
|
||||
|
||||
export interface CommandDraft {
|
||||
add(definition: CommandDefinition): void
|
||||
}
|
||||
|
||||
export interface CommandDomain extends Pick<CommandApi, "list"> {
|
||||
readonly transform: Transform<CommandDraft>
|
||||
readonly reload: () => Promise<void>
|
||||
}
|
||||
|
||||
@@ -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<SessionHooks>
|
||||
}
|
||||
|
||||
+2265
-688
File diff suppressed because it is too large
Load Diff
@@ -117,8 +117,8 @@ export class CommandNotFoundError extends Schema.TaggedError<CommandNotFoundErro
|
||||
{ httpApiStatus: 404 },
|
||||
) {}
|
||||
|
||||
export class CommandEvaluationError extends Schema.TaggedError<CommandEvaluationError>()(
|
||||
"CommandEvaluationError",
|
||||
export class CommandExecutionError extends Schema.TaggedError<CommandExecutionError>()(
|
||||
"CommandExecutionError",
|
||||
{
|
||||
command: Schema.String,
|
||||
message: Schema.String,
|
||||
|
||||
@@ -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 = <I extends HttpApiMiddleware.AnyId, S>(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.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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<typeof Info> {}
|
||||
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 = {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -115,7 +115,6 @@ function makeRoutes<AuthError, AuthServices>(
|
||||
}),
|
||||
],
|
||||
[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 })],
|
||||
[
|
||||
|
||||
@@ -1230,24 +1230,17 @@ export function Prompt(props: PromptProps) {
|
||||
})
|
||||
setStore("mode", "normal")
|
||||
} else if (slashHead && isCommand) {
|
||||
move.startSubmit()
|
||||
const model = { providerID: selection.providerID, id: selection.modelID, variant }
|
||||
const cancelCommit = local.model.trackSessionCommit(sessionID, model)
|
||||
|
||||
void 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" })
|
||||
})
|
||||
} else if (isSkill) {
|
||||
|
||||
@@ -1647,17 +1647,12 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||
)
|
||||
}
|
||||
|
||||
const selected = await resolveSelectedModel(input, client, next)
|
||||
if (next.variant && !selected) throw new Error("Cannot select a variant before selecting a model")
|
||||
input.trace?.write("send.command", { sessionID: input.sessionID, messageID, command: command.name, delivery })
|
||||
return client.session.command(
|
||||
{
|
||||
sessionID: input.sessionID,
|
||||
id: messageID,
|
||||
command: command.name,
|
||||
arguments: command.arguments,
|
||||
agent: next.agent,
|
||||
model: selected,
|
||||
text: command.arguments,
|
||||
files: attachments.files.length ? attachments.files : undefined,
|
||||
agents: agents.length ? agents : undefined,
|
||||
skills: skills.length ? skills : undefined,
|
||||
@@ -1698,7 +1693,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||
throw new Error("This prompt cannot be queued")
|
||||
if (!state.connected) throw new Error("Event stream is reconnecting")
|
||||
const client = sdk
|
||||
if (next.agent)
|
||||
if (!next.prompt.command && next.agent)
|
||||
await client.session.switchAgent({ sessionID: input.sessionID, agent: next.agent }, { signal: next.signal })
|
||||
if (!next.prompt.command) {
|
||||
const selected = await resolveSelectedModel(input, client, next)
|
||||
@@ -1706,7 +1701,8 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||
if (selected)
|
||||
await client.session.switchModel({ sessionID: input.sessionID, model: selected }, { signal: next.signal })
|
||||
}
|
||||
mergePending(await admitPrompt(next, client, delivery))
|
||||
const admitted = await admitPrompt(next, client, delivery)
|
||||
if (admitted) mergePending(admitted)
|
||||
settlementClient = client
|
||||
},
|
||||
async waitForIdle() {
|
||||
@@ -1744,13 +1740,8 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||
return
|
||||
}
|
||||
if (command) {
|
||||
await runTurnWait(
|
||||
next,
|
||||
messageID,
|
||||
client,
|
||||
() => admitPrompt(next, client, next.prompt.delivery ?? "steer"),
|
||||
admitted,
|
||||
)
|
||||
await admitPrompt(next, client, next.prompt.delivery ?? "steer")
|
||||
admitted?.()
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -2814,14 +2814,7 @@ describe("V2 mini transport", () => {
|
||||
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,7 +2858,6 @@ 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()
|
||||
await transport.close()
|
||||
|
||||
Reference in New Issue
Block a user