mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-15 21:36:21 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97769c4c4a | ||
|
|
9295a6e60f |
@@ -59,15 +59,7 @@ export const isContextOverflowFailure = (failure: unknown) =>
|
||||
: Schema.is(ProviderErrorEvent)(failure) && failure.classification === "context-overflow"
|
||||
|
||||
const decodeJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown))
|
||||
// OpenCode Zen reports account caps as typed 429/402 errors that are not throttles.
|
||||
const QUOTA_CODES = new Set([
|
||||
"insufficient_quota",
|
||||
"usage_not_included",
|
||||
"billing_error",
|
||||
"gousagelimiterror",
|
||||
"freeusagelimiterror",
|
||||
"creditlimitexceeded",
|
||||
])
|
||||
const QUOTA_CODES = new Set(["insufficient_quota", "usage_not_included", "billing_error"])
|
||||
const AUTH_CODES = new Set(["authentication_error", "permission_error"])
|
||||
const SERVER_CODES = new Set([
|
||||
"api_error",
|
||||
@@ -95,8 +87,7 @@ const CONTENT_POLICY_CODES = new Set([
|
||||
// as a `[code]` label at the start of the rewritten message.
|
||||
const GATEWAY_CODE_LABEL = /^[^:\n]+: \[([A-Za-z0-9_.-]+)\]/
|
||||
const RATE_LIMIT_TEXT = /rate increased too quickly|rate[-_\s]?limit|too[_\s]?many[_\s]?requests/i
|
||||
// Only consulted on 429, where throttles and account caps share a status.
|
||||
const QUOTA_TEXT = /insufficient[-_\s]?quota|quota[-_\s]?exceeded|budget exceeded|usage limit/i
|
||||
const QUOTA_TEXT = /insufficient[-_\s]?quota|quota[-_\s]?exceeded/i
|
||||
// Policy rejections without a dedicated code, matched against the provider's own
|
||||
// explanation only. OpenAI reuses `invalid_prompt` for usage-policy rejections while
|
||||
// Bedrock Mantle reuses it for schema validation; Anthropic reports blocked output
|
||||
@@ -152,11 +143,7 @@ export function classifyProviderFailure(input: ProviderFailure): AIError["reason
|
||||
return new InvalidRequestError({ ...details, classification: "payload-too-large" })
|
||||
if (codes.some((code) => CONTENT_POLICY_CODES.has(code)) || (clientScoped && CONTENT_POLICY_TEXT.test(input.message)))
|
||||
return new ContentPolicyError(details)
|
||||
if (
|
||||
input.status === 402 ||
|
||||
codes.some((code) => QUOTA_CODES.has(code)) ||
|
||||
(input.status === 429 && QUOTA_TEXT.test(text))
|
||||
)
|
||||
if (codes.some((code) => QUOTA_CODES.has(code)) || (input.status === 429 && QUOTA_TEXT.test(text)))
|
||||
return new QuotaExceededError(details)
|
||||
if (input.status === 401 || input.status === 403 || codes.some((code) => AUTH_CODES.has(code)))
|
||||
return new AuthenticationError(details)
|
||||
@@ -176,12 +163,10 @@ export function classifyProviderFailure(input: ProviderFailure): AIError["reason
|
||||
input.status === 408 ||
|
||||
input.status === 409 ||
|
||||
(input.status !== undefined && input.status >= 500) ||
|
||||
// Server codes and phrasing only decide when no HTTP status contradicts them:
|
||||
// gateways such as OpenCode Zen substitute `server_error` for codes they do
|
||||
// not forward, so a 4xx with a server code is still a rejected request.
|
||||
((input.status === undefined || input.status < 400) &&
|
||||
((!codes.some((code) => INVALID_REQUEST_CODES.has(code)) && SERVER_ERROR_TEXT.test(text)) ||
|
||||
codes.some((code) => SERVER_CODES.has(code) || code.includes("exhausted") || code.includes("unavailable"))))
|
||||
!codes.some((code) => INVALID_REQUEST_CODES.has(code)) &&
|
||||
SERVER_ERROR_TEXT.test(text)) ||
|
||||
codes.some((code) => SERVER_CODES.has(code) || code.includes("exhausted") || code.includes("unavailable"))
|
||||
)
|
||||
return new ProviderInternalError({
|
||||
...details,
|
||||
|
||||
@@ -309,7 +309,7 @@ describe("RequestExecutor", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not let server codes override a 4xx rejection", () =>
|
||||
it.effect("classifies provider overloads hidden behind HTTP 400", () =>
|
||||
Effect.gen(function* () {
|
||||
const classify = (body: string) =>
|
||||
Effect.gen(function* () {
|
||||
@@ -317,11 +317,11 @@ describe("RequestExecutor", () => {
|
||||
const error = yield* executor.execute(request).pipe(Effect.flip)
|
||||
|
||||
expectAIError(error)
|
||||
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
|
||||
expect(error.reason).toMatchObject({ _tag: "ProviderInternal" })
|
||||
}).pipe(Effect.provide(fixedResponse(body, { status: 400 })))
|
||||
|
||||
yield* classify('{"code":"resource_exhausted"}')
|
||||
yield* classify('{"error":{"type":"server_error","message":"Upstream request failed: Model is unavailable."}}')
|
||||
yield* classify('{"code":"service_unavailable"}')
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -249,54 +249,10 @@ describe("provider error classification", () => {
|
||||
|
||||
test("classifies any remaining 4xx status as an invalid request", () => {
|
||||
expect(
|
||||
[400, 404, 418, 422, 451].map((status) => classifyProviderFailure({ message: `HTTP ${status}`, status })._tag),
|
||||
).toEqual(Array(5).fill("InvalidRequest"))
|
||||
})
|
||||
|
||||
test("classifies 402 as exhausted quota", () => {
|
||||
expect(classifyProviderFailure({ message: "Payment Required", status: 402 })._tag).toBe("QuotaExceeded")
|
||||
})
|
||||
|
||||
test("classifies OpenCode Zen account limits as quota rather than throttling", () => {
|
||||
const typed = (type: string, message: string) => ({ type: "error", error: { type, message } })
|
||||
const substituted = (message: string) => ({
|
||||
error: { type: "server_error", message: `Upstream request failed: ${message}` },
|
||||
})
|
||||
const cases: ReadonlyArray<[number, { error: { message: string } }]> = [
|
||||
[429, typed("GoUsageLimitError", "Go usage limit exceeded")],
|
||||
[429, typed("FreeUsageLimitError", "Rate limit exceeded. Please try again later.")],
|
||||
[402, typed("CreditLimitExceeded", "Credit limit exceeded.")],
|
||||
[402, substituted("Insufficient account funds")],
|
||||
[402, substituted("Account invoice is overdue")],
|
||||
[429, substituted("Account budget exceeded")],
|
||||
]
|
||||
expect(
|
||||
cases.map(
|
||||
([status, body]) =>
|
||||
classifyProviderFailure({ message: body.error.message, status, rawBody: JSON.stringify(body) })._tag,
|
||||
[400, 402, 404, 418, 422, 451].map(
|
||||
(status) => classifyProviderFailure({ message: `HTTP ${status}`, status })._tag,
|
||||
),
|
||||
).toEqual(Array(6).fill("QuotaExceeded"))
|
||||
})
|
||||
|
||||
test("does not let substituted server codes make a 4xx retryable", () => {
|
||||
const openai = { error: { type: "server_error", message: "Upstream request failed: Model is unavailable." } }
|
||||
const anthropic = {
|
||||
type: "error",
|
||||
error: { type: "api_error", message: "Upstream request failed: Model is unavailable." },
|
||||
}
|
||||
expect(
|
||||
[openai, anthropic].map(
|
||||
(body) =>
|
||||
classifyProviderFailure({ message: body.error.message, status: 400, rawBody: JSON.stringify(body) })._tag,
|
||||
),
|
||||
).toEqual(["InvalidRequest", "InvalidRequest"])
|
||||
// Without a contradicting status the same codes still mark provider trouble.
|
||||
expect(classifyProviderFailure({ message: openai.error.message, rawBody: JSON.stringify(openai) })._tag).toBe(
|
||||
"ProviderInternal",
|
||||
)
|
||||
expect(
|
||||
classifyProviderFailure({ message: openai.error.message, status: 200, rawBody: JSON.stringify(openai) })._tag,
|
||||
).toBe("ProviderInternal")
|
||||
).toEqual(Array(6).fill("InvalidRequest"))
|
||||
})
|
||||
|
||||
test("classifies nested provider codes when a top-level code is also present", () => {
|
||||
|
||||
@@ -405,7 +405,12 @@ test("executes a selected slash command after creating its worktree", async ({ p
|
||||
item: { type: "user", payload: { text: expanded }, delivery: "steer" },
|
||||
},
|
||||
})
|
||||
await route.fulfill({ status: 204, headers })
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
headers,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ data: { type: "prompt", inboxID: "msg_workspace_review" } }),
|
||||
})
|
||||
})
|
||||
const editor = page.locator('[data-component="composer-editor"]')
|
||||
await editor.fill("/review")
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { AgentSideConnection, PromptResponse, SessionUpdate } from "@agentclientprotocol/sdk"
|
||||
import type { Command } from "@opencode/schema/command"
|
||||
import type {
|
||||
EventSubscribeOutput,
|
||||
OpenCodeClient,
|
||||
@@ -37,6 +38,9 @@ export type TurnStart =
|
||||
| { readonly type: "skill"; readonly id: string }
|
||||
| { readonly type: "compaction"; readonly id: string }
|
||||
|
||||
/** `prompt` submissions admitted work under the start ID; `immediate` submissions have none to follow. */
|
||||
export type Admission = Command.Outcome["type"]
|
||||
|
||||
export const ChildSessionUpdatesCapability = "opencode/child-session-updates"
|
||||
export const ChildSessionUpdateMethod = "opencode/session/child_update"
|
||||
|
||||
@@ -76,8 +80,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 submit: (signal: AbortSignal) => Promise<Admission>
|
||||
readonly control: TurnControl
|
||||
readonly childSessionUpdate?: (update: ChildSessionUpdate) => Promise<void>
|
||||
readonly connectionSignal?: AbortSignal
|
||||
@@ -114,6 +117,14 @@ export async function streamTurn(input: {
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
// Command-spawned subagents run while the invoking Session is idle, so cancelling the prompt
|
||||
// must interrupt them directly. Sessions that start after cancellation are interrupted on start.
|
||||
const interruptChildren = async () => {
|
||||
await Promise.all(
|
||||
[...openChildren].map((sessionID) => input.client.session.interrupt({ sessionID }).catch(() => {})),
|
||||
)
|
||||
}
|
||||
|
||||
const updateSession = async (value: SessionUpdate, child: ChildSession | undefined, mode: "turn" | "background") => {
|
||||
const projected = child ? projectChildUpdate(value, child) : value
|
||||
if (mode === "turn" && (!child || !input.childSessionUpdate)) {
|
||||
@@ -178,9 +189,10 @@ export async function streamTurn(input: {
|
||||
if (!started) continue
|
||||
|
||||
if (event.type === "session.execution.started") {
|
||||
if (child) {
|
||||
await notifyChild(child, { type: "status", status: "running" })
|
||||
if (mode === "turn" && control.cancelled) {
|
||||
await input.client.session.interrupt({ sessionID: event.data.sessionID }).catch(() => {})
|
||||
}
|
||||
if (child) await notifyChild(child, { type: "status", status: "running" })
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -342,11 +354,15 @@ export async function streamTurn(input: {
|
||||
input.sessionSignal?.removeEventListener("abort", connectionAbort)
|
||||
await stream.return?.(undefined).catch(() => {})
|
||||
}
|
||||
const onCancel = () => void interruptChildren()
|
||||
control.admission.signal.addEventListener("abort", onCancel, { once: true })
|
||||
try {
|
||||
await input.submit(control.admission.signal).catch((error) => {
|
||||
const admission = await input.submit(control.admission.signal).catch((error): Admission => {
|
||||
if (!control.cancelled) throw error
|
||||
// The request may have been admitted before the abort reached the server; keep observing.
|
||||
return "prompt"
|
||||
})
|
||||
if (input.action) {
|
||||
if (admission === "immediate") {
|
||||
streamController.abort()
|
||||
await completed.catch(() => {})
|
||||
return response(undefined, undefined, "succeeded", control.cancelled, undefined)
|
||||
@@ -384,6 +400,7 @@ export async function streamTurn(input: {
|
||||
await completed.catch(() => {})
|
||||
throw error
|
||||
} finally {
|
||||
control.admission.signal.removeEventListener("abort", onCancel)
|
||||
if (!handedOff) await closeStream()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,6 +52,7 @@ import {
|
||||
ChildSessionUpdatesCapability,
|
||||
replayMessages,
|
||||
streamTurn,
|
||||
type Admission,
|
||||
type ChildSessionUpdate,
|
||||
type TurnControl,
|
||||
type TurnStart,
|
||||
@@ -327,7 +328,6 @@ 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,
|
||||
@@ -362,7 +362,12 @@ function preparePrompt(catalog: Catalog, prompt: PromptRequest["prompt"], messag
|
||||
return { start, text, files, synthetic, slash, command }
|
||||
}
|
||||
|
||||
async function submitPrompt(client: OpenCodeClient, session: Attached, prompt: PreparedPrompt, signal: AbortSignal) {
|
||||
async function submitPrompt(
|
||||
client: OpenCodeClient,
|
||||
session: Attached,
|
||||
prompt: PreparedPrompt,
|
||||
signal: AbortSignal,
|
||||
): Promise<Admission> {
|
||||
if (prompt.synthetic.length > 0) {
|
||||
await client.session.synthetic({
|
||||
sessionID: session.id,
|
||||
@@ -372,23 +377,30 @@ async function submitPrompt(client: OpenCodeClient, session: Attached, prompt: P
|
||||
resume: false,
|
||||
})
|
||||
}
|
||||
if (prompt.start.type === "compaction") return client.session.compact({ sessionID: session.id, id: prompt.start.id })
|
||||
if (prompt.start.type === "compaction") {
|
||||
await client.session.compact({ sessionID: session.id, id: prompt.start.id })
|
||||
return "prompt"
|
||||
}
|
||||
if (prompt.command) {
|
||||
return client.session.command(
|
||||
// The command admits any resulting input under the turn's start ID so the stream can follow it.
|
||||
const outcome = await client.session.command(
|
||||
{
|
||||
sessionID: session.id,
|
||||
name: prompt.command.name,
|
||||
id: prompt.start.id,
|
||||
text: prompt.slash?.args ?? "",
|
||||
files: prompt.files,
|
||||
delivery: "steer",
|
||||
},
|
||||
{ signal },
|
||||
)
|
||||
return outcome.type
|
||||
}
|
||||
return client.session.prompt(
|
||||
await client.session.prompt(
|
||||
{ sessionID: session.id, id: prompt.start.id, text: prompt.text, files: prompt.files, delivery: "steer" },
|
||||
{ signal },
|
||||
)
|
||||
return "prompt"
|
||||
}
|
||||
|
||||
function turnStart(messageID: string, slash: PreparedPrompt["slash"]): TurnStart {
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
import type { PromptResponse, SessionNotification } from "@agentclientprotocol/sdk"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import path from "node:path"
|
||||
import { createAcpFixture, expectOk, initialize, newSession, type AcpProcess } from "./subprocess"
|
||||
|
||||
const commandPlugin = path.join(import.meta.dir, "fixture/command-plugin")
|
||||
|
||||
describe("acp slash command subprocess", () => {
|
||||
test("template command stays pending until its model work completes and streams the output", async () => {
|
||||
const script = scriptedModel()
|
||||
await using fixture = await createAcpFixture({
|
||||
respond: script.respond,
|
||||
config: { commands: { audit: { description: "Audit the change", template: "Audit $ARGUMENTS" } } },
|
||||
})
|
||||
const acp = fixture.spawn()
|
||||
await initialize(acp)
|
||||
const session = await newSession(acp, fixture.home)
|
||||
|
||||
const gate = script.hold()
|
||||
const pending = trackedPrompt(acp, session.sessionId, "/audit now")
|
||||
await gate.started
|
||||
await Bun.sleep(200)
|
||||
expect(pending.settled()).toBe(false)
|
||||
expect(lastUserText(fixture.llm.requests)).toBe("Audit now")
|
||||
|
||||
gate.release("COMMAND_RESPONSE")
|
||||
const response = await pending.response
|
||||
expect(response.stopReason).toBe("end_turn")
|
||||
const chunk = await acp.waitForNotification<SessionNotification>("session/update", (params) =>
|
||||
isAgentText(params, "COMMAND_RESPONSE"),
|
||||
)
|
||||
expect(chunk.params.sessionId).toBe(session.sessionId)
|
||||
}, 60_000)
|
||||
|
||||
test("built-in /init prompt command waits for the model response", async () => {
|
||||
const script = scriptedModel()
|
||||
await using fixture = await createAcpFixture({ respond: script.respond })
|
||||
const acp = fixture.spawn()
|
||||
await initialize(acp)
|
||||
const session = await newSession(acp, fixture.home)
|
||||
|
||||
const gate = script.hold()
|
||||
const pending = trackedPrompt(acp, session.sessionId, "/init")
|
||||
await gate.started
|
||||
await Bun.sleep(200)
|
||||
expect(pending.settled()).toBe(false)
|
||||
expect(lastUserText(fixture.llm.requests)).toContain("AGENTS.md")
|
||||
|
||||
gate.release("INIT_RESPONSE")
|
||||
expect((await pending.response).stopReason).toBe("end_turn")
|
||||
await acp.waitForNotification<SessionNotification>("session/update", (params) =>
|
||||
isAgentText(params, "INIT_RESPONSE"),
|
||||
)
|
||||
}, 60_000)
|
||||
|
||||
test("immediate plugin commands finish without model work", async () => {
|
||||
await using fixture = await createAcpFixture({ config: { plugins: [commandPlugin] } })
|
||||
const acp = fixture.spawn()
|
||||
await initialize(acp)
|
||||
const session = await newSession(acp, fixture.home)
|
||||
const commands = await acp.waitForNotification<SessionNotification>(
|
||||
"session/update",
|
||||
(params) => params.update.sessionUpdate === "available_commands_update",
|
||||
)
|
||||
expect(
|
||||
commands.params.update.sessionUpdate === "available_commands_update"
|
||||
? commands.params.update.availableCommands.map((command) => command.name)
|
||||
: [],
|
||||
).toEqual(expect.arrayContaining(["ping", "pong"]))
|
||||
|
||||
for (const text of ["/ping", "/pong"]) {
|
||||
const response = expectOk(
|
||||
await acp.request<PromptResponse>("session/prompt", {
|
||||
sessionId: session.sessionId,
|
||||
prompt: [{ type: "text", text }],
|
||||
}),
|
||||
)
|
||||
expect(response.stopReason).toBe("end_turn")
|
||||
}
|
||||
expect(fixture.llm.requests).toHaveLength(0)
|
||||
}, 60_000)
|
||||
|
||||
test("subagent command follows the child and the parent's follow-up before completing", async () => {
|
||||
const script = scriptedModel()
|
||||
await using fixture = await createAcpFixture({
|
||||
respond: script.respond,
|
||||
config: {
|
||||
commands: { audit: { description: "Audit in a subagent", template: "Audit $ARGUMENTS", subagent: true } },
|
||||
},
|
||||
})
|
||||
const acp = fixture.spawn()
|
||||
await initialize(acp)
|
||||
const session = await newSession(acp, fixture.home)
|
||||
|
||||
const child = script.hold()
|
||||
const parent = script.hold()
|
||||
const pending = trackedPrompt(acp, session.sessionId, "/audit now")
|
||||
await child.started
|
||||
expect(lastUserText(fixture.llm.requests)).toBe("You are a subagent spawned by another session.\nAudit now")
|
||||
child.release("CHILD_DONE")
|
||||
await parent.started
|
||||
await Bun.sleep(200)
|
||||
expect(pending.settled()).toBe(false)
|
||||
expect(lastUserText(fixture.llm.requests)).toContain("CHILD_DONE")
|
||||
|
||||
parent.release("PARENT_DONE")
|
||||
expect((await pending.response).stopReason).toBe("end_turn")
|
||||
const childChunk = await acp.waitForNotification<SessionNotification>("session/update", (params) =>
|
||||
isAgentText(params, "CHILD_DONE"),
|
||||
)
|
||||
expect(childChunk.params.sessionId).toBe(session.sessionId)
|
||||
expect(childChunk.params.update._meta?.["opencode/child-session"]).toMatchObject({ parentID: session.sessionId })
|
||||
const parentChunk = await acp.waitForNotification<SessionNotification>("session/update", (params) =>
|
||||
isAgentText(params, "PARENT_DONE"),
|
||||
)
|
||||
expect(parentChunk.params.update._meta?.["opencode/child-session"]).toBeUndefined()
|
||||
}, 60_000)
|
||||
|
||||
test("cancelling during command work returns cancelled", async () => {
|
||||
const script = scriptedModel()
|
||||
await using fixture = await createAcpFixture({
|
||||
respond: script.respond,
|
||||
config: { commands: { audit: { description: "Audit the change", template: "Audit $ARGUMENTS" } } },
|
||||
})
|
||||
const acp = fixture.spawn()
|
||||
await initialize(acp)
|
||||
const session = await newSession(acp, fixture.home)
|
||||
|
||||
const gate = script.hold()
|
||||
const pending = trackedPrompt(acp, session.sessionId, "/audit now")
|
||||
await gate.started
|
||||
await acp.notify("session/cancel", { sessionId: session.sessionId })
|
||||
|
||||
expect((await pending.response).stopReason).toBe("cancelled")
|
||||
gate.release("LATE")
|
||||
const followUp = expectOk(
|
||||
await acp.request<PromptResponse>("session/prompt", {
|
||||
sessionId: session.sessionId,
|
||||
prompt: [{ type: "text", text: "hello again" }],
|
||||
}),
|
||||
)
|
||||
expect(followUp.stopReason).toBe("end_turn")
|
||||
}, 60_000)
|
||||
|
||||
test("cancelling during subagent command work interrupts the child and the parent's follow-up", async () => {
|
||||
const script = scriptedModel()
|
||||
await using fixture = await createAcpFixture({
|
||||
respond: script.respond,
|
||||
config: {
|
||||
commands: { audit: { description: "Audit in a subagent", template: "Audit $ARGUMENTS", subagent: true } },
|
||||
},
|
||||
})
|
||||
const acp = fixture.spawn()
|
||||
await initialize(acp)
|
||||
const session = await newSession(acp, fixture.home)
|
||||
|
||||
const child = script.hold()
|
||||
const parent = script.hold()
|
||||
const pending = trackedPrompt(acp, session.sessionId, "/audit now")
|
||||
await child.started
|
||||
await acp.notify("session/cancel", { sessionId: session.sessionId })
|
||||
|
||||
// The parent's follow-up run (from the cancelled subagent notice) can only settle before its
|
||||
// model response is released by being interrupted.
|
||||
expect((await pending.response).stopReason).toBe("cancelled")
|
||||
child.release("LATE")
|
||||
parent.release("LATE")
|
||||
const followUp = expectOk(
|
||||
await acp.request<PromptResponse>("session/prompt", {
|
||||
sessionId: session.sessionId,
|
||||
prompt: [{ type: "text", text: "hello again" }],
|
||||
}),
|
||||
)
|
||||
expect(followUp.stopReason).toBe("end_turn")
|
||||
}, 60_000)
|
||||
})
|
||||
|
||||
function scriptedModel() {
|
||||
const holds: Array<{ started: () => void; released: Promise<string> }> = []
|
||||
return {
|
||||
respond: (request: unknown) => {
|
||||
// Auxiliary requests such as title generation carry no tools; only agent-loop steps take a hold.
|
||||
if (!isAgentStep(request)) return "accepted"
|
||||
const next = holds.shift()
|
||||
if (!next) return "accepted"
|
||||
next.started()
|
||||
return next.released
|
||||
},
|
||||
hold() {
|
||||
const started = Promise.withResolvers<void>()
|
||||
const released = Promise.withResolvers<string>()
|
||||
holds.push({ started: started.resolve, released: released.promise })
|
||||
return { started: started.promise, release: released.resolve }
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function trackedPrompt(acp: AcpProcess, sessionId: string, text: string) {
|
||||
let done = false
|
||||
const response = acp
|
||||
.request<PromptResponse>("session/prompt", { sessionId, prompt: [{ type: "text", text }] })
|
||||
.then(expectOk)
|
||||
.finally(() => {
|
||||
done = true
|
||||
})
|
||||
return { response, settled: () => done }
|
||||
}
|
||||
|
||||
function isAgentText(params: SessionNotification, text: string) {
|
||||
return (
|
||||
params.update.sessionUpdate === "agent_message_chunk" &&
|
||||
params.update.content.type === "text" &&
|
||||
params.update.content.text === text
|
||||
)
|
||||
}
|
||||
|
||||
function isAgentStep(request: unknown) {
|
||||
return !!request && typeof request === "object" && "tools" in request && Array.isArray(request.tools)
|
||||
}
|
||||
|
||||
function lastUserText(requests: readonly unknown[]) {
|
||||
const last = requests.findLast(isAgentStep)
|
||||
if (!last || typeof last !== "object" || !("messages" in last) || !Array.isArray(last.messages)) return undefined
|
||||
const user = last.messages.findLast(
|
||||
(message: unknown) =>
|
||||
!!message && typeof message === "object" && "role" in message && message.role === "user" && "content" in message,
|
||||
)
|
||||
const content = user?.content
|
||||
if (typeof content === "string") return content
|
||||
if (!Array.isArray(content)) return undefined
|
||||
return content
|
||||
.flatMap((part: unknown) =>
|
||||
part && typeof part === "object" && "text" in part && typeof part.text === "string" ? [part.text] : [],
|
||||
)
|
||||
.join("")
|
||||
}
|
||||
@@ -656,6 +656,7 @@ describe("acp event behavior", () => {
|
||||
{ signal },
|
||||
)
|
||||
submitted.resolve()
|
||||
return "prompt"
|
||||
},
|
||||
})
|
||||
|
||||
@@ -673,6 +674,81 @@ describe("acp event behavior", () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("cancelling command work in a child interrupts the child and the parent's follow-up run", async () => {
|
||||
const control: TurnControl = { cancelled: false, admission: new AbortController() }
|
||||
const childOutput = Promise.withResolvers<void>()
|
||||
const interrupted: string[] = []
|
||||
const state = { childRunning: false, parentRunning: false }
|
||||
const fixture = createSseFixture({
|
||||
onPrompt({ id, send }) {
|
||||
send(
|
||||
durableEvent("session.created", { sessionID: "ses_sub", ...childSession("ses_sub", "ses_owner", "Audit") }),
|
||||
)
|
||||
send(durableEvent("session.inbox.delivered", { sessionID: "ses_sub", inboxID: id }))
|
||||
send(durableEvent("session.execution.started", { sessionID: "ses_sub" }))
|
||||
send(
|
||||
ephemeralEvent("session.text.delta", {
|
||||
sessionID: "ses_sub",
|
||||
assistantMessageID: "msg_sub",
|
||||
ordinal: 0,
|
||||
delta: "working",
|
||||
}),
|
||||
)
|
||||
state.childRunning = true
|
||||
},
|
||||
onInterrupt({ sessionID, send }) {
|
||||
interrupted.push(sessionID)
|
||||
if (sessionID === "ses_sub") {
|
||||
if (!state.childRunning) return false
|
||||
state.childRunning = false
|
||||
send(durableEvent("session.execution.interrupted", { sessionID, reason: "user" }))
|
||||
// The subagent job reports into the idle parent, which wakes for a follow-up run.
|
||||
state.parentRunning = true
|
||||
send(durableEvent("session.execution.started", { sessionID: "ses_owner" }))
|
||||
return true
|
||||
}
|
||||
if (!state.parentRunning) return false
|
||||
state.parentRunning = false
|
||||
send(durableEvent("session.execution.interrupted", { sessionID, reason: "user" }))
|
||||
return true
|
||||
},
|
||||
})
|
||||
const result = streamTurn({
|
||||
client: fixture.client,
|
||||
connection: {
|
||||
sessionUpdate: async (update) => {
|
||||
if (update.update.sessionUpdate === "agent_message_chunk") childOutput.resolve()
|
||||
},
|
||||
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
|
||||
},
|
||||
sessionID: "ses_owner",
|
||||
cwd: "/workspace",
|
||||
start: { type: "input", id: "input_sub" },
|
||||
writeTextFile: false,
|
||||
control,
|
||||
submit: async (signal) => {
|
||||
await fixture.client.session.prompt({ sessionID: "ses_owner", id: "input_sub", text: "/audit" }, { signal })
|
||||
return "prompt"
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
await withTimeout(childOutput.promise, "child output was not observed")
|
||||
control.cancelled = true
|
||||
control.admission.abort()
|
||||
// The service issues the parent interrupt alongside the abort.
|
||||
await fixture.client.session.interrupt({ sessionID: "ses_owner" })
|
||||
|
||||
const response = await withTimeout(result, "cancelled subagent turn did not terminate")
|
||||
expect(response).toMatchObject({ stopReason: "cancelled" })
|
||||
expect(interrupted).toContain("ses_sub")
|
||||
expect(interrupted.indexOf("ses_sub")).toBeLessThan(interrupted.lastIndexOf("ses_owner"))
|
||||
expect(state).toEqual({ childRunning: false, parentRunning: false })
|
||||
} finally {
|
||||
await fixture.stop()
|
||||
}
|
||||
})
|
||||
|
||||
test("returns cancelled when admission is aborted before promotion", async () => {
|
||||
const submitted = Promise.withResolvers<void>()
|
||||
const control: TurnControl = { cancelled: false, admission: new AbortController() }
|
||||
@@ -694,10 +770,9 @@ describe("acp event behavior", () => {
|
||||
writeTextFile: false,
|
||||
control,
|
||||
submit: (signal) =>
|
||||
fixture.client.session.prompt(
|
||||
{ sessionID: "ses_cancel_admission", id: "input_cancel_admission", text: "cancel me" },
|
||||
{ signal },
|
||||
),
|
||||
fixture.client.session
|
||||
.prompt({ sessionID: "ses_cancel_admission", id: "input_cancel_admission", text: "cancel me" }, { signal })
|
||||
.then(() => "prompt" as const),
|
||||
})
|
||||
|
||||
try {
|
||||
@@ -782,7 +857,9 @@ function turn(input: {
|
||||
control: { cancelled: false, admission: new AbortController() },
|
||||
childSessionUpdate: input.childSessionUpdate,
|
||||
submit: (signal) =>
|
||||
input.fixture.client.session.prompt({ sessionID: input.sessionID, id: input.inboxID, text: "hello" }, { signal }),
|
||||
input.fixture.client.session
|
||||
.prompt({ sessionID: input.sessionID, id: input.inboxID, text: "hello" }, { signal })
|
||||
.then(() => "prompt" as const),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -98,7 +98,7 @@ test("acp prompt resolves after ordered turn updates", async () => {
|
||||
start: { type: "input", id },
|
||||
writeTextFile: false,
|
||||
control: { cancelled: false, admission: new AbortController() },
|
||||
submit: () => client.session.prompt({ sessionID: "ses_test", id, text: "hi" }),
|
||||
submit: () => client.session.prompt({ sessionID: "ses_test", id, text: "hi" }).then(() => "prompt" as const),
|
||||
})
|
||||
|
||||
expect(updates).toEqual([
|
||||
@@ -122,7 +122,7 @@ test("acp prompt resolves after ordered turn updates", async () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("acp action resolves without prompt lifecycle events", async () => {
|
||||
test("acp immediate admission resolves without prompt lifecycle events", async () => {
|
||||
const encoder = new TextEncoder()
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
@@ -150,9 +150,8 @@ test("acp action resolves without prompt lifecycle events", async () => {
|
||||
cwd: "/workspace",
|
||||
start: { type: "input", id: "msg_action" },
|
||||
writeTextFile: false,
|
||||
action: true,
|
||||
control: { cancelled: false, admission: new AbortController() },
|
||||
submit: async () => {},
|
||||
submit: async () => "immediate",
|
||||
})
|
||||
|
||||
expect(response).toMatchObject({ stopReason: "end_turn" })
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Plugin } from "@opencode/plugin"
|
||||
|
||||
export default Plugin.define({
|
||||
id: "acp-test-commands",
|
||||
async setup(ctx) {
|
||||
await ctx.command.transform((editor) => {
|
||||
editor.add({
|
||||
name: "ping",
|
||||
description: "Immediate action that admits no work",
|
||||
execute: async () => {},
|
||||
})
|
||||
editor.add({
|
||||
name: "pong",
|
||||
description: "Explicit immediate outcome",
|
||||
execute: async () => ({ type: "immediate" }),
|
||||
})
|
||||
})
|
||||
},
|
||||
})
|
||||
@@ -547,7 +547,10 @@ function startTurn(fixture: Fixture, connection: Connection, sessionID: string,
|
||||
start: { type: "input", id: inboxID },
|
||||
writeTextFile: true,
|
||||
control: { cancelled: false, admission: new AbortController() },
|
||||
submit: (signal) => fixture.client.session.prompt({ sessionID, id: inboxID, text: "hello" }, { signal }),
|
||||
submit: (signal) =>
|
||||
fixture.client.session
|
||||
.prompt({ sessionID, id: inboxID, text: "hello" }, { signal })
|
||||
.then(() => "prompt" as const),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ type FixtureHandler = (
|
||||
|
||||
type FixtureOptions = {
|
||||
readonly fetch?: FixtureHandler
|
||||
readonly connection?: Partial<Parameters<typeof ACPService.make>[0]["connection"]>
|
||||
readonly models?: readonly ModelInfo[]
|
||||
readonly defaultModel?: ModelInfo
|
||||
readonly agents?: readonly AgentInfo[]
|
||||
@@ -177,6 +178,7 @@ export function makeACPFixture(options: FixtureOptions = {}) {
|
||||
updates.push(update)
|
||||
},
|
||||
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
|
||||
...options.connection,
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { ChildSessionUpdatesCapability } from "../../src/acp/event"
|
||||
import { makeACPFixture, makeSession, secondModel, type FixtureContext, type FixtureRequest } from "./service-fixture"
|
||||
|
||||
describe("acp service prompt routing and usage", () => {
|
||||
@@ -12,7 +13,7 @@ describe("acp service prompt routing and usage", () => {
|
||||
return Response.json({ data: makeSession("ses_routes") })
|
||||
}
|
||||
if (request.method === "POST" && request.path === "/api/session/ses_routes/command") {
|
||||
return new Response(null, { status: 204 })
|
||||
return Response.json({ data: { type: "immediate" } })
|
||||
}
|
||||
if (request.method === "POST" && request.path === "/api/session/ses_routes/compact") {
|
||||
const id = requestID(request)
|
||||
@@ -42,6 +43,7 @@ describe("acp service prompt routing and usage", () => {
|
||||
const compact = fixture.requests.find((request) => request.path === "/api/session/ses_routes/compact")
|
||||
expect(command?.body).toMatchObject({
|
||||
name: "review",
|
||||
id: expect.stringMatching(/^msg_/),
|
||||
text: "now",
|
||||
files: [],
|
||||
delivery: "steer",
|
||||
@@ -50,6 +52,141 @@ describe("acp service prompt routing and usage", () => {
|
||||
expect(fixture.requests.some((request) => request.path === "/api/session/ses_routes/prompt")).toBe(false)
|
||||
})
|
||||
|
||||
test("keeps a command prompt pending until its admitted work completes", async () => {
|
||||
const gate = Promise.withResolvers<void>()
|
||||
await using fixture = makeACPFixture({
|
||||
fetch(request, context) {
|
||||
if (request.method === "POST" && request.path === "/api/session") {
|
||||
return Response.json({ data: makeSession("ses_cmd") })
|
||||
}
|
||||
if (request.method === "POST" && request.path === "/api/session/ses_cmd/command") {
|
||||
const id = requestID(request)
|
||||
context.send({
|
||||
id: `evt_${id}`,
|
||||
type: "session.inbox.delivered",
|
||||
data: { sessionID: "ses_cmd", inboxID: id },
|
||||
})
|
||||
void gate.promise.then(() => {
|
||||
context.send({
|
||||
id: "evt_text",
|
||||
type: "session.text.delta",
|
||||
data: { sessionID: "ses_cmd", assistantMessageID: "msg_assistant", ordinal: 0, delta: "REVIEWED" },
|
||||
})
|
||||
context.send({ id: "evt_done", type: "session.execution.succeeded", data: { sessionID: "ses_cmd" } })
|
||||
})
|
||||
return Response.json({ data: { type: "prompt", inboxID: id } })
|
||||
}
|
||||
if (request.method === "GET" && request.path === "/api/session/ses_cmd/message/msg_assistant") {
|
||||
return new Response(null, { status: 404 })
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
})
|
||||
const session = await fixture.service.newSession({ cwd: "/workspace", mcpServers: [] })
|
||||
|
||||
let settled = false
|
||||
const pending = fixture.service
|
||||
.prompt({ sessionId: session.sessionId, prompt: [{ type: "text", text: "/review" }] })
|
||||
.finally(() => {
|
||||
settled = true
|
||||
})
|
||||
await Bun.sleep(50)
|
||||
expect(settled).toBe(false)
|
||||
expect(fixture.updates.some((item) => item.update.sessionUpdate === "agent_message_chunk")).toBe(false)
|
||||
|
||||
gate.resolve()
|
||||
const response = await pending
|
||||
expect(response.stopReason).toBe("end_turn")
|
||||
expect(
|
||||
fixture.updates.flatMap((item) =>
|
||||
item.update.sessionUpdate === "agent_message_chunk" && item.update.content.type === "text"
|
||||
? [item.update.content.text]
|
||||
: [],
|
||||
),
|
||||
).toEqual(["REVIEWED"])
|
||||
})
|
||||
|
||||
test("follows command work admitted to a child session until the parent completes", async () => {
|
||||
const childUpdates: unknown[] = []
|
||||
await using fixture = makeACPFixture({
|
||||
fetch(request, context) {
|
||||
if (request.method === "POST" && request.path === "/api/session") {
|
||||
return Response.json({ data: makeSession("ses_parent") })
|
||||
}
|
||||
if (request.method === "POST" && request.path === "/api/session/ses_parent/command") {
|
||||
const id = requestID(request)
|
||||
context.send({
|
||||
id: "evt_child_created",
|
||||
type: "session.created",
|
||||
data: { sessionID: "ses_child", parentID: "ses_parent", title: "Audit" },
|
||||
})
|
||||
context.send({
|
||||
id: `evt_${id}`,
|
||||
type: "session.inbox.delivered",
|
||||
data: { sessionID: "ses_child", inboxID: id },
|
||||
})
|
||||
context.send({
|
||||
id: "evt_child_text",
|
||||
type: "session.text.delta",
|
||||
data: { sessionID: "ses_child", assistantMessageID: "msg_child", ordinal: 0, delta: "child work" },
|
||||
})
|
||||
context.send({ id: "evt_child_done", type: "session.execution.succeeded", data: { sessionID: "ses_child" } })
|
||||
context.send({
|
||||
id: "evt_parent_text",
|
||||
type: "session.text.delta",
|
||||
data: { sessionID: "ses_parent", assistantMessageID: "msg_parent", ordinal: 0, delta: "summary" },
|
||||
})
|
||||
context.send({
|
||||
id: "evt_parent_done",
|
||||
type: "session.execution.succeeded",
|
||||
data: { sessionID: "ses_parent" },
|
||||
})
|
||||
return Response.json({ data: { type: "prompt", inboxID: id } })
|
||||
}
|
||||
if (request.method === "GET" && request.path === "/api/session/ses_parent/message/msg_parent") {
|
||||
return new Response(null, { status: 404 })
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
connection: {
|
||||
extNotification: async (_method, params) => {
|
||||
childUpdates.push(params)
|
||||
},
|
||||
},
|
||||
commands: [{ name: "audit", description: "Audit in a subagent" }],
|
||||
})
|
||||
await fixture.service.initialize({
|
||||
protocolVersion: 1,
|
||||
clientCapabilities: { _meta: { [ChildSessionUpdatesCapability]: true } },
|
||||
clientInfo: { name: "test", version: "0" },
|
||||
})
|
||||
const session = await fixture.service.newSession({ cwd: "/workspace", mcpServers: [] })
|
||||
|
||||
const response = await fixture.service.prompt({
|
||||
sessionId: session.sessionId,
|
||||
prompt: [{ type: "text", text: "/audit" }],
|
||||
})
|
||||
|
||||
expect(response.stopReason).toBe("end_turn")
|
||||
expect(
|
||||
fixture.updates.flatMap((item) =>
|
||||
item.update.sessionUpdate === "agent_message_chunk" && item.update.content.type === "text"
|
||||
? [item.update.content.text]
|
||||
: [],
|
||||
),
|
||||
).toEqual(["summary"])
|
||||
expect(childUpdates).toContainEqual(
|
||||
expect.objectContaining({
|
||||
childSessionId: "ses_child",
|
||||
type: "update",
|
||||
update: expect.objectContaining({
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
content: { type: "text", text: "child work" },
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("returns turn usage and publishes current context usage with cumulative session cost", async () => {
|
||||
const assistantTokens = {
|
||||
input: 100,
|
||||
|
||||
@@ -46,6 +46,7 @@ type Waiter = {
|
||||
|
||||
export type AcpProcess = {
|
||||
readonly request: <T>(method: string, params?: unknown) => Promise<JsonRpcResponse<T>>
|
||||
readonly notify: (method: string, params?: unknown) => Promise<void>
|
||||
readonly waitForNotification: <T>(
|
||||
method: string,
|
||||
predicate: (params: T) => boolean,
|
||||
@@ -64,7 +65,15 @@ description: Verifier compatibility skill.
|
||||
# Verifier Skill
|
||||
`
|
||||
|
||||
export async function createAcpFixture(options: { readonly skill?: string } = {}) {
|
||||
export type FixtureOptions = {
|
||||
readonly skill?: string
|
||||
/** Extra opencode.json entries merged over the verifier config. */
|
||||
readonly config?: Record<string, unknown>
|
||||
/** Produce the scripted completion text; awaiting here holds the model response. */
|
||||
readonly respond?: (request: unknown) => string | Promise<string>
|
||||
}
|
||||
|
||||
export async function createAcpFixture(options: FixtureOptions = {}) {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-cli-acp-"))
|
||||
const home = path.join(root, "workspace")
|
||||
const config = path.join(root, "config")
|
||||
@@ -84,15 +93,19 @@ export async function createAcpFixture(options: { readonly skill?: string } = {}
|
||||
if (request.method !== "POST" || new URL(request.url).pathname !== "/v1/chat/completions") {
|
||||
return new Response("Not found", { status: 404 })
|
||||
}
|
||||
requests.push(await request.json().catch(() => undefined))
|
||||
return new Response(completion("accepted"), {
|
||||
const body = await request.json().catch(() => undefined)
|
||||
requests.push(body)
|
||||
return new Response(completion(await (options.respond?.(body) ?? "accepted")), {
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
})
|
||||
},
|
||||
})
|
||||
await Bun.write(
|
||||
path.join(config, "opencode.json"),
|
||||
JSON.stringify(verifierConfig(`http://127.0.0.1:${llm.port}/v1`, options.skill ? skills : undefined)),
|
||||
JSON.stringify({
|
||||
...verifierConfig(`http://127.0.0.1:${llm.port}/v1`, options.skill ? skills : undefined),
|
||||
...options.config,
|
||||
}),
|
||||
)
|
||||
await Bun.write(models, "{}")
|
||||
|
||||
@@ -306,6 +319,12 @@ function spawnAcp(input: { readonly env: Record<string, string | undefined> }):
|
||||
if (!isResponse<T>(response)) throw new Error(`Invalid ACP response: ${JSON.stringify(response)}`)
|
||||
return response
|
||||
},
|
||||
async notify(method: string, params?: unknown) {
|
||||
if (inputClosed) throw new Error("ACP stdin is closed")
|
||||
const notification: JsonRpcNotification<unknown> = { jsonrpc: "2.0", method, params }
|
||||
await child.stdin.write(encoder.encode(`${JSON.stringify(notification)}\n`))
|
||||
await child.stdin.flush()
|
||||
},
|
||||
async waitForNotification<T>(method: string, predicate: (params: T) => boolean, timeoutMs = 20_000) {
|
||||
const notification = await take(
|
||||
(message) => isNotification<T>(message) && message.method === method && predicate(message.params),
|
||||
|
||||
@@ -15,6 +15,7 @@ import type { SessionMessage } from "@opencode/schema/session-message"
|
||||
import type { SessionInbox } from "@opencode/schema/session-inbox"
|
||||
import type { PromptInput } from "@opencode/schema/prompt-input"
|
||||
import type { AgentAttachment } from "@opencode/schema/prompt"
|
||||
import type { Command } from "@opencode/schema/command"
|
||||
import type { Skill } from "@opencode/schema/skill"
|
||||
import type { FileDiff } from "@opencode/schema/file-diff"
|
||||
import type { InstructionEntry } from "@opencode/schema/instruction-entry"
|
||||
@@ -29,7 +30,6 @@ import type { Mcp } from "@opencode/schema/mcp"
|
||||
import type { Credential } from "@opencode/schema/credential"
|
||||
import type { PermissionSaved } from "@opencode/schema/permission-saved"
|
||||
import type { FileSystem } from "@opencode/schema/filesystem"
|
||||
import type { Command } from "@opencode/schema/command"
|
||||
import type { OpenCodeEvent } from "@opencode/protocol/groups/event"
|
||||
import type { Pty } from "@opencode/schema/pty"
|
||||
import type { PtyTicket } from "@opencode/schema/pty-ticket"
|
||||
@@ -262,13 +262,14 @@ export type SessionPromptOperation<E = never> = (input: SessionPromptInput) => E
|
||||
export type SessionCommandInput = {
|
||||
readonly sessionID: Session.ID
|
||||
readonly name: string
|
||||
readonly id?: SessionMessage.ID | 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
|
||||
}
|
||||
export type SessionCommandOutput = void
|
||||
export type SessionCommandOutput = Command.Outcome
|
||||
export type SessionCommandOperation<E = never> = (input: SessionCommandInput) => Effect.Effect<SessionCommandOutput, E>
|
||||
|
||||
export type SessionSkillInput = {
|
||||
|
||||
@@ -491,13 +491,17 @@ const EndpointSessionCommand = (raw: RawClient["server.session"]) => (input: Ses
|
||||
params: { sessionID: input["sessionID"] },
|
||||
payload: {
|
||||
name: input["name"],
|
||||
id: input["id"],
|
||||
text: input["text"],
|
||||
files: input["files"],
|
||||
agents: input["agents"],
|
||||
skills: input["skills"],
|
||||
delivery: input["delivery"],
|
||||
},
|
||||
}).pipe(Effect.mapError(mapClientError)),
|
||||
}).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
),
|
||||
)
|
||||
|
||||
const EndpointSessionSkill = (raw: RawClient["server.session"]) => (input: SessionSkillInput) =>
|
||||
|
||||
@@ -690,24 +690,25 @@ export function make(options: ClientOptions) {
|
||||
requestOptions,
|
||||
).then((value) => value.data),
|
||||
command: (input: SessionCommandInput, requestOptions?: RequestOptions) =>
|
||||
request<SessionCommandOutput>(
|
||||
request<{ readonly data: SessionCommandOutput }>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/command`,
|
||||
body: {
|
||||
name: input["name"],
|
||||
id: input["id"],
|
||||
text: input["text"],
|
||||
files: input["files"],
|
||||
agents: input["agents"],
|
||||
skills: input["skills"],
|
||||
delivery: input["delivery"],
|
||||
},
|
||||
successStatus: 204,
|
||||
successStatus: 200,
|
||||
declaredStatuses: [400, 401, 404, 500],
|
||||
empty: true,
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
).then((value) => value.data),
|
||||
skill: (input: SessionSkillInput, requestOptions?: RequestOptions) =>
|
||||
request<SessionSkillOutput>(
|
||||
{
|
||||
|
||||
@@ -161,6 +161,8 @@ export type SessionActive = { type: "running" }
|
||||
|
||||
export type SessionInboxDelivery = "steer" | "queue"
|
||||
|
||||
export type CommandOutcome = { type: "immediate" } | { type: "prompt"; inboxID: string }
|
||||
|
||||
export type SessionInboxSyntheticPayload = { text: string; description?: string; metadata?: { [x: string]: JsonValue } }
|
||||
|
||||
export type SessionInboxCompactionPayload = {}
|
||||
@@ -1342,6 +1344,7 @@ export type ModelCompatibility = {
|
||||
maxTokensField?: ModelMaxTokensField
|
||||
requireFinishReason?: boolean
|
||||
requireAssistantAfterTool?: boolean
|
||||
supportsPromptCacheKey?: boolean
|
||||
}
|
||||
|
||||
export type ProviderInfo = {
|
||||
@@ -4075,6 +4078,7 @@ export type SessionCommandInput = {
|
||||
readonly sessionID: { readonly sessionID: string }["sessionID"]
|
||||
readonly name: {
|
||||
readonly name: string
|
||||
readonly id?: string | null
|
||||
readonly text: string
|
||||
readonly files?: ReadonlyArray<{
|
||||
readonly uri: string
|
||||
@@ -4092,8 +4096,29 @@ export type SessionCommandInput = {
|
||||
}>
|
||||
readonly delivery?: ("steer" | "queue") | null
|
||||
}["name"]
|
||||
readonly id?: {
|
||||
readonly name: string
|
||||
readonly id?: string | null
|
||||
readonly text: string
|
||||
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
|
||||
}["id"]
|
||||
readonly text: {
|
||||
readonly name: string
|
||||
readonly id?: string | null
|
||||
readonly text: string
|
||||
readonly files?: ReadonlyArray<{
|
||||
readonly uri: string
|
||||
@@ -4113,6 +4138,7 @@ export type SessionCommandInput = {
|
||||
}["text"]
|
||||
readonly files?: {
|
||||
readonly name: string
|
||||
readonly id?: string | null
|
||||
readonly text: string
|
||||
readonly files?: ReadonlyArray<{
|
||||
readonly uri: string
|
||||
@@ -4132,6 +4158,7 @@ export type SessionCommandInput = {
|
||||
}["files"]
|
||||
readonly agents?: {
|
||||
readonly name: string
|
||||
readonly id?: string | null
|
||||
readonly text: string
|
||||
readonly files?: ReadonlyArray<{
|
||||
readonly uri: string
|
||||
@@ -4151,6 +4178,7 @@ export type SessionCommandInput = {
|
||||
}["agents"]
|
||||
readonly skills?: {
|
||||
readonly name: string
|
||||
readonly id?: string | null
|
||||
readonly text: string
|
||||
readonly files?: ReadonlyArray<{
|
||||
readonly uri: string
|
||||
@@ -4170,6 +4198,7 @@ export type SessionCommandInput = {
|
||||
}["skills"]
|
||||
readonly delivery?: {
|
||||
readonly name: string
|
||||
readonly id?: string | null
|
||||
readonly text: string
|
||||
readonly files?: ReadonlyArray<{
|
||||
readonly uri: string
|
||||
@@ -4189,7 +4218,7 @@ export type SessionCommandInput = {
|
||||
}["delivery"]
|
||||
}
|
||||
|
||||
export type SessionCommandOutput = void
|
||||
export type SessionCommandOutput = { data: CommandOutcome }["data"]
|
||||
|
||||
export type SessionSkillInput = {
|
||||
readonly sessionID: { readonly sessionID: string }["sessionID"]
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Command } from "@opencode/schema/command"
|
||||
import type { PromptInput } from "@opencode/schema/prompt-input"
|
||||
import type { Session } from "@opencode/schema/session"
|
||||
import type { SessionInbox } from "@opencode/schema/session-inbox"
|
||||
import type { SessionMessage } from "@opencode/schema/session-message"
|
||||
import { makeLocationNode } from "@opencode/util/effect/app-node"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { Bus } from "./bus.js"
|
||||
@@ -11,10 +12,21 @@ import { State } from "./state.js"
|
||||
|
||||
export const Info = Command.Info
|
||||
export type Info = Command.Info
|
||||
export const Outcome = Command.Outcome
|
||||
export type Outcome = Command.Outcome
|
||||
export { Event } from "@opencode/schema/command"
|
||||
|
||||
export function prompted(admitted: { readonly id: SessionMessage.ID }): Outcome {
|
||||
return { type: "prompt", inboxID: admitted.id }
|
||||
}
|
||||
|
||||
export interface Invocation {
|
||||
readonly sessionID: Session.ID
|
||||
/**
|
||||
* Identity for the input this command admits. Commands that prompt a Session must admit
|
||||
* with this ID and report it in their outcome so clients can follow the resulting work.
|
||||
*/
|
||||
readonly messageID: SessionMessage.ID
|
||||
readonly prompt: PromptInput.Prompt
|
||||
readonly delivery: SessionInbox.Delivery
|
||||
}
|
||||
@@ -22,7 +34,8 @@ export interface Invocation {
|
||||
export interface Definition {
|
||||
readonly name: string
|
||||
readonly description?: string
|
||||
readonly execute: (input: Invocation) => Effect.Effect<void, unknown>
|
||||
/** Resolve with an Outcome to report admitted work; `void` means the command finished immediately. */
|
||||
readonly execute: (input: Invocation) => Effect.Effect<Outcome | void, unknown>
|
||||
}
|
||||
|
||||
export type Editor = {
|
||||
@@ -45,7 +58,7 @@ export interface Interface extends State.Transformable<Editor> {
|
||||
readonly execute: (input: {
|
||||
readonly name: string
|
||||
readonly invocation: Invocation
|
||||
}) => Effect.Effect<void, NotFoundError | ExecutionError>
|
||||
}) => Effect.Effect<Outcome, NotFoundError | ExecutionError>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Command") {}
|
||||
@@ -82,10 +95,25 @@ export const layer = Layer.effect(
|
||||
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(
|
||||
const outcome = 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) })),
|
||||
)
|
||||
if (outcome === undefined) return { type: "immediate" as const }
|
||||
// Plugin callbacks cross a JavaScript boundary; only a well-formed outcome is reportable.
|
||||
if (!isOutcome(outcome))
|
||||
return yield* new ExecutionError({
|
||||
command: input.name,
|
||||
message: `Command returned an invalid outcome (${typeof outcome})`,
|
||||
})
|
||||
// Clients follow the invocation ID, so a command that admitted under another ID (typically by
|
||||
// omitting `id: messageID` from its prompt) would leave them waiting forever. Fail loudly instead.
|
||||
if (outcome.type === "prompt" && outcome.inboxID !== input.invocation.messageID)
|
||||
return yield* new ExecutionError({
|
||||
command: input.name,
|
||||
message: `Command admitted ${outcome.inboxID} instead of the invocation message ID ${input.invocation.messageID}`,
|
||||
})
|
||||
return outcome
|
||||
}),
|
||||
})
|
||||
}),
|
||||
@@ -97,6 +125,8 @@ export const node = makeLocationNode({
|
||||
deps: [Bus.node],
|
||||
})
|
||||
|
||||
const isOutcome = Schema.is(Outcome)
|
||||
|
||||
function errorMessage(error: unknown) {
|
||||
if (error instanceof Error) return error.message
|
||||
if (typeof error === "string") return error
|
||||
|
||||
@@ -11,6 +11,7 @@ import { Effect, Option, PubSub, Schema, Stream } from "effect"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { Agent } from "../../agent.js"
|
||||
import { Config } from "../../config.js"
|
||||
import { Command } from "../../command.js"
|
||||
import { Location } from "../../location.js"
|
||||
import { Session } from "../../session.js"
|
||||
import { SubagentJob } from "../../session/subagent-job.js"
|
||||
@@ -104,9 +105,10 @@ export const Plugin = define({
|
||||
agent: selected.id,
|
||||
model: model ?? selected.info?.model ?? parent.model,
|
||||
})
|
||||
yield* sessions.prompt({
|
||||
const admitted = yield* sessions.prompt({
|
||||
...input.prompt,
|
||||
sessionID: child.id,
|
||||
id: input.messageID,
|
||||
text: ["You are a subagent spawned by another session.", text].join("\n"),
|
||||
resume: false,
|
||||
})
|
||||
@@ -119,20 +121,22 @@ export const Plugin = define({
|
||||
}
|
||||
yield* subagents.start(recovery)
|
||||
yield* subagents.background(recovery)
|
||||
return
|
||||
return Command.prompted(admitted)
|
||||
}
|
||||
if (agent !== undefined) {
|
||||
const session = yield* ctx.session.get({ sessionID: input.sessionID })
|
||||
if (session.agent !== agent) yield* ctx.session.switchAgent({ sessionID: input.sessionID, agent })
|
||||
}
|
||||
if (model !== undefined) yield* ctx.session.switchModel({ sessionID: input.sessionID, model })
|
||||
yield* ctx.session.prompt({
|
||||
const admitted = yield* ctx.session.prompt({
|
||||
...input.prompt,
|
||||
sessionID: input.sessionID,
|
||||
id: input.messageID,
|
||||
text,
|
||||
delivery: input.delivery,
|
||||
})
|
||||
}).pipe(Effect.asVoid),
|
||||
return Command.prompted(admitted)
|
||||
}),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ export * as CommandPlugin from "./command.js"
|
||||
import { define } from "@opencode/plugin/effect/plugin"
|
||||
import { Effect, Stream } from "effect"
|
||||
import { Bus } from "../bus.js"
|
||||
import { Command } from "../command.js"
|
||||
import { Location } from "../location.js"
|
||||
import { Mcp } from "../mcp/index.js"
|
||||
import PROMPT_INITIALIZE from "./command/initialize.txt"
|
||||
@@ -34,10 +35,11 @@ export const Plugin = define({
|
||||
.prompt({
|
||||
...input.prompt,
|
||||
sessionID: input.sessionID,
|
||||
id: input.messageID,
|
||||
text: append(PROMPT_INITIALIZE.replace("${path}", location.project.directory), input.prompt.text),
|
||||
delivery: input.delivery,
|
||||
})
|
||||
.pipe(Effect.asVoid),
|
||||
.pipe(Effect.map(Command.prompted)),
|
||||
})
|
||||
editor.add({
|
||||
name: "review",
|
||||
@@ -47,10 +49,11 @@ export const Plugin = define({
|
||||
.prompt({
|
||||
...input.prompt,
|
||||
sessionID: input.sessionID,
|
||||
id: input.messageID,
|
||||
text: append(PROMPT_REVIEW.replace("${path}", location.project.directory), input.prompt.text),
|
||||
delivery: input.delivery,
|
||||
})
|
||||
.pipe(Effect.asVoid),
|
||||
.pipe(Effect.map(Command.prompted)),
|
||||
})
|
||||
for (const prompt of loaded.prompts) {
|
||||
editor.add({
|
||||
@@ -67,16 +70,18 @@ export const Plugin = define({
|
||||
),
|
||||
})
|
||||
if (!result) return yield* Effect.fail(new Error(`MCP prompt not found: ${prompt.server}:${prompt.name}`))
|
||||
yield* ctx.session.prompt({
|
||||
const admitted = yield* ctx.session.prompt({
|
||||
...input.prompt,
|
||||
sessionID: input.sessionID,
|
||||
id: input.messageID,
|
||||
text: result.messages
|
||||
.map((message) => promptMessageText(message.content))
|
||||
.join("\n")
|
||||
.trim(),
|
||||
delivery: input.delivery,
|
||||
})
|
||||
}).pipe(Effect.asVoid),
|
||||
return Command.prompted(admitted)
|
||||
}),
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -186,12 +186,13 @@ export interface Interface {
|
||||
readonly command: (input: {
|
||||
sessionID: SessionSchema.ID
|
||||
command: string
|
||||
id?: SessionMessage.ID
|
||||
text: string
|
||||
files?: PromptInput.Prompt["files"]
|
||||
agents?: PromptInput.Prompt["agents"]
|
||||
skills?: PromptInput.Prompt["skills"]
|
||||
delivery?: SessionInbox.Delivery
|
||||
}) => Effect.Effect<void, NotFoundError | Command.NotFoundError | Command.ExecutionError>
|
||||
}) => Effect.Effect<Command.Outcome, NotFoundError | Command.NotFoundError | Command.ExecutionError>
|
||||
readonly shell: (
|
||||
input: Parameters<Session.Handle["shell"]>[0] & { sessionID: SessionSchema.ID },
|
||||
) => ReturnType<Session.Handle["shell"]>
|
||||
|
||||
@@ -3,6 +3,7 @@ export * as SessionCommand from "./command.js"
|
||||
import type { PromptInput } from "@opencode/schema/prompt-input"
|
||||
import type { Session } from "@opencode/schema/session"
|
||||
import type { SessionInbox } from "@opencode/schema/session-inbox"
|
||||
import { SessionMessage } from "@opencode/schema/session-message"
|
||||
import { Effect } from "effect"
|
||||
import { Command } from "../command.js"
|
||||
import { Instance } from "../instance/service.js"
|
||||
@@ -11,6 +12,7 @@ import { Plugin } from "../plugin/service.js"
|
||||
export const execute = Effect.fn("SessionCommand.execute")(function* (input: {
|
||||
session: Session.Info
|
||||
command: string
|
||||
id?: SessionMessage.ID
|
||||
text: string
|
||||
files?: PromptInput.Prompt["files"]
|
||||
agents?: PromptInput.Prompt["agents"]
|
||||
@@ -19,10 +21,11 @@ export const execute = Effect.fn("SessionCommand.execute")(function* (input: {
|
||||
}) {
|
||||
const instances = yield* Instance.Service
|
||||
const commands = yield* Plugin.awaitActivation.pipe(Effect.andThen(Command.Service), instances.provide(input.session))
|
||||
yield* commands.execute({
|
||||
return yield* commands.execute({
|
||||
name: input.command,
|
||||
invocation: {
|
||||
sessionID: input.session.id,
|
||||
messageID: input.id ?? SessionMessage.ID.create(),
|
||||
prompt: {
|
||||
text: input.text,
|
||||
files: input.files,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect } from "bun:test"
|
||||
import { Command } from "@opencode/core/command"
|
||||
import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder"
|
||||
import { Session } from "@opencode/schema/session"
|
||||
import { SessionMessage } from "@opencode/schema/session-message"
|
||||
import { Effect } from "effect"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
@@ -16,7 +17,10 @@ describe("Command", () => {
|
||||
editor.add({
|
||||
name: "goal",
|
||||
description: "Manage the session goal",
|
||||
execute: (input) => Effect.sync(() => calls.push(input)),
|
||||
execute: (input) =>
|
||||
Effect.sync(() => {
|
||||
calls.push(input)
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
@@ -25,14 +29,58 @@ describe("Command", () => {
|
||||
)
|
||||
const invocation = {
|
||||
sessionID: Session.ID.make("ses_test"),
|
||||
messageID: SessionMessage.ID.make("msg_goal"),
|
||||
prompt: { text: "ship it", files: [{ uri: "file:///tmp/plan.md" }] },
|
||||
delivery: "steer" as const,
|
||||
}
|
||||
yield* command.execute({ name: "goal", invocation })
|
||||
expect(yield* command.execute({ name: "goal", invocation })).toEqual({ type: "immediate" })
|
||||
expect(calls).toEqual([invocation])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("returns prompt outcomes admitted with the invocation message ID", () =>
|
||||
Effect.gen(function* () {
|
||||
const command = yield* Command.Service
|
||||
yield* command.transform((editor) => {
|
||||
editor.add({
|
||||
name: "ask",
|
||||
execute: (input) => Effect.succeed({ type: "prompt" as const, inboxID: input.messageID }),
|
||||
})
|
||||
editor.add({
|
||||
name: "stray",
|
||||
execute: () => Effect.succeed({ type: "prompt" as const, inboxID: SessionMessage.ID.make("msg_other") }),
|
||||
})
|
||||
editor.add({
|
||||
name: "junk",
|
||||
execute: () => Effect.succeed(true as unknown as Command.Outcome),
|
||||
})
|
||||
})
|
||||
const invocation = {
|
||||
sessionID: Session.ID.make("ses_test"),
|
||||
messageID: SessionMessage.ID.make("msg_ask"),
|
||||
prompt: { text: "" },
|
||||
delivery: "steer" as const,
|
||||
}
|
||||
|
||||
expect(yield* command.execute({ name: "ask", invocation })).toEqual({
|
||||
type: "prompt",
|
||||
inboxID: invocation.messageID,
|
||||
})
|
||||
const error = yield* command.execute({ name: "stray", invocation }).pipe(Effect.flip)
|
||||
expect(error).toMatchObject({
|
||||
_tag: "Command.ExecutionError",
|
||||
command: "stray",
|
||||
message: "Command admitted msg_other instead of the invocation message ID msg_ask",
|
||||
})
|
||||
const junk = yield* command.execute({ name: "junk", invocation }).pipe(Effect.flip)
|
||||
expect(junk).toMatchObject({
|
||||
_tag: "Command.ExecutionError",
|
||||
command: "junk",
|
||||
message: "Command returned an invalid outcome (boolean)",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("replaces commands with later definitions", () =>
|
||||
Effect.gen(function* () {
|
||||
const command = yield* Command.Service
|
||||
@@ -60,6 +108,7 @@ describe("Command", () => {
|
||||
name: "fail",
|
||||
invocation: {
|
||||
sessionID: Session.ID.make("ses_test"),
|
||||
messageID: SessionMessage.ID.make("msg_fail"),
|
||||
prompt: { text: "" },
|
||||
delivery: "steer",
|
||||
},
|
||||
|
||||
@@ -13,6 +13,7 @@ import { Model } from "@opencode/core/model"
|
||||
import { Provider } from "@opencode/core/provider"
|
||||
import { AbsolutePath } from "@opencode/core/schema"
|
||||
import { Session } from "@opencode/core/session"
|
||||
import { SessionMessage } from "@opencode/schema/session-message"
|
||||
import { SessionRunnerModel } from "@opencode/core/session/runner/model"
|
||||
import { LayerNode } from "@opencode/util/effect/layer-node"
|
||||
import { Global } from "@opencode/util/global"
|
||||
@@ -83,12 +84,18 @@ describe("command subagents", () => {
|
||||
const gate = yield* llm.gate()
|
||||
|
||||
// This must return while the child's model is still blocked.
|
||||
yield* sessions.command({ sessionID: parent.id, command: "review", text: "changes" })
|
||||
const outcome = yield* sessions.command({
|
||||
sessionID: parent.id,
|
||||
command: "review",
|
||||
id: SessionMessage.ID.make("msg_review"),
|
||||
text: "changes",
|
||||
})
|
||||
yield* gate.started
|
||||
const children = (yield* sessions.list({ parentID: parent.id })).data
|
||||
expect(children).toHaveLength(1)
|
||||
const child = children[0]
|
||||
if (!child) return yield* Effect.die("Expected a child session")
|
||||
expect(outcome).toEqual({ type: "prompt", inboxID: SessionMessage.ID.make("msg_review") })
|
||||
expect(child).toMatchObject({ agent: fixture.agent, model: { id: fixture.model }, title: "Review code" })
|
||||
expect(yield* sessions.get(parent.id)).toMatchObject({ agent: "build", model: parentModel })
|
||||
expect(yield* sessions.context(parent.id)).toEqual([])
|
||||
|
||||
@@ -105,7 +105,7 @@ describe("ConfigCommandPlugin.Plugin", () => {
|
||||
Effect.sync(() => {
|
||||
prompts.push({ text: input.text, delivery: input.delivery })
|
||||
return SessionInbox.User.make({
|
||||
id: SessionMessage.ID.make("msg_test"),
|
||||
id: input.id ?? SessionMessage.ID.make("msg_test"),
|
||||
sessionID: input.sessionID,
|
||||
time: { created: DateTime.makeUnsafe(0) },
|
||||
type: "user",
|
||||
@@ -129,6 +129,7 @@ describe("ConfigCommandPlugin.Plugin", () => {
|
||||
name: "explain",
|
||||
invocation: {
|
||||
sessionID: Session.ID.make("ses_test"),
|
||||
messageID: SessionMessage.ID.make("msg_explain"),
|
||||
prompt: { text: item.input },
|
||||
delivery: "queue",
|
||||
},
|
||||
@@ -176,7 +177,7 @@ Review files`,
|
||||
Effect.sync(() => {
|
||||
prompts.push({ text: input.text, files: input.files, delivery: input.delivery })
|
||||
return SessionInbox.User.make({
|
||||
id: SessionMessage.ID.make("msg_test"),
|
||||
id: input.id ?? SessionMessage.ID.make("msg_test"),
|
||||
sessionID: input.sessionID,
|
||||
time: { created: DateTime.makeUnsafe(0) },
|
||||
type: "user",
|
||||
@@ -210,6 +211,7 @@ Review files`,
|
||||
name: "nested/docs",
|
||||
invocation: {
|
||||
sessionID: Session.ID.make("ses_test"),
|
||||
messageID: SessionMessage.ID.make("msg_docs"),
|
||||
prompt: { text: "details", files: [{ uri: "file:///tmp/context.md" }] },
|
||||
delivery: "queue",
|
||||
},
|
||||
@@ -236,6 +238,7 @@ Review files`,
|
||||
name: "review",
|
||||
invocation: {
|
||||
sessionID: Session.ID.make("ses_test"),
|
||||
messageID: SessionMessage.ID.make("msg_latest"),
|
||||
prompt: { text: "latest" },
|
||||
delivery: "steer",
|
||||
},
|
||||
|
||||
@@ -11,6 +11,7 @@ import { PluginModule } from "@opencode/core/plugin/module"
|
||||
import { Watcher } from "@opencode/core/filesystem/watcher"
|
||||
import { fromPromise } from "@opencode/plugin/promise/adapter"
|
||||
import { Session } from "@opencode/schema/session"
|
||||
import { SessionMessage } from "@opencode/schema/session-message"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { PluginTestLayer } from "./plugin/fixture"
|
||||
|
||||
@@ -497,7 +498,12 @@ it.effect("reloading a plugin replaces its command implementation", () =>
|
||||
])
|
||||
const request = {
|
||||
name: "greet",
|
||||
invocation: { sessionID: Session.ID.make("ses_plugin"), prompt: { text: "" }, delivery: "steer" as const },
|
||||
invocation: {
|
||||
sessionID: Session.ID.make("ses_plugin"),
|
||||
messageID: SessionMessage.ID.make("msg_greet"),
|
||||
prompt: { text: "" },
|
||||
delivery: "steer" as const,
|
||||
},
|
||||
}
|
||||
|
||||
yield* load("1", "before")
|
||||
|
||||
@@ -37,6 +37,7 @@ describe("CommandPlugin.Plugin", () => {
|
||||
Effect.gen(function* () {
|
||||
const command = yield* Command.Service
|
||||
const prompts: {
|
||||
id?: string
|
||||
text: string
|
||||
files?: readonly { readonly uri: string }[]
|
||||
delivery?: "steer" | "queue"
|
||||
@@ -51,9 +52,9 @@ describe("CommandPlugin.Plugin", () => {
|
||||
session: {
|
||||
prompt: (input) =>
|
||||
Effect.sync(() => {
|
||||
prompts.push({ text: input.text, files: input.files, delivery: input.delivery })
|
||||
prompts.push({ id: input.id, text: input.text, files: input.files, delivery: input.delivery })
|
||||
return SessionInbox.User.make({
|
||||
id: SessionMessage.ID.make("msg_test"),
|
||||
id: input.id ?? SessionMessage.ID.make("msg_test"),
|
||||
sessionID: input.sessionID,
|
||||
time: { created: DateTime.makeUnsafe(0) },
|
||||
type: "user",
|
||||
@@ -78,10 +79,11 @@ describe("CommandPlugin.Plugin", () => {
|
||||
name: "review",
|
||||
description: "review changes [commit|branch|pr], defaults to uncommitted",
|
||||
})
|
||||
yield* command.execute({
|
||||
const outcome = yield* command.execute({
|
||||
name: "init",
|
||||
invocation: {
|
||||
sessionID: Session.ID.make("ses_test"),
|
||||
messageID: SessionMessage.ID.make("msg_init"),
|
||||
prompt: { text: "extra context", files: [{ uri: "file:///tmp/context.md" }] },
|
||||
delivery: "queue",
|
||||
},
|
||||
@@ -90,6 +92,7 @@ describe("CommandPlugin.Plugin", () => {
|
||||
name: "review",
|
||||
invocation: {
|
||||
sessionID: Session.ID.make("ses_test"),
|
||||
messageID: SessionMessage.ID.make("msg_review"),
|
||||
prompt: { text: " branch $& $$ $` $' " },
|
||||
delivery: "steer",
|
||||
},
|
||||
@@ -98,6 +101,7 @@ describe("CommandPlugin.Plugin", () => {
|
||||
name: "init",
|
||||
invocation: {
|
||||
sessionID: Session.ID.make("ses_test"),
|
||||
messageID: SessionMessage.ID.make("msg_init_empty"),
|
||||
prompt: { text: "" },
|
||||
delivery: "steer",
|
||||
},
|
||||
@@ -106,27 +110,33 @@ describe("CommandPlugin.Plugin", () => {
|
||||
name: "review",
|
||||
invocation: {
|
||||
sessionID: Session.ID.make("ses_test"),
|
||||
messageID: SessionMessage.ID.make("msg_review_empty"),
|
||||
prompt: { text: " " },
|
||||
delivery: "steer",
|
||||
},
|
||||
})
|
||||
expect(outcome).toEqual({ type: "prompt", inboxID: SessionMessage.ID.make("msg_init") })
|
||||
expect(prompts).toEqual([
|
||||
{
|
||||
id: "msg_init",
|
||||
text: PROMPT_INITIALIZE.replace("${path}", project).replaceAll("$ARGUMENTS", "extra context"),
|
||||
files: [{ uri: "file:///tmp/context.md" }],
|
||||
delivery: "queue",
|
||||
},
|
||||
{
|
||||
id: "msg_review",
|
||||
text: PROMPT_REVIEW.replace("${path}", project).replaceAll("$ARGUMENTS", () => "branch $& $$ $` $'"),
|
||||
files: undefined,
|
||||
delivery: "steer",
|
||||
},
|
||||
{
|
||||
id: "msg_init_empty",
|
||||
text: PROMPT_INITIALIZE.replace("${path}", project).replaceAll("$ARGUMENTS", ""),
|
||||
files: undefined,
|
||||
delivery: "steer",
|
||||
},
|
||||
{
|
||||
id: "msg_review_empty",
|
||||
text: PROMPT_REVIEW.replace("${path}", project).replaceAll("$ARGUMENTS", ""),
|
||||
files: undefined,
|
||||
delivery: "steer",
|
||||
|
||||
@@ -1,20 +1,30 @@
|
||||
import type { CommandApi } from "@opencode/client/effect/api"
|
||||
import type { Command } from "@opencode/schema/command"
|
||||
import type { PromptInput } from "@opencode/schema/prompt-input"
|
||||
import type { Session } from "@opencode/schema/session"
|
||||
import type { SessionInbox } from "@opencode/schema/session-inbox"
|
||||
import type { SessionMessage } from "@opencode/schema/session-message"
|
||||
import type { Effect } from "effect"
|
||||
import type { Transform } from "./registration.js"
|
||||
|
||||
export interface CommandInvocation {
|
||||
readonly sessionID: Session.ID
|
||||
/**
|
||||
* Identity for the input this command admits. Pass it as the prompt `id` and report it in a
|
||||
* `prompt` outcome so clients can follow the resulting work.
|
||||
*/
|
||||
readonly messageID: SessionMessage.ID
|
||||
readonly prompt: PromptInput.Prompt
|
||||
readonly delivery: SessionInbox.Delivery
|
||||
}
|
||||
|
||||
/** Resolve with `prompt` when the command admitted session input; `void` or `immediate` finish the command now. */
|
||||
export type CommandOutcome = Command.Outcome
|
||||
|
||||
export interface CommandDefinition {
|
||||
readonly name: string
|
||||
readonly description?: string
|
||||
readonly execute: (input: CommandInvocation) => Effect.Effect<void, unknown>
|
||||
readonly execute: (input: CommandInvocation) => Effect.Effect<CommandOutcome | void, unknown>
|
||||
}
|
||||
|
||||
export interface CommandEditor {
|
||||
|
||||
@@ -1,19 +1,29 @@
|
||||
import type { CommandApi } from "@opencode/client/promise/api"
|
||||
import type { Command } from "@opencode/schema/command"
|
||||
import type { PromptInput } from "@opencode/schema/prompt-input"
|
||||
import type { Session } from "@opencode/schema/session"
|
||||
import type { SessionInbox } from "@opencode/schema/session-inbox"
|
||||
import type { SessionMessage } from "@opencode/schema/session-message"
|
||||
import type { Transform } from "./registration.js"
|
||||
|
||||
export interface CommandInvocation {
|
||||
readonly sessionID: Session.ID
|
||||
/**
|
||||
* Identity for the input this command admits. Pass it as the prompt `id` and report it in a
|
||||
* `prompt` outcome so clients can follow the resulting work.
|
||||
*/
|
||||
readonly messageID: SessionMessage.ID
|
||||
readonly prompt: PromptInput.Prompt
|
||||
readonly delivery: SessionInbox.Delivery
|
||||
}
|
||||
|
||||
/** Resolve with `prompt` when the command admitted session input; `void` or `immediate` finish the command now. */
|
||||
export type CommandOutcome = Command.Outcome
|
||||
|
||||
export interface CommandDefinition {
|
||||
readonly name: string
|
||||
readonly description?: string
|
||||
readonly execute: (input: CommandInvocation) => Promise<void>
|
||||
readonly execute: (input: CommandInvocation) => Promise<CommandOutcome | void>
|
||||
}
|
||||
|
||||
export interface CommandEditor {
|
||||
|
||||
@@ -2050,8 +2050,22 @@
|
||||
],
|
||||
"security": [],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "<No Content>"
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"$ref": "#/components/schemas/Command.Outcome"
|
||||
}
|
||||
},
|
||||
"required": ["data"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "InvalidRequestError",
|
||||
@@ -2104,7 +2118,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Execute a slash command callback immediately.",
|
||||
"description": "Execute a slash command callback. Returns whether the command finished immediately or admitted session input whose execution continues after this request.",
|
||||
"summary": "Run command",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
@@ -2115,6 +2129,18 @@
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"pattern": "^msg_"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Message ID for input the command admits, so its resulting work can be correlated."
|
||||
},
|
||||
"text": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -12811,6 +12837,37 @@
|
||||
"required": ["name"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Command.Outcome": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["immediate"]
|
||||
}
|
||||
},
|
||||
"required": ["type"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["prompt"]
|
||||
},
|
||||
"inboxID": {
|
||||
"type": "string",
|
||||
"pattern": "^msg_",
|
||||
"description": "Admitted inbox item ID. Must equal the invocation message ID."
|
||||
}
|
||||
},
|
||||
"required": ["type", "inboxID"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"CommandExecutionErrorEncoded": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -15549,6 +15606,9 @@
|
||||
},
|
||||
"requireAssistantAfterTool": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"supportsPromptCacheKey": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
UnknownError,
|
||||
} from "../errors.js"
|
||||
import { Agent } from "@opencode/schema/agent"
|
||||
import { Command } from "@opencode/schema/command"
|
||||
import { Skill } from "@opencode/schema/skill"
|
||||
import { Model } from "@opencode/schema/model"
|
||||
import { Permission } from "@opencode/schema/permission"
|
||||
@@ -395,10 +396,13 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
|
||||
params: { sessionID: Session.ID },
|
||||
payload: Schema.Struct({
|
||||
name: Schema.String,
|
||||
id: SessionMessage.ID.pipe(Schema.optional).annotate({
|
||||
description: "Message ID for input the command admits, so its resulting work can be correlated.",
|
||||
}),
|
||||
...PromptInput.Prompt.fields,
|
||||
delivery: SessionInbox.Delivery.pipe(Schema.optional),
|
||||
}),
|
||||
success: HttpApiSchema.NoContent,
|
||||
success: Schema.Struct({ data: Command.Outcome }),
|
||||
error: [SessionNotFoundError, CommandNotFoundError, CommandExecutionError],
|
||||
})
|
||||
.middleware(sessionLocationMiddleware)
|
||||
@@ -406,7 +410,8 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
|
||||
OpenApi.annotations({
|
||||
identifier: "session.command",
|
||||
summary: "Run command",
|
||||
description: "Execute a slash command callback immediately.",
|
||||
description:
|
||||
"Execute a slash command callback. Returns whether the command finished immediately or admitted session input whose execution continues after this request.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -539,9 +544,7 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
|
||||
error: [SessionNotFoundError, SessionBusyError],
|
||||
})
|
||||
.middleware(sessionLocationMiddleware)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({ identifier: "session.revert.commit", summary: "Commit staged revert" }),
|
||||
),
|
||||
.annotateMerge(OpenApi.annotations({ identifier: "session.revert.commit", summary: "Commit staged revert" })),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.get("session.context", "/api/session/:sessionID/context", {
|
||||
|
||||
@@ -3,6 +3,7 @@ export * as Command from "./command.js"
|
||||
import { Schema } from "effect"
|
||||
import { ephemeral, inventory } from "./event.js"
|
||||
import { optional } from "./schema.js"
|
||||
import { SessionMessage } from "./session-message.js"
|
||||
|
||||
const Updated = ephemeral({ type: "command.updated", schema: {} })
|
||||
|
||||
@@ -12,6 +13,22 @@ export const Info = Schema.Struct({
|
||||
description: Schema.String.pipe(optional),
|
||||
}).annotate({ identifier: "Command.Info" })
|
||||
|
||||
/**
|
||||
* What a command did when invoked. `immediate` commands finish inside the request;
|
||||
* `prompt` commands durably admitted session input whose resulting work clients
|
||||
* follow through the invocation message ID.
|
||||
*/
|
||||
export const Outcome = Schema.Union([
|
||||
Schema.Struct({ type: Schema.tag("immediate") }),
|
||||
Schema.Struct({
|
||||
type: Schema.tag("prompt"),
|
||||
inboxID: SessionMessage.ID.annotate({
|
||||
description: "Admitted inbox item ID. Must equal the invocation message ID.",
|
||||
}),
|
||||
}),
|
||||
]).pipe(Schema.toTaggedUnion("type"), Schema.annotate({ identifier: "Command.Outcome" }))
|
||||
export type Outcome = typeof Outcome.Type
|
||||
|
||||
export const Event = {
|
||||
Updated,
|
||||
Definitions: inventory(Updated),
|
||||
|
||||
@@ -315,10 +315,11 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
|
||||
.handle(
|
||||
"session.command",
|
||||
Effect.fn(function* (ctx) {
|
||||
yield* session
|
||||
const outcome = yield* session
|
||||
.command({
|
||||
sessionID: ctx.params.sessionID,
|
||||
command: ctx.payload.name,
|
||||
id: ctx.payload.id,
|
||||
text: ctx.payload.text,
|
||||
files: ctx.payload.files,
|
||||
agents: ctx.payload.agents,
|
||||
@@ -344,7 +345,7 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
|
||||
),
|
||||
),
|
||||
)
|
||||
return HttpApiSchema.NoContent.make()
|
||||
return { data: outcome }
|
||||
}),
|
||||
)
|
||||
.handle(
|
||||
|
||||
@@ -1754,7 +1754,8 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||
}
|
||||
|
||||
input.trace?.write("send.command", { sessionID: input.sessionID, messageID, command: command.name, delivery })
|
||||
return client.session.command(
|
||||
// Command outcomes are not inbox items; the event stream observes any admitted work.
|
||||
await client.session.command(
|
||||
{
|
||||
sessionID: input.sessionID,
|
||||
name: command.name,
|
||||
|
||||
@@ -712,9 +712,9 @@ describe("V2 mini transport", () => {
|
||||
})
|
||||
|
||||
while (!ui.commits.some((commit) => commit.text === "Done.")) await Bun.sleep(0)
|
||||
expect(ui.commits.filter((commit) => commit.kind === "assistant" || commit.kind === "tool").map((commit) => commit.text)).toEqual([
|
||||
"Done.",
|
||||
])
|
||||
expect(
|
||||
ui.commits.filter((commit) => commit.kind === "assistant" || commit.kind === "tool").map((commit) => commit.text),
|
||||
).toEqual(["Done."])
|
||||
await transport.close()
|
||||
})
|
||||
|
||||
@@ -742,7 +742,11 @@ describe("V2 mini transport", () => {
|
||||
model: { providerID: "test", id: "model" },
|
||||
content: [
|
||||
{ type: "text", text: "I'll check." },
|
||||
canonicalToolPart("read", { status: "completed", input: {}, content: [{ type: "text", text: "file" }] }),
|
||||
canonicalToolPart("read", {
|
||||
status: "completed",
|
||||
input: {},
|
||||
content: [{ type: "text", text: "file" }],
|
||||
}),
|
||||
],
|
||||
time: { created: 2, completed: 3 },
|
||||
},
|
||||
@@ -759,7 +763,9 @@ describe("V2 mini transport", () => {
|
||||
|
||||
while (!ui.commits.some((commit) => commit.text === "Done.")) await Bun.sleep(0)
|
||||
expect(
|
||||
ui.commits.filter((commit) => commit.kind === "user" || commit.kind === "assistant" || commit.kind === "tool").map((commit) => commit.text),
|
||||
ui.commits
|
||||
.filter((commit) => commit.kind === "user" || commit.kind === "assistant" || commit.kind === "tool")
|
||||
.map((commit) => commit.text),
|
||||
).toEqual(["what happened", "Done."])
|
||||
await transport.close()
|
||||
})
|
||||
@@ -2887,10 +2893,7 @@ describe("V2 mini transport", () => {
|
||||
{ sessionID: "ses_1", model: { providerID: "openai", id: "gpt-5", variant: "high" } },
|
||||
{ signal: undefined },
|
||||
)
|
||||
expect(defaultModel).toHaveBeenCalledWith(
|
||||
{ location: { directory: "/project" } },
|
||||
{ signal: undefined },
|
||||
)
|
||||
expect(defaultModel).toHaveBeenCalledWith({ location: { directory: "/project" } }, { signal: undefined })
|
||||
await transport.close()
|
||||
})
|
||||
|
||||
@@ -3364,7 +3367,7 @@ describe("V2 mini transport", () => {
|
||||
data: { sessionID: "ses_1" },
|
||||
})
|
||||
})
|
||||
return ok(undefined)
|
||||
return ok({ type: "prompt" as const, inboxID: "msg_cmd" })
|
||||
})
|
||||
|
||||
await transport.runPromptTurn({
|
||||
|
||||
@@ -2050,8 +2050,22 @@
|
||||
],
|
||||
"security": [],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "<No Content>"
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"$ref": "#/components/schemas/Command.Outcome"
|
||||
}
|
||||
},
|
||||
"required": ["data"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "InvalidRequestError",
|
||||
@@ -2104,7 +2118,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Execute a slash command callback immediately.",
|
||||
"description": "Execute a slash command callback. Returns whether the command finished immediately or admitted session input whose execution continues after this request.",
|
||||
"summary": "Run command",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
@@ -2115,6 +2129,18 @@
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"pattern": "^msg_"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Message ID for input the command admits, so its resulting work can be correlated."
|
||||
},
|
||||
"text": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -12811,6 +12837,37 @@
|
||||
"required": ["name"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Command.Outcome": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["immediate"]
|
||||
}
|
||||
},
|
||||
"required": ["type"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["prompt"]
|
||||
},
|
||||
"inboxID": {
|
||||
"type": "string",
|
||||
"pattern": "^msg_",
|
||||
"description": "Admitted inbox item ID. Must equal the invocation message ID."
|
||||
}
|
||||
},
|
||||
"required": ["type", "inboxID"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"CommandExecutionErrorEncoded": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -15549,6 +15606,9 @@
|
||||
},
|
||||
"requireAssistantAfterTool": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"supportsPromptCacheKey": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
|
||||
@@ -2050,8 +2050,22 @@
|
||||
],
|
||||
"security": [],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "<No Content>"
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"$ref": "#/components/schemas/Command.Outcome"
|
||||
}
|
||||
},
|
||||
"required": ["data"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "InvalidRequestError",
|
||||
@@ -2104,7 +2118,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Execute a slash command callback immediately.",
|
||||
"description": "Execute a slash command callback. Returns whether the command finished immediately or admitted session input whose execution continues after this request.",
|
||||
"summary": "Run command",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
@@ -2115,6 +2129,18 @@
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"pattern": "^msg_"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Message ID for input the command admits, so its resulting work can be correlated."
|
||||
},
|
||||
"text": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -12811,6 +12837,37 @@
|
||||
"required": ["name"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Command.Outcome": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["immediate"]
|
||||
}
|
||||
},
|
||||
"required": ["type"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["prompt"]
|
||||
},
|
||||
"inboxID": {
|
||||
"type": "string",
|
||||
"pattern": "^msg_",
|
||||
"description": "Admitted inbox item ID. Must equal the invocation message ID."
|
||||
}
|
||||
},
|
||||
"required": ["type", "inboxID"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"CommandExecutionErrorEncoded": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -15549,6 +15606,9 @@
|
||||
},
|
||||
"requireAssistantAfterTool": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"supportsPromptCacheKey": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
|
||||
@@ -471,7 +471,7 @@ effect: (ctx) =>
|
||||
}),
|
||||
```
|
||||
|
||||
The current command transform is add-only. An executor receives the session, prompt attachments, and delivery mode.
|
||||
The current command transform is add-only. An executor receives the session, a message ID for admitted input, prompt attachments, and delivery mode. Return a `prompt` outcome when the command admits session input so clients follow that work; succeed with `void` for immediate actions.
|
||||
|
||||
```ts
|
||||
effect: (ctx) =>
|
||||
@@ -483,12 +483,15 @@ effect: (ctx) =>
|
||||
name: "security-review",
|
||||
description: "Review changes for security issues",
|
||||
execute: (input) =>
|
||||
session.prompt({
|
||||
...input.prompt,
|
||||
sessionID: input.sessionID,
|
||||
text: `Review these changes for security issues.\n\n${input.prompt.text}`,
|
||||
delivery: input.delivery,
|
||||
}).pipe(Effect.asVoid),
|
||||
session
|
||||
.prompt({
|
||||
...input.prompt,
|
||||
sessionID: input.sessionID,
|
||||
id: input.messageID,
|
||||
text: `Review these changes for security issues.\n\n${input.prompt.text}`,
|
||||
delivery: input.delivery,
|
||||
})
|
||||
.pipe(Effect.map((admitted) => ({ type: "prompt" as const, inboxID: admitted.id }))),
|
||||
})
|
||||
})
|
||||
yield* command.reload()
|
||||
|
||||
@@ -419,25 +419,48 @@ Read the commands available at a location.
|
||||
const commands = await ctx.command.list()
|
||||
```
|
||||
|
||||
Register commands with a transform. The executor receives the session, prompt attachments, and requested delivery mode.
|
||||
Register commands with a transform. The executor receives the session, a message ID for any input it admits, prompt attachments, and the requested delivery mode.
|
||||
|
||||
```ts
|
||||
await ctx.command.transform((editor) => {
|
||||
editor.add({
|
||||
name: "security-review",
|
||||
description: "Review changes for security issues",
|
||||
execute: async ({ sessionID, prompt, delivery }) => {
|
||||
await ctx.session.prompt({
|
||||
execute: async ({ sessionID, messageID, prompt, delivery }) => {
|
||||
const admitted = await ctx.session.prompt({
|
||||
...prompt,
|
||||
sessionID,
|
||||
id: messageID,
|
||||
text: `Review these changes for security issues.\n\n${prompt.text}`,
|
||||
delivery,
|
||||
})
|
||||
return { type: "prompt", inboxID: admitted.id }
|
||||
},
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
#### Outcomes
|
||||
|
||||
Return an outcome so clients know whether the command finished or started model work they should follow.
|
||||
|
||||
| Outcome | Meaning |
|
||||
| -------------------------------------- | ------------------------------------------------------------------------------------------------ |
|
||||
| `undefined` or `{ type: "immediate" }` | The command finished inside the request. Clients complete the slash command right away. |
|
||||
| `{ type: "prompt", inboxID }` | The command admitted session input. Clients keep the slash command open until that work settles. |
|
||||
|
||||
Admit with `id: messageID` and echo it as `inboxID`; a different `inboxID` fails the command. The input may go to the invoking session or to a child session it creates.
|
||||
|
||||
```ts
|
||||
editor.add({
|
||||
name: "clear-scratch",
|
||||
description: "Delete the scratch directory",
|
||||
execute: async () => {
|
||||
await ctx.storage.remove("scratch")
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
Reload commands after external state used by a transform changes.
|
||||
|
||||
```ts
|
||||
@@ -447,6 +470,7 @@ await ctx.command.reload()
|
||||
#### Reference
|
||||
|
||||
Schemas: [`Command.Info`](/api#schema-Command.Info),
|
||||
[`Command.Outcome`](/api#schema-Command.Outcome),
|
||||
[`Session.Inbox.Delivery`](/api#schema-Session.Inbox.Delivery)
|
||||
|
||||
```ts
|
||||
@@ -463,14 +487,17 @@ interface CommandEditor {
|
||||
interface CommandDefinition {
|
||||
name: string
|
||||
description?: string
|
||||
execute(input: CommandInvocation): Promise<void>
|
||||
execute(input: CommandInvocation): Promise<CommandOutcome | void>
|
||||
}
|
||||
|
||||
interface CommandInvocation {
|
||||
sessionID: string
|
||||
messageID: string
|
||||
prompt: PromptInput
|
||||
delivery: "steer" | "queue"
|
||||
}
|
||||
|
||||
type CommandOutcome = { type: "immediate" } | { type: "prompt"; inboxID: string }
|
||||
```
|
||||
|
||||
### Integrations
|
||||
|
||||
Reference in New Issue
Block a user