fix(cli): harden acp lifecycle
This commit is contained in:
@@ -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
|
||||
)
|
||||
|
||||
@@ -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[]) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
|
||||
|
||||
@@ -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] : []
|
||||
}),
|
||||
|
||||
@@ -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, "../.."),
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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",
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user