Quellcode durchsuchen

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

Shoubhit Dash vor 2 Monaten
Ursprung
Commit
6e8e772582

+ 8 - 1
packages/opencode/src/acp/error.ts

@@ -46,6 +46,7 @@ export class UnsupportedOperationError extends Schema.TaggedErrorClass<Unsupport
 export class ServiceFailureError extends Schema.TaggedErrorClass<ServiceFailureError>()("ACPServiceFailureError", {
   safeMessage: Schema.String,
   service: Schema.optional(Schema.String),
+  errorName: Schema.optional(Schema.String),
 }) {}
 
 export type Error =
@@ -81,7 +82,13 @@ export function toRequestError(error: Error) {
     case "ACPUnsupportedOperationError":
       return RequestError.methodNotFound(error.method)
     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,
+      )
   }
 }
 

+ 57 - 9
packages/opencode/src/acp/service.ts

@@ -30,7 +30,7 @@ import {
   type SetSessionModeResponse,
 } from "@agentclientprotocol/sdk"
 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 * as ACPError from "./error"
 import { buildConfigOptions, parseModelSelection } from "./config-option"
@@ -521,7 +521,7 @@ export function make(input: {
           "session",
         )
         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)
@@ -543,7 +543,7 @@ export function make(input: {
           "session",
         )
         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") {
@@ -563,7 +563,7 @@ export function make(input: {
       }
 
       yield* sendUsageUpdate(input.usage, input.sdk, input.connection, current.id, current.cwd)
-      return promptResponse(undefined, params.messageId)
+      return yield* promptResponse(undefined, params.messageId)
     }),
     cancel,
   }
@@ -695,7 +695,8 @@ type MessageInfo = {
   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) {
   return Effect.tryPromise({
@@ -811,13 +812,60 @@ function detectSlashCommand(parts: ReturnType<typeof promptContentToParts>) {
   return { name, args: rest.join(" ").trim() }
 }
 
-function promptResponse(info: AssistantInfo, messageId: string | null | undefined): PromptResponse {
-  return {
-    stopReason: "end_turn",
-    ...(info ? { usage: UsageService.buildUsage(info) } : {}),
+const promptResponse = Effect.fn("ACP.promptResponse")(function* (
+  info: AssistantInfo,
+  messageId: string | null | undefined,
+) {
+  if (!info?.error) {
+    return {
+      stopReason: "end_turn" as const,
+      ...(info ? { usage: UsageService.buildUsage(info) } : {}),
+      ...(messageId ? { userMessageId: messageId } : {}),
+      _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(

+ 71 - 16
packages/opencode/test/acp/service-session.test.ts

@@ -10,7 +10,7 @@ import type {
   SessionConfigSelectOption,
   SetSessionConfigOptionResponse,
 } 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 { ModelV2 } from "@opencode-ai/core/model"
 import { Effect } from "effect"
@@ -144,7 +144,10 @@ const provider: Provider.Info = {
 describe("ACP service sessions", () => {
   const makeService = (
     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 mcpAdds: string[] = []
@@ -193,19 +196,21 @@ describe("ACP service sessions", () => {
             data: input.directory ? sessions.filter((session) => session.directory === input.directory) : sessions,
           }),
         messages: () => Promise.resolve({ data: messages }),
-        prompt: (input: unknown) => {
-          prompts.push(input)
-          return Promise.resolve({
-            data: {
-              info: assistantInfo({
-                input: 100,
-                output: 40,
-                reasoning: 7,
-                cache: { read: 11, write: 13 },
-              }),
-            },
-          })
-        },
+        prompt:
+          options?.prompt ??
+          ((input: unknown) => {
+            prompts.push(input)
+            return Promise.resolve({
+              data: {
+                info: assistantInfo({
+                  input: 100,
+                  output: 40,
+                  reasoning: 7,
+                  cache: { read: 11, write: 13 },
+                }),
+              },
+            })
+          }),
         command: (input: unknown) => {
           commands.push(input)
           return Promise.resolve({
@@ -994,6 +999,52 @@ describe("ACP service sessions", () => {
     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 () => {
     const { service, prompts } = makeService()
     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 {
     role: "assistant",
     providerID: "test",
     modelID: "test-model",
     cost: 0,
     tokens,
+    ...(error ? { error } : {}),
   }
 }