fix(cli): harden acp lifecycle

This commit is contained in:
Shoubhit Dash
2026-07-20 16:13:36 +05:30
parent 4b2691a18d
commit 9735f7baf7
14 changed files with 234 additions and 87 deletions
+1
View File
@@ -97,6 +97,7 @@ function isACPError(error: unknown): error is ACPError.Error {
error instanceof ACPError.InvalidModelError ||
error instanceof ACPError.InvalidEffortError ||
error instanceof ACPError.InvalidModeError ||
error instanceof ACPError.AuthRequiredError ||
error instanceof ACPError.UnknownAuthMethodError ||
error instanceof ACPError.ServiceFailureError
)
+6 -3
View File
@@ -33,7 +33,8 @@ export function buildConfigOptions(input: {
currentModeId?: string
}): SessionConfigOption[] {
const variants = variantsForModel(input.providers, input.currentModel)
const effort = variants.length > 0 ? buildEffortSelectOption({ variants, currentVariant: input.currentVariant }) : undefined
const effort =
variants.length > 0 ? buildEffortSelectOption({ variants, currentVariant: input.currentVariant }) : undefined
return [
buildModelSelectOption({ providers: input.providers, currentModel: input.currentModel }),
...(effort ? [effort] : []),
@@ -122,8 +123,10 @@ export function formatVariantName(variant: string) {
}
function variantsForModel(providers: readonly ConfigOptionProvider[], model: ModelSelection["model"]) {
return providers.find((provider) => provider.id === model.providerID)?.models.find((item) => item.id === model.modelID)
?.variants ?? []
return (
providers.find((provider) => provider.id === model.providerID)?.models.find((item) => item.id === model.modelID)
?.variants ?? []
)
}
function selectVariant(variant: string | undefined, variants: readonly string[]) {
+10 -5
View File
@@ -148,7 +148,12 @@ export function partsToContentChunks(parts: readonly ReplayPart[]): ContentChunk
function resourceLinkToPart(link: ResourceLink): PromptPart {
try {
if (link.uri.startsWith("file://")) {
return { type: "file", url: link.uri, filename: link.name || filenameFromUri(link.uri) || "file", mime: link.mimeType ?? "text/plain" }
return {
type: "file",
url: link.uri,
filename: link.name || filenameFromUri(link.uri) || "file",
mime: link.mimeType ?? "text/plain",
}
}
if (link.uri.startsWith("zed://")) {
const pathname = new URL(link.uri).searchParams.get("path")
@@ -164,9 +169,9 @@ function resourceLinkToPart(link: ResourceLink): PromptPart {
return { type: "text", text: link.uri }
}
function decodeDataUrl(url: string) {
function decodeDataUrl(url: string): { readonly mime: string; readonly base64: string } | undefined {
const match = /^data:([^;]+);base64,(.*)$/.exec(url)
if (!match?.[1] || match[2] === undefined) return
if (!match?.[1] || match[2] === undefined) return undefined
return { mime: match[1], base64: match[2] }
}
@@ -176,8 +181,8 @@ function audienceFlags(audience: readonly Role[] | null | undefined) {
return {}
}
function filenameFromUri(uri: string | undefined) {
if (!uri || uri.startsWith("data:")) return
function filenameFromUri(uri: string | undefined): string | undefined {
if (!uri || uri.startsWith("data:")) return undefined
try {
return path.basename(new URL(uri).pathname) || undefined
} catch {
+8 -1
View File
@@ -23,6 +23,8 @@ export class InvalidModeError extends Schema.TaggedErrorClass<InvalidModeError>(
mode: Schema.String,
}) {}
export class AuthRequiredError extends Schema.TaggedErrorClass<AuthRequiredError>()("ACPAuthRequiredError", {}) {}
export class UnknownAuthMethodError extends Schema.TaggedErrorClass<UnknownAuthMethodError>()(
"ACPUnknownAuthMethodError",
{ methodId: Schema.String },
@@ -40,10 +42,11 @@ export type Error =
| InvalidModelError
| InvalidEffortError
| InvalidModeError
| AuthRequiredError
| UnknownAuthMethodError
| ServiceFailureError
export function toRequestError(error: Error) {
export function toRequestError(error: Error): RequestError {
switch (error._tag) {
case "ACPSessionNotFoundError":
return RequestError.invalidParams({ sessionId: error.sessionId }, `session not found: ${error.sessionId}`)
@@ -58,6 +61,8 @@ export function toRequestError(error: Error) {
return RequestError.invalidParams({ effort: error.effort }, `effort not found: ${error.effort}`)
case "ACPInvalidModeError":
return RequestError.invalidParams({ mode: error.mode }, `mode not found: ${error.mode}`)
case "ACPAuthRequiredError":
return RequestError.authRequired({}, "provider authentication required")
case "ACPUnknownAuthMethodError":
return RequestError.invalidParams({ methodId: error.methodId }, `unknown auth method: ${error.methodId}`)
case "ACPServiceFailureError":
@@ -69,6 +74,8 @@ export function toRequestError(error: Error) {
error.safeMessage,
)
}
const exhaustive: never = error
return exhaustive
}
export function fromUnknown(error: unknown, service?: string) {
+62 -10
View File
@@ -11,7 +11,15 @@ import type {
SessionMessageInfo,
} from "@opencode-ai/client/promise"
import { partsToContentChunks, type ReplayPart } from "./content"
import { completedToolUpdate, errorToolUpdate, pendingToolCall, runningToolUpdate, type ToolContent, type ToolInput } from "./tool"
import { ACPError } from "./error"
import {
completedToolUpdate,
errorToolUpdate,
pendingToolCall,
runningToolUpdate,
type ToolContent,
type ToolInput,
} from "./tool"
type Connection = Pick<AgentSideConnection, "sessionUpdate" | "requestPermission">
@@ -44,6 +52,7 @@ export async function streamTurn(input: {
readonly sessionID: string
readonly cwd: string
readonly start: Start
readonly userMessageID?: string | null
readonly submit: (signal: AbortSignal) => Promise<unknown>
readonly activate: (control: TurnControl) => void
readonly deactivate: () => void
@@ -58,6 +67,7 @@ export async function streamTurn(input: {
let started = false
let assistantMessageID: string | undefined
let finish: SessionMessageAssistant["finish"]
let executionError: { readonly type: string; readonly message: string } | undefined
const tools = new Map<string, ToolState>()
const update = (value: Parameters<Connection["sessionUpdate"]>[0]["update"]) =>
@@ -220,7 +230,10 @@ export async function streamTurn(input: {
}
if (event.type === "session.execution.succeeded") return "succeeded" as const
if (event.type === "session.execution.interrupted") return "interrupted" as const
if (event.type === "session.execution.failed") return "failed" as const
if (event.type === "session.execution.failed") {
executionError = event.data.error
return "failed" as const
}
}
return "interrupted" as const
}
@@ -233,9 +246,22 @@ export async function streamTurn(input: {
})
const terminal = await completed
const assistant = assistantMessageID
? await input.client.session.message({ sessionID: input.sessionID, messageID: assistantMessageID }).catch(() => undefined)
? await input.client.session
.message({ sessionID: input.sessionID, messageID: assistantMessageID })
.catch(() => undefined)
: undefined
return response(assistant?.type === "assistant" ? assistant : undefined, terminal, control.cancelled, finish, input.start.id)
return response(
assistant?.type === "assistant" ? assistant : undefined,
executionError,
terminal,
control.cancelled,
finish,
input.userMessageID,
)
} catch (error) {
streamController.abort()
await completed.catch(() => {})
throw error
} finally {
input.deactivate()
streamController.abort()
@@ -261,7 +287,7 @@ export async function replayMessages(
})
const files: ReplayPart[] = (message.files ?? []).map((file) => ({
type: "file",
url: `data:${file.mime};base64,${file.data}`,
url: file.source.type === "uri" ? file.source.uri : `data:${file.mime};base64,${file.data}`,
filename: file.name,
mime: file.mime,
}))
@@ -320,6 +346,21 @@ export async function replayMessages(
},
})
}
if (part.state.status === "running") {
await connection.sessionUpdate({
sessionId: sessionID,
update: {
sessionUpdate: "tool_call_update",
...runningToolUpdate({
toolCallId: part.id,
toolName: part.name,
state: { input: part.state.input },
content: part.state.content,
cwd,
}),
},
})
}
if (part.state.status === "error") {
await connection.sessionUpdate({
sessionId: sessionID,
@@ -343,7 +384,8 @@ export async function replayMessages(
function matchesStart(event: EventSubscribeOutput, start: Start) {
if (start.type === "input") return event.type === "session.input.promoted" && event.data.inputID === start.id
if (start.type === "compaction") return event.type === "session.compaction.admitted" && event.data.inputID === start.id
if (start.type === "compaction")
return event.type === "session.compaction.admitted" && event.data.inputID === start.id
return event.type === "session.skill.activated" && event.id === start.id.replace(/^msg_/, "evt_")
}
@@ -359,11 +401,21 @@ function toolInput(tool: Extract<SessionMessageAssistant["content"][number], { t
function response(
assistant: SessionMessageAssistant | undefined,
executionError: { readonly type: string; readonly message: string } | undefined,
terminal: "succeeded" | "failed" | "interrupted",
cancelled: boolean,
finish: SessionMessageAssistant["finish"],
messageID: string,
messageID: string | null | undefined,
): PromptResponse {
const error = assistant?.error ?? executionError
if (error?.type === "provider.auth") throw new ACPError.AuthRequiredError()
if (error && error.type !== "aborted" && error.type !== "provider.content-filter") {
throw new ACPError.ServiceFailureError({
safeMessage: error.message || "OpenCode prompt failed",
service: "session",
errorName: error.type,
})
}
const tokens = assistant?.tokens
const usage = tokens
? {
@@ -376,14 +428,14 @@ function response(
}
: undefined
const stopReason =
cancelled || terminal === "interrupted"
cancelled || terminal === "interrupted" || error?.type === "aborted"
? ("cancelled" as const)
: finish === "length"
? ("max_tokens" as const)
: finish === "content-filter"
: finish === "content-filter" || error?.type === "provider.content-filter"
? ("refusal" as const)
: ("end_turn" as const)
return { stopReason, ...(usage ? { usage } : {}), userMessageId: messageID, _meta: {} }
return { stopReason, ...(usage ? { usage } : {}), ...(messageID ? { userMessageId: messageID } : {}), _meta: {} }
}
export * as ACPEvent from "./event"
+47 -42
View File
@@ -1,3 +1,13 @@
import {
isSessionNotFoundError,
type CommandInfo,
type ModelInfo,
type ModelRef,
type OpenCodeClient,
type SessionInfo,
type SessionMessageInfo,
type SkillInfo,
} from "@opencode-ai/client/promise"
import type {
AgentSideConnection,
AuthenticateRequest,
@@ -21,7 +31,6 @@ import type {
PromptResponse,
ResumeSessionRequest,
ResumeSessionResponse,
SessionInfo as ACPSessionInfo,
SetSessionConfigOptionRequest,
SetSessionConfigOptionResponse,
SetSessionModelRequest,
@@ -29,17 +38,6 @@ import type {
SetSessionModeRequest,
SetSessionModeResponse,
} from "@agentclientprotocol/sdk"
import type {
AgentInfo,
CommandInfo,
LocationRef,
ModelInfo,
ModelRef,
OpenCodeClient,
SessionInfo,
SessionMessageInfo,
SkillInfo,
} from "@opencode-ai/client/promise"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import { buildConfigOptions, parseModelSelection, type ConfigOptionProvider } from "./config-option"
@@ -91,8 +89,7 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
const registeredMcp = new Map<string, Set<string>>()
const active = new Map<string, TurnControl>()
const catalog = (cwd: string, refresh = false) => {
if (refresh) catalogs.delete(cwd)
const catalog = (cwd: string) => {
const cached = catalogs.get(cwd)
if (cached) return cached
const loaded = loadCatalog(input.client, cwd).catch((error) => {
@@ -174,7 +171,7 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
},
loadSession: async (params) => {
const session = await getSession(input.client, params.sessionId)
const state = await attach(session, params.cwd, params.mcpServers, true)
const state = await attach(session, session.location.directory, params.mcpServers, true)
return { configOptions: configOptions(state) }
},
listSessions: async (params) => {
@@ -185,20 +182,18 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
...(params.cursor ? { cursor: params.cursor } : {}),
})
return {
sessions: page.data.map(
(session): ACPSessionInfo => ({
sessionId: session.id,
cwd: session.location.directory,
title: session.title,
updatedAt: new Date(session.time.updated).toISOString(),
}),
),
sessions: page.data.map((session) => ({
sessionId: session.id,
cwd: session.location.directory,
title: session.title,
updatedAt: new Date(session.time.updated).toISOString(),
})),
...(page.cursor.next ? { nextCursor: page.cursor.next } : {}),
}
},
resumeSession: async (params) => {
const session = await getSession(input.client, params.sessionId)
const state = await attach(session, params.cwd, params.mcpServers ?? [], false)
const state = await attach(session, session.location.directory, params.mcpServers ?? [], false)
return { configOptions: configOptions(state) }
},
closeSession: async (params) => {
@@ -213,15 +208,13 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
return {}
},
forkSession: async (params) => {
await requireSession(params.sessionId)
const forked = await input.client.session.fork({ sessionID: params.sessionId })
const state = await attach(forked, params.cwd, params.mcpServers ?? [], true)
const state = await attach(forked, forked.location.directory, params.mcpServers ?? [], true)
return { sessionId: state.id, configOptions: configOptions(state) }
},
setSessionConfigOption: async (params) => {
const state = await requireSession(params.sessionId)
if (typeof params.value !== "string")
throw new ACPError.InvalidConfigOptionError({ configId: params.configId })
if (typeof params.value !== "string") throw new ACPError.InvalidConfigOptionError({ configId: params.configId })
if (params.configId === "model") {
const selected = requireModel(state.catalog, params.value)
state.model = selected
@@ -257,15 +250,12 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
},
prompt: async (params) => {
const state = await requireSession(params.sessionId)
state.catalog = await catalog(state.cwd, true)
const messageID = params.messageId ?? SessionMessage.ID.create()
const messageID = SessionMessage.ID.create()
const parts = promptContentToParts(params.prompt)
const visible = parts.filter((part) => part.type !== "text" || (!part.synthetic && !part.ignored))
const synthetic = parts.flatMap((part) => (part.type === "text" && part.synthetic ? [part.text] : []))
const text = visible.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n")
const files = visible.flatMap((part) =>
part.type === "file" ? [{ uri: part.url, name: part.filename, description: part.mime }] : [],
)
const files = visible.flatMap((part) => (part.type === "file" ? [{ uri: part.url, name: part.filename }] : []))
const slash = detectSlashCommand(text)
const command = slash ? state.catalog.commands.find((item) => item.name === slash.name) : undefined
const skill = slash ? state.catalog.skills.find((item) => item.name === slash.name) : undefined
@@ -281,6 +271,7 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
sessionID: state.id,
cwd: state.cwd,
start,
userMessageID: params.messageId,
activate: (control) => active.set(state.id, control),
deactivate: () => active.delete(state.id),
submit: async (signal) => {
@@ -297,7 +288,14 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
if (skill) return input.client.session.skill({ sessionID: state.id, id: messageID, skill: skill.id })
if (command)
return input.client.session.command(
{ sessionID: state.id, id: messageID, command: command.name, arguments: slash?.args, delivery: "steer" },
{
sessionID: state.id,
id: messageID,
command: command.name,
arguments: slash?.args,
files,
delivery: "steer",
},
{ signal },
)
return input.client.session.prompt(
@@ -306,7 +304,7 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
)
},
})
await sendUsageUpdate(input.client, input.connection, state)
await sendUsageUpdate(input.client, input.connection, state).catch(() => {})
return response
},
cancel: async (params) => {
@@ -338,7 +336,11 @@ async function loadCatalog(client: OpenCodeClient, cwd: string): Promise<Catalog
return {
providers: providers(models),
models,
defaultModel: { providerID: defaultModel.providerID, id: defaultModel.id, variant: defaultModel.variants[0]?.id },
defaultModel: {
providerID: defaultModel.providerID,
id: defaultModel.id,
variant: defaultModel.variants.find((variant) => variant.id === "default")?.id ?? defaultModel.variants[0]?.id,
},
modes: agents.map((agent) => ({ id: agent.id, name: agent.name, description: agent.description })),
defaultModeID: defaultAgent.id,
commands: commandResult.data,
@@ -376,8 +378,9 @@ async function selectMode(client: OpenCodeClient, state: Attached, modeID: strin
}
async function getSession(client: OpenCodeClient, sessionID: string) {
return client.session.get({ sessionID }).catch(() => {
throw new ACPError.SessionNotFoundError({ sessionId: sessionID })
return client.session.get({ sessionID }).catch((error) => {
if (isSessionNotFoundError(error)) throw new ACPError.SessionNotFoundError({ sessionId: sessionID })
throw error
})
}
@@ -385,7 +388,9 @@ async function messages(client: OpenCodeClient, sessionID: string) {
const result: SessionMessageInfo[] = []
let cursor: string | undefined
do {
const page = await client.message.list({ sessionID, limit: 200, order: "asc", cursor })
const page = cursor
? await client.message.list({ sessionID, limit: 200, cursor })
: await client.message.list({ sessionID, limit: 200, order: "asc" })
result.push(...page.data)
cursor = page.cursor.next ?? undefined
} while (cursor)
@@ -473,11 +478,11 @@ async function sendUsageUpdate(client: OpenCodeClient, connection: Connection, s
})
}
function detectSlashCommand(text: string) {
function detectSlashCommand(text: string): { readonly name: string; readonly args: string } | undefined {
const value = text.trim()
if (!value.startsWith("/")) return
if (!value.startsWith("/")) return undefined
const [name, ...rest] = value.slice(1).split(/\s+/)
if (!name) return
if (!name) return undefined
return { name, args: rest.join(" ").trim() }
}
+3 -5
View File
@@ -130,10 +130,7 @@ export function errorToolUpdate(input: {
title: toolTitle(input.toolName, input.input, undefined),
locations: toLocations(input.toolName, input.input, input.cwd),
rawInput: rawInput(input.toolName, input.input, input.cwd),
content: [
...toolContent(input.content),
{ type: "content", content: { type: "text", text: input.error } },
],
content: [...toolContent(input.content), { type: "content", content: { type: "text", text: input.error } }],
rawOutput: { structured: input.structured, error: input.error },
}
}
@@ -173,7 +170,8 @@ function locationFrom(...values: unknown[]): ToolCallLocation[] {
return Array.from(
new Set(
values.flatMap((value): string[] => {
if (Array.isArray(value)) return value.filter((item): item is string => typeof item === "string" && item.length > 0)
if (Array.isArray(value))
return value.filter((item): item is string => typeof item === "string" && item.length > 0)
const path = stringValue(value)
return path ? [path] : []
}),
+10 -4
View File
@@ -23,7 +23,7 @@ describe("acp command", () => {
test("initializes over ndjson and exits on stdin eof", async () => {
const child = spawn()
const stderr = new Response(child.stderr).text()
child.stdin.write(
await child.stdin.write(
new TextEncoder().encode(
JSON.stringify({
jsonrpc: "2.0",
@@ -37,7 +37,7 @@ describe("acp command", () => {
}) + "\n",
),
)
child.stdin.flush()
await child.stdin.flush()
const response = await readMessage(child.stdout)
expect(response.id).toBe(1)
expect(response.error).toBeUndefined()
@@ -47,7 +47,7 @@ describe("acp command", () => {
agentInfo: { name: "OpenCode" },
})
child.stdin.end()
await child.stdin.end()
const exitCode = await child.exited
const errorOutput = await stderr
if (exitCode !== 0) throw new Error(`ACP exited with ${exitCode}: ${errorOutput}`)
@@ -82,10 +82,16 @@ async function readMessage(stream: ReadableStream<Uint8Array>) {
const newline = output.indexOf("\n")
if (newline === -1) continue
reader.releaseLock()
return JSON.parse(output.slice(0, newline)) as Message
const message: unknown = JSON.parse(output.slice(0, newline))
if (!isMessage(message)) throw new Error(`invalid ACP response: ${output.slice(0, newline)}`)
return message
}
}
function isMessage(value: unknown): value is Message {
return typeof value === "object" && value !== null
}
async function cli(args: string[]) {
const child = Bun.spawn([process.execPath, "run", "src/index.ts", ...args], {
cwd: path.join(import.meta.dir, "../.."),
+10 -2
View File
@@ -1,5 +1,10 @@
import { describe, expect, test } from "bun:test"
import { buildConfigOptions, formatVariantName, parseModelSelection, type ConfigOptionProvider } from "../../src/acp/config-option"
import {
buildConfigOptions,
formatVariantName,
parseModelSelection,
type ConfigOptionProvider,
} from "../../src/acp/config-option"
const providers: ConfigOptionProvider[] = [
{
@@ -19,7 +24,10 @@ describe("acp config options", () => {
providers,
currentModel: { providerID: "anthropic", modelID: "claude/sonnet-4" },
currentVariant: "high",
modes: [{ id: "build", name: "Build" }, { id: "plan", name: "Plan" }],
modes: [
{ id: "build", name: "Build" },
{ id: "plan", name: "Plan" },
],
currentModeId: "build",
})
expect(options.map((option) => option.id)).toEqual(["model", "effort", "mode"])
+9 -7
View File
@@ -13,12 +13,12 @@ describe("acp content", () => {
})
test("converts file and embedded resources", () => {
expect(
contentBlockToParts({ type: "resource_link", uri: "file:///tmp/notes.txt", name: "notes.txt" }),
).toEqual([{ type: "file", url: "file:///tmp/notes.txt", filename: "notes.txt", mime: "text/plain" }])
expect(
contentBlockToParts({ type: "resource", resource: { uri: "mcp://context", text: "hello" } }),
).toEqual([{ type: "text", text: "[mcp://context]\nhello" }])
expect(contentBlockToParts({ type: "resource_link", uri: "file:///tmp/notes.txt", name: "notes.txt" })).toEqual([
{ type: "file", url: "file:///tmp/notes.txt", filename: "notes.txt", mime: "text/plain" },
])
expect(contentBlockToParts({ type: "resource", resource: { uri: "mcp://context", text: "hello" } })).toEqual([
{ type: "text", text: "[mcp://context]\nhello" },
])
})
test("replays files and data urls", () => {
@@ -28,7 +28,9 @@ describe("acp content", () => {
{ type: "file", url: "data:text/plain;base64,aGVsbG8=", filename: "note.txt", mime: "text/plain" },
]),
).toEqual([
{ content: { type: "resource_link", uri: "file:///tmp/readme.md", name: "readme.md", mimeType: "text/markdown" } },
{
content: { type: "resource_link", uri: "file:///tmp/readme.md", name: "readme.md", mimeType: "text/markdown" },
},
{
content: {
type: "resource",
+26
View File
@@ -0,0 +1,26 @@
import { describe, expect, test } from "bun:test"
import { RequestError } from "@agentclientprotocol/sdk"
import { ACPError } from "../../src/acp/error"
describe("acp errors", () => {
test("maps validation failures to invalid params", () => {
const errors: ACPError.Error[] = [
new ACPError.SessionNotFoundError({ sessionId: "ses_missing" }),
new ACPError.InvalidConfigOptionError({ configId: "temperature" }),
new ACPError.InvalidModelError({ modelId: "missing" }),
new ACPError.InvalidEffortError({ effort: "extreme" }),
new ACPError.InvalidModeError({ mode: "turbo" }),
]
expect(errors.map((error) => ACPError.toRequestError(error).code)).toEqual([-32602, -32602, -32602, -32602, -32602])
})
test("maps auth and service failures safely", () => {
const auth = ACPError.toRequestError(new ACPError.AuthRequiredError())
expect(auth).toBeInstanceOf(RequestError)
expect(auth.code).toBe(-32000)
const internal = ACPError.toRequestError(ACPError.fromUnknown(new Error("secret token"), "session"))
expect(internal.code).toBe(-32603)
expect(JSON.stringify(internal.toErrorResponse())).not.toContain("secret token")
})
})
+10 -3
View File
@@ -23,14 +23,19 @@ test("acp prompt resolves after ordered turn updates", async () => {
)
}
if (url.pathname === "/api/session/ses_test/prompt") {
const body = (await request.json()) as { id: string }
const body: unknown = await request.json()
if (!body || typeof body !== "object") {
return new Response(null, { status: 400 })
}
const id = Reflect.get(body, "id")
if (typeof id !== "string") return new Response(null, { status: 400 })
queueMicrotask(() => {
if (!events) return
send(events, {
id: "evt_promoted",
created: 1,
type: "session.input.promoted",
data: { sessionID: "ses_test", inputID: body.id },
data: { sessionID: "ses_test", inputID: id },
})
send(events, {
id: "evt_text",
@@ -80,6 +85,7 @@ test("acp prompt resolves after ordered turn updates", async () => {
try {
const id = "msg_prompt"
const userMessageID = "client-message"
const response = await streamTurn({
client,
connection: {
@@ -91,6 +97,7 @@ test("acp prompt resolves after ordered turn updates", async () => {
sessionID: "ses_test",
cwd: "/workspace",
start: { type: "input", id },
userMessageID,
submit: () => client.session.prompt({ sessionID: "ses_test", id, text: "hi" }),
activate: () => {},
deactivate: () => {},
@@ -106,7 +113,7 @@ test("acp prompt resolves after ordered turn updates", async () => {
},
},
])
expect(response).toMatchObject({ stopReason: "end_turn", userMessageId: id, usage: { totalTokens: 2 } })
expect(response).toMatchObject({ stopReason: "end_turn", userMessageId: userMessageID, usage: { totalTokens: 2 } })
} finally {
events?.close()
await server.stop(true)
+2 -1
View File
@@ -20,7 +20,8 @@ describe("acp service", () => {
if (url.pathname === "/api/model") return Response.json({ location, data: [model] })
if (url.pathname === "/api/model/default") return Response.json({ location, data: model })
if (url.pathname === "/api/agent") return Response.json({ location, data: [agent] })
if (url.pathname === "/api/command") return Response.json({ location, data: [{ name: "review", template: "" }] })
if (url.pathname === "/api/command")
return Response.json({ location, data: [{ name: "review", template: "" }] })
if (url.pathname === "/api/skill") return Response.json({ location, data: [skill] })
if (url.pathname === "/api/session" && request.method === "POST") return Response.json({ data: session })
if (url.pathname === "/api/mcp/docs" && request.method === "PUT") return new Response(null, { status: 204 })
+30 -4
View File
@@ -1,5 +1,12 @@
import { describe, expect, test } from "bun:test"
import { completedToolUpdate, errorToolUpdate, pendingToolCall, runningToolUpdate, toLocations, toToolKind } from "../../src/acp/tool"
import {
completedToolUpdate,
errorToolUpdate,
pendingToolCall,
runningToolUpdate,
toLocations,
toToolKind,
} from "../../src/acp/tool"
describe("acp tools", () => {
test("maps kinds and locations", () => {
@@ -11,7 +18,9 @@ describe("acp tools", () => {
})
test("builds tool lifecycle updates", () => {
expect(pendingToolCall({ toolCallId: "call", toolName: "read", state: { input: { filePath: "/tmp/a" } } })).toMatchObject({
expect(
pendingToolCall({ toolCallId: "call", toolName: "read", state: { input: { filePath: "/tmp/a" } } }),
).toMatchObject({
toolCallId: "call",
status: "pending",
kind: "read",
@@ -20,12 +29,29 @@ describe("acp tools", () => {
toolCallId: "call",
status: "in_progress",
})
expect(completedToolUpdate({ toolCallId: "call", toolName: "read", input: {}, content: [{ type: "text", text: "done" }], structured: {} })).toMatchObject({
expect(
completedToolUpdate({
toolCallId: "call",
toolName: "read",
input: {},
content: [{ type: "text", text: "done" }],
structured: {},
}),
).toMatchObject({
toolCallId: "call",
status: "completed",
content: [{ type: "content", content: { type: "text", text: "done" } }],
})
expect(errorToolUpdate({ toolCallId: "call", toolName: "read", input: {}, content: [], structured: {}, error: "failed" })).toMatchObject({
expect(
errorToolUpdate({
toolCallId: "call",
toolName: "read",
input: {},
content: [],
structured: {},
error: "failed",
}),
).toMatchObject({
toolCallId: "call",
status: "failed",
})