Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/surface-acp-turn-failures.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Report failed ACP turns through auth, refusal, or sanitized JSON-RPC error outcomes instead of successful empty completions.
2 changes: 2 additions & 0 deletions docs/en/reference/kimi-acp.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ The spec divides methods into a **stable** surface and an evolving **unstable**
| `session/close` | No | |
| `logout` | No | |

`session/prompt` does not report a failed turn as `end_turn`. Authentication failures return `authRequired (-32000)` without error data, provider filtering and prompt-hook blocks return `refusal`, and other failures return `internalError (-32603)`. For public Kimi error codes returned as `internalError`, `error.data` contains only `code`, the canonical `retryable` value, and an optional valid HTTP `statusCode`; private codes, raw provider messages, and other details are not sent over ACP.

### Stable client-side reverse-RPC — agent → IDE (4 / 9)

| Method | Implemented | Description |
Expand Down
2 changes: 2 additions & 0 deletions docs/zh/reference/kimi-acp.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ kimi acp
| `session/close` | 否 | |
| `logout` | 否 | |

`session/prompt` 不会把失败的轮次报告为 `end_turn`。认证失败返回不含错误数据的 `authRequired (-32000)`,供应商过滤和提示词 Hook 拦截返回 `refusal`,其余失败返回 `internalError (-32603)`。对于以 `internalError` 返回的公开 Kimi 错误码,`error.data` 仅包含 `code`、规范的 `retryable` 值和可选的合法 HTTP `statusCode`;私有错误码、供应商原始消息与其他详细信息不会通过 ACP 发送。

### 稳定面 client-side reverse-RPC — agent → IDE(4 / 9)

| 方法 | 状态 | 说明 |
Expand Down
21 changes: 5 additions & 16 deletions packages/acp-adapter/src/events-map.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,33 +49,22 @@ export function assistantDeltaToSessionUpdate(
*
* `completed` → `end_turn`: the model finished a clean turn.
* `cancelled` → `cancelled`: the client/agent cancelled mid-turn.
* `failed` → `end_turn` *with* an out-of-band log: the SDK reports a
* step-level error via `TurnEndedEvent.error`. ACP's `StopReason` does
* not have a dedicated `failed` variant in this protocol version, and
* the spec discourages signaling errors through `stopReason` (errors
* belong on the JSON-RPC error channel). Returning `end_turn` keeps the
* client unblocked; the caller is expected to log the `error` payload
* separately so the failure is observable in the agent logs.
* `failed` + `provider.filtered` → `refusal`: the provider's safety policy
* blocked the response. ACP's `refusal` stop reason is the native signal
* for a model/provider decline, so the client can render the block instead
* of mistaking it for a clean `end_turn`.
* `blocked` → `refusal`: a prompt hook blocked the turn before the model
* ran. ACP has no separate hook-blocked terminal state, so reuse the
* refusal channel instead of reporting a clean `end_turn`.
*
* Failed turns are deliberately excluded from this function's input. The
* caller must settle those through the JSON-RPC error channel, except for
* provider filtering, which has the native ACP `refusal` stop reason.
*/
export function turnEndReasonToStopReason(
reason: TurnEndReason,
error?: { readonly code: string },
reason: Exclude<TurnEndReason, 'failed'>,
): AcpStopReason {
switch (reason) {
case 'completed':
return 'end_turn';
case 'cancelled':
return 'cancelled';
case 'failed':
if (error?.code === 'provider.filtered') return 'refusal';
return 'end_turn';
case 'blocked':
return 'refusal';
}
Expand Down
55 changes: 55 additions & 0 deletions packages/acp-adapter/src/prompt-failure.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { RequestError, type PromptResponse } from '@agentclientprotocol/sdk';
import { ErrorCodes, KIMI_ERROR_INFO } from '@moonshot-ai/kimi-code-sdk';

export type PromptFailureOutcome =
| { readonly kind: 'authRequired'; readonly error: RequestError }
| { readonly kind: 'refusal'; readonly response: PromptResponse }
| { readonly kind: 'internalError'; readonly error: RequestError };

export function mapPromptFailure(error: unknown): PromptFailureOutcome {
const code = kimiErrorCodeFromUnknown(error);
if (code === ErrorCodes.AUTH_LOGIN_REQUIRED || code === ErrorCodes.PROVIDER_AUTH_ERROR) {
return { kind: 'authRequired', error: RequestError.authRequired() };
}
if (code === ErrorCodes.PROVIDER_FILTERED) {
return { kind: 'refusal', response: { stopReason: 'refusal' } };
}

const info = code === undefined ? undefined : KIMI_ERROR_INFO[code];
const data =
info?.public === true
? {
code,
retryable: info.retryable,
statusCode: validHttpStatusCode(detailsFromUnknown(error)?.['statusCode']),
}
: undefined;
return {
kind: 'internalError',
error: RequestError.internalError(data, 'session prompt failed'),
};
}

function kimiErrorCodeFromUnknown(
error: unknown,
): keyof typeof KIMI_ERROR_INFO | undefined {
if (error === null || typeof error !== 'object' || !('code' in error)) return undefined;
const code = error.code;
return typeof code === 'string' && Object.hasOwn(KIMI_ERROR_INFO, code)
? (code as keyof typeof KIMI_ERROR_INFO)
: undefined;
}

function detailsFromUnknown(error: unknown): Record<string, unknown> | undefined {
if (error === null || typeof error !== 'object' || !('details' in error)) return undefined;
const details = error.details;
return details !== null && typeof details === 'object' && !Array.isArray(details)
? (details as Record<string, unknown>)
: undefined;
}

function validHttpStatusCode(value: unknown): number | undefined {
return typeof value === 'number' && Number.isInteger(value) && value >= 100 && value <= 599
? value
: undefined;
}
Loading