fix(acp): surface prompt errors (#34061)

This commit is contained in:
Shoubhit Dash
2026-06-26 12:20:38 +00:00
committed by GitHub
parent 971518c6d9
commit 6e8e772582
3 changed files with 136 additions and 26 deletions
+8 -1
View File
@@ -46,6 +46,7 @@ export class UnsupportedOperationError extends Schema.TaggedErrorClass<Unsupport
export class ServiceFailureError extends Schema.TaggedErrorClass<ServiceFailureError>()("ACPServiceFailureError", { export class ServiceFailureError extends Schema.TaggedErrorClass<ServiceFailureError>()("ACPServiceFailureError", {
safeMessage: Schema.String, safeMessage: Schema.String,
service: Schema.optional(Schema.String), service: Schema.optional(Schema.String),
errorName: Schema.optional(Schema.String),
}) {} }) {}
export type Error = export type Error =
@@ -81,7 +82,13 @@ export function toRequestError(error: Error) {
case "ACPUnsupportedOperationError": case "ACPUnsupportedOperationError":
return RequestError.methodNotFound(error.method) return RequestError.methodNotFound(error.method)
case "ACPServiceFailureError": case "ACPServiceFailureError":
return RequestError.internalError({ service: error.service }, error.safeMessage) return RequestError.internalError(
{
...(error.service ? { service: error.service } : {}),
...(error.errorName ? { errorName: error.errorName } : {}),
},
error.safeMessage,
)
} }
} }
+55 -7
View File
@@ -30,7 +30,7 @@ import {
type SetSessionModeResponse, type SetSessionModeResponse,
} from "@agentclientprotocol/sdk" } from "@agentclientprotocol/sdk"
import { InstallationVersion } from "@opencode-ai/core/installation/version" import { InstallationVersion } from "@opencode-ai/core/installation/version"
import type { Message, OpencodeClient, SessionMessageResponse } from "@opencode-ai/sdk/v2" import type { AssistantMessage, Message, OpencodeClient, SessionMessageResponse } from "@opencode-ai/sdk/v2"
import { Context, Effect, Layer, ManagedRuntime } from "effect" import { Context, Effect, Layer, ManagedRuntime } from "effect"
import * as ACPError from "./error" import * as ACPError from "./error"
import { buildConfigOptions, parseModelSelection } from "./config-option" import { buildConfigOptions, parseModelSelection } from "./config-option"
@@ -521,7 +521,7 @@ export function make(input: {
"session", "session",
) )
yield* sendUsageUpdate(input.usage, input.sdk, input.connection, current.id, current.cwd) yield* sendUsageUpdate(input.usage, input.sdk, input.connection, current.id, current.cwd)
return promptResponse(response.info, params.messageId) return yield* promptResponse(response.info, params.messageId)
} }
const known = snapshot.availableCommands.find((item) => item.name === command.name) const known = snapshot.availableCommands.find((item) => item.name === command.name)
@@ -543,7 +543,7 @@ export function make(input: {
"session", "session",
) )
yield* sendUsageUpdate(input.usage, input.sdk, input.connection, current.id, current.cwd) yield* sendUsageUpdate(input.usage, input.sdk, input.connection, current.id, current.cwd)
return promptResponse(response.info, params.messageId) return yield* promptResponse(response.info, params.messageId)
} }
if (command.name === "compact") { if (command.name === "compact") {
@@ -563,7 +563,7 @@ export function make(input: {
} }
yield* sendUsageUpdate(input.usage, input.sdk, input.connection, current.id, current.cwd) yield* sendUsageUpdate(input.usage, input.sdk, input.connection, current.id, current.cwd)
return promptResponse(undefined, params.messageId) return yield* promptResponse(undefined, params.messageId)
}), }),
cancel, cancel,
} }
@@ -695,7 +695,8 @@ type MessageInfo = {
readonly agent?: Message["agent"] readonly agent?: Message["agent"]
} }
type AssistantInfo = UsageService.AssistantTokenCost | undefined type AssistantError = NonNullable<AssistantMessage["error"]>
type AssistantInfo = (UsageService.AssistantTokenCost & Pick<AssistantMessage, "error">) | undefined
function request<T>(fn: () => Promise<T | SdkResponse<T>>, service?: string) { function request<T>(fn: () => Promise<T | SdkResponse<T>>, service?: string) {
return Effect.tryPromise({ return Effect.tryPromise({
@@ -811,13 +812,60 @@ function detectSlashCommand(parts: ReturnType<typeof promptContentToParts>) {
return { name, args: rest.join(" ").trim() } return { name, args: rest.join(" ").trim() }
} }
function promptResponse(info: AssistantInfo, messageId: string | null | undefined): PromptResponse { const promptResponse = Effect.fn("ACP.promptResponse")(function* (
info: AssistantInfo,
messageId: string | null | undefined,
) {
if (!info?.error) {
return { return {
stopReason: "end_turn", stopReason: "end_turn" as const,
...(info ? { usage: UsageService.buildUsage(info) } : {}), ...(info ? { usage: UsageService.buildUsage(info) } : {}),
...(messageId ? { userMessageId: messageId } : {}), ...(messageId ? { userMessageId: messageId } : {}),
_meta: {}, _meta: {},
} }
}
const base = {
usage: UsageService.buildUsage(info),
...(messageId ? { userMessageId: messageId } : {}),
_meta: {},
}
if (info.error.name === "MessageAbortedError") {
return {
stopReason: "cancelled" as const,
...base,
}
}
if (info.error.name === "MessageOutputLengthError") {
return {
stopReason: "max_tokens" as const,
...base,
}
}
if (info.error.name === "ContentFilterError") {
return {
stopReason: "refusal" as const,
...base,
}
}
if (info.error.name === "ProviderAuthError") {
return yield* new ACPError.AuthRequiredError({ providerId: info.error.data.providerID })
}
return yield* new ACPError.ServiceFailureError({
service: "session",
safeMessage: promptErrorMessage(info.error),
errorName: info.error.name,
})
})
function promptErrorMessage(error: AssistantError) {
if ("message" in error.data && typeof error.data.message === "string") return error.data.message
return "OpenCode prompt failed"
} }
function sendUsageUpdate( function sendUsageUpdate(
@@ -10,7 +10,7 @@ import type {
SessionConfigSelectOption, SessionConfigSelectOption,
SetSessionConfigOptionResponse, SetSessionConfigOptionResponse,
} from "@agentclientprotocol/sdk" } from "@agentclientprotocol/sdk"
import type { OpencodeClient } from "@opencode-ai/sdk/v2" import type { AssistantMessage, OpencodeClient } from "@opencode-ai/sdk/v2"
import { ProviderV2 } from "@opencode-ai/core/provider" import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model" import { ModelV2 } from "@opencode-ai/core/model"
import { Effect } from "effect" import { Effect } from "effect"
@@ -144,7 +144,10 @@ const provider: Provider.Info = {
describe("ACP service sessions", () => { describe("ACP service sessions", () => {
const makeService = ( const makeService = (
messages: readonly { info: unknown; parts: readonly unknown[] }[] = [], messages: readonly { info: unknown; parts: readonly unknown[] }[] = [],
options?: { abort?: (input: { sessionID: string }) => Promise<{ data: boolean }> }, options?: {
abort?: (input: { sessionID: string }) => Promise<{ data: boolean }>
prompt?: (input: unknown) => Promise<{ data: { info: ReturnType<typeof assistantInfo> } }>
},
) => { ) => {
const updates: SessionNotification[] = [] const updates: SessionNotification[] = []
const mcpAdds: string[] = [] const mcpAdds: string[] = []
@@ -193,7 +196,9 @@ describe("ACP service sessions", () => {
data: input.directory ? sessions.filter((session) => session.directory === input.directory) : sessions, data: input.directory ? sessions.filter((session) => session.directory === input.directory) : sessions,
}), }),
messages: () => Promise.resolve({ data: messages }), messages: () => Promise.resolve({ data: messages }),
prompt: (input: unknown) => { prompt:
options?.prompt ??
((input: unknown) => {
prompts.push(input) prompts.push(input)
return Promise.resolve({ return Promise.resolve({
data: { data: {
@@ -205,7 +210,7 @@ describe("ACP service sessions", () => {
}), }),
}, },
}) })
}, }),
command: (input: unknown) => { command: (input: unknown) => {
commands.push(input) commands.push(input)
return Promise.resolve({ return Promise.resolve({
@@ -994,6 +999,52 @@ describe("ACP service sessions", () => {
expect(usageUpdates).toEqual([session.sessionId]) expect(usageUpdates).toEqual([session.sessionId])
}) })
it("maps assistant prompt errors to request errors instead of end turn", async () => {
const { service } = makeService([], {
prompt: () =>
Promise.resolve({
data: {
info: assistantInfo(
{ input: 8, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
{ name: "APIError", data: { message: "Provider request failed", isRetryable: false } },
),
},
}),
})
const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
const error = await Effect.runPromise(
service
.prompt({ sessionId: session.sessionId, prompt: [{ type: "text", text: "hello" }] })
.pipe(Effect.mapError(ACPError.toRequestError), Effect.flip),
)
expect(error.code).toBe(-32603)
expect(error.message).toBe("Internal error: Provider request failed")
expect(error.data).toEqual({ service: "session", errorName: "APIError" })
})
it("maps aborted assistant prompt errors to cancelled", async () => {
const { service } = makeService([], {
prompt: () =>
Promise.resolve({
data: {
info: assistantInfo(
{ input: 8, output: 1, reasoning: 0, cache: { read: 0, write: 0 } },
{ name: "MessageAbortedError", data: { message: "Aborted" } },
),
},
}),
})
const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
const result = await Effect.runPromise(
service.prompt({ sessionId: session.sessionId, prompt: [{ type: "text", text: "hello" }] }),
)
expect(result.stopReason).toBe("cancelled")
})
it("prompt maps assistant and user audience annotations", async () => { it("prompt maps assistant and user audience annotations", async () => {
const { service, prompts } = makeService() const { service, prompts } = makeService()
const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] })) const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
@@ -1145,13 +1196,17 @@ describe("ACP service sessions", () => {
}) })
}) })
function assistantInfo(tokens: UsageService.AssistantTokenCost["tokens"]): UsageService.AssistantMessage { function assistantInfo(
tokens: UsageService.AssistantTokenCost["tokens"],
error?: AssistantMessage["error"],
): UsageService.AssistantMessage & Pick<AssistantMessage, "error"> {
return { return {
role: "assistant", role: "assistant",
providerID: "test", providerID: "test",
modelID: "test-model", modelID: "test-model",
cost: 0, cost: 0,
tokens, tokens,
...(error ? { error } : {}),
} }
} }