fix(acp): surface prompt errors (#34061)
This commit is contained in:
@@ -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,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 } : {}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user