Skip to content
Merged
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
8 changes: 8 additions & 0 deletions .changeset/acp-sdk-and-cli-fixes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@moonshot-ai/acp-adapter": patch
"@moonshot-ai/agent-core": patch
"@moonshot-ai/kimi-code-sdk": patch
"@moonshot-ai/kimi-code": patch
---

Fix ACP slash skill routing, bootstrap context reads, file and permission edge cases, subagent event handling, and stale-file edit messaging.
47 changes: 45 additions & 2 deletions apps/kimi-code/src/cli/sub/acp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,17 @@

import type { Command } from 'commander';

import { runAcpServer } from '@moonshot-ai/acp-adapter';
import { createKimiHarness } from '@moonshot-ai/kimi-code-sdk';
import {
ACP_BUILTIN_SLASH_COMMANDS,
runAcpServer,
type AvailableCommand,
type SlashCommandsSnapshot,
} from '@moonshot-ai/acp-adapter';
import { createKimiHarness, type Session, type SkillSummary } from '@moonshot-ai/kimi-code-sdk';

import { KIMI_CODE_HOME_ENV } from '#/constant/app';
import { createKimiCodeHostIdentity, getVersion } from '#/cli/version';
import { buildSkillSlashCommands } from '#/tui/commands/skills';

import { runLoginFlow } from './login-flow';

Expand Down Expand Up @@ -66,9 +72,46 @@ export function registerAcpCommand(parent: Command): void {
// client can spawn it with `args:['login']` for the top-level
// `kimi login` subcommand — matches kimi-cli `acp/server.py:77-96`.
const legacyCommand = process.argv[1];
const builtinCommands: AvailableCommand[] = (ACP_BUILTIN_SLASH_COMMANDS as readonly AvailableCommand[]).map((cmd) => ({
name: cmd.name,
description: cmd.description,
input: cmd.input,
}));
// Skills are session-scoped (per-cwd config), so we defer the
// listSkills() call until the adapter hands us the just-created
// Session — mirrors opencode's per-directory snapshot. A
// listSkills() failure degrades to builtins-only so a broken
// skill source never blanks the palette.
const resolveSlashCommands = async (
session: Session,
): Promise<SlashCommandsSnapshot> => {
let skills: readonly SkillSummary[] = [];
try {
skills = await session.listSkills();
} catch {
skills = [];
}
// `buildSkillSlashCommands` already returns both views — the
// palette entries (advertised via `available_commands_update`)
// and the `commandName → skillName` map the adapter uses to
// intercept `/skill:<name>` inputs and route them to
// `Session.activateSkill`. Passing both through keeps the two
// surfaces in lockstep (palette ↔ interceptable set) without
// a second `listSkills()` round trip.
const built = buildSkillSlashCommands(skills);
const skillCommands = built.commands.map((cmd) => ({
name: cmd.name,
description: cmd.description,
}));
return {
commands: [...builtinCommands, ...skillCommands],
skillCommandMap: built.commandMap,
Comment on lines +106 to +108

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Do not advertise unhandled TUI slash commands

This publishes every TUI built-in command to ACP clients, but the ACP prompt path only intercepts entries present in skillCommandMap and explicitly passes unknown slash commands such as /clear through to Session.prompt. In an ACP client that renders available_commands_update, selecting built-ins like /new, /logout, /exit, or /clear will therefore send the literal slash text to the model instead of executing the command the palette advertised. Advertise only commands the ACP adapter can execute, or add ACP-side handlers for these built-ins before including them.

Useful? React with 👍 / 👎.

};
};
try {
await runAcpServer(harness, {
agentInfo: { name: 'Kimi Code CLI', version: getVersion() },
slashCommands: resolveSlashCommands,
...(terminalAuthEnv ? { terminalAuthEnv } : {}),
...(legacyCommand !== undefined && legacyCommand.length > 0
? { terminalAuthLegacyCommand: legacyCommand }
Expand Down
1 change: 1 addition & 0 deletions apps/kimi-code/test/cli/acp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { Command } from 'commander';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

vi.mock('@moonshot-ai/acp-adapter', () => ({
ACP_BUILTIN_SLASH_COMMANDS: [],
runAcpServer: vi.fn(async () => undefined),
}));

Expand Down
2 changes: 1 addition & 1 deletion docs/en/reference/kimi-acp.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ The table below lists the capabilities declared by the current ACP adapter layer
| --- | --- | --- |
| `promptCapabilities.image` | `true` | Supports ACP `image` content blocks (base64 + mimeType) |
| `promptCapabilities.audio` | `false` | Audio prompts not yet supported |
| `promptCapabilities.embeddedContext` | `false` | Embedded resource prompts not yet supported (`resource`/`resource_link` go through the text channel) |
| `promptCapabilities.embeddedContext` | `true` | Client may send `resource`/`resource_link` embedded resource blocks; text content is injected into the prompt as `<resource uri="...">...</resource>`; blob resources are dropped with a warn |
| `mcpCapabilities.http` | `true` | Forwards HTTP MCP services configured by the IDE |
| `mcpCapabilities.sse` | `false` | SSE MCP services not supported; matching entries are discarded and a warn is logged |
| `loadSession` | `true` | Supports `session/load` to resume an existing session, replaying history on load |
Expand Down
2 changes: 1 addition & 1 deletion docs/zh/reference/kimi-acp.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ kimi acp
| --- | --- | --- |
| `promptCapabilities.image` | `true` | 支持 ACP `image` 内容块(base64 + mimeType) |
| `promptCapabilities.audio` | `false` | 暂不支持音频 prompt |
| `promptCapabilities.embeddedContext` | `false` | 暂不支持嵌入式资源 prompt(`resource`/`resource_link` 走文本通道) |
| `promptCapabilities.embeddedContext` | `true` | 客户端可发送 `resource`/`resource_link` 嵌入式资源块,文本内容会以 `<resource uri="...">...</resource>` 形式注入 prompt;blob 资源被丢弃并写 warn |
| `mcpCapabilities.http` | `true` | 转发 IDE 配置的 HTTP MCP 服务 |
| `mcpCapabilities.sse` | `false` | 不支持 SSE MCP 服务,相关条目会被丢弃并写 warn 日志 |
| `loadSession` | `true` | 支持 `session/load` 续接已有会话,加载时会同步回放历史 |
Expand Down
8 changes: 8 additions & 0 deletions packages/acp-adapter/src/approval.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,8 +157,16 @@ export function permissionResponseToApprovalResponse(
}
switch (optionId) {
case APPROVE_ONCE_OPTION_ID:
// Legacy Python kimi-cli (< v0.9.0) used 'approve' as the
// allow-once optionId. Keep accepting it so custom ACP clients
// built against the old SDK are not silently rejected.
case 'approve':
return { decision: 'approved' };
case APPROVE_ALWAYS_OPTION_ID:
// Legacy Python kimi-cli (< v0.9.0) used 'approve_for_session' as
// the allow-always optionId. Same backward-compatibility rationale
// as the 'approve' branch above.
case 'approve_for_session':
return { decision: 'approved', scope: 'session' };
case REJECT_OPTION_ID:
return { decision: 'rejected' };
Expand Down
39 changes: 39 additions & 0 deletions packages/acp-adapter/src/builtin-commands.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import type { AvailableCommand } from '@agentclientprotocol/sdk';

export const ACP_BUILTIN_SLASH_COMMANDS = [
{
name: 'compact',
description: 'Compact the conversation context',
input: { hint: '<optional custom summarization instructions>' },
},
{
name: 'status',
description: 'Show current session status',
},
{
name: 'usage',
description: 'Show session token usage',
},
{
name: 'mcp',
description: 'Show MCP server status',
},
{
name: 'tasks',
description: 'List background tasks',
},
{
name: 'help',
description: 'Show available ACP commands',
},
] as const satisfies readonly AvailableCommand[];

export type AcpBuiltinSlashCommandName = (typeof ACP_BUILTIN_SLASH_COMMANDS)[number]['name'];

export const ACP_BUILTIN_SLASH_COMMAND_NAMES = new Set<string>(
ACP_BUILTIN_SLASH_COMMANDS.map((command) => command.name),
);

export function isAcpBuiltinSlashCommand(name: string): name is AcpBuiltinSlashCommandName {
return ACP_BUILTIN_SLASH_COMMAND_NAMES.has(name);
}
91 changes: 50 additions & 41 deletions packages/acp-adapter/src/convert.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,25 +12,6 @@ import { isHideOutputMarker } from './marker';
* Convert an array of ACP {@link ContentBlock}s into the SDK's
* {@link PromptPart} array.
*
* Phase 9.1 lifts `image` blocks into `image_url` parts: per the ACP
* schema (`ImageContent` at types.gen.d.ts:1905-1920) the `data` field
* is a raw base64-encoded payload accompanied by a required `mimeType`,
* so the data URL is constructed at the adapter boundary as
* `data:<mimeType>;base64,<data>`. We treat `data` as opaque base64 —
* if a caller pre-wraps it as a `data:` URL the prefix detection isn't
* worth the complexity and that string lands inside another `data:`
* envelope (documented limitation; ACP spec says base64, so callers
* conforming to spec are unaffected).
*
* Phase 9.2 inlines `resource_link` and `resource` (EmbeddedResource)
* blocks as XML-flavoured text the model can read directly:
* - `resource_link` → `<resource_link uri="..." name="..." />`
* - `resource` with TextResourceContents → `<resource uri="...">text</resource>`
* - `resource` with BlobResourceContents → dropped with a warn
* (per PLAN D3: "blob 忽略并 warn").
* Attribute values are escaped via {@link escapeXmlAttr}.
*
* `audio` blocks remain dropped with a warn.
*/
export function acpBlocksToPromptParts(
blocks: readonly ContentBlock[],
Expand All @@ -53,6 +34,11 @@ export function acpBlocksToPromptParts(
continue;
}
if (block.type === 'resource_link') {
const fileRef = fileLinkToTextRef(block.uri);
if (fileRef !== null) {
out.push({ type: 'text', text: fileRef });
continue;
}
const text = `<resource_link uri="${escapeXmlAttr(
block.uri,
)}" name="${escapeXmlAttr(block.name)}" />`;
Expand Down Expand Up @@ -101,28 +87,51 @@ function escapeXmlAttr(s: string): string {
.replace(/'/g, '&apos;');
}

/**
* Convert an SDK {@link ToolInputDisplay} block into an ACP
* {@link ToolCallContent} entry, when (and only when) the block carries
* structured before/after text suitable for a diff view or a Phase 13.2
* plan-review markdown body.
*
* Mapped variants:
* - `kind: 'diff'` — always rendered (both `before` and `after` are
* required on the schema).
* - `kind: 'file_io'` with **both** `before` and `after` populated —
* matches the Edit tool's display payload; rendered as a diff too.
* - `kind: 'plan_review'` — rendered as a text block via
* {@link composePlanContent} so the ACP client surfaces the full plan
* markdown (and the optional `Plan saved to:` path prefix) at the top
* of the approval prompt. Empty plans defensively return `null` —
* the policy already guarantees non-empty, but the adapter must not
* trust that to avoid emitting a blank content entry.
*
* All other display kinds (command, search, url_fetch, agent_call,
* skill_call, todo_list, …) return `null` and the caller drops them.
* Phase 7 will add a `terminal` variant; Phase 9 may add image/resource.
*/
function fileLinkToTextRef(uri: string): string | null {
let url: URL;
try {
url = new URL(uri);
} catch {
return null;
}
if (url.protocol !== 'file:') return null;

let path: string;
try {
path = decodeURIComponent(url.pathname);
} catch {
return null;
}

// `file://server/share/a.ts` is the URI form of a Windows UNC path
// (`\\server\share\a.ts`). `URL.pathname` only carries `/share/a.ts`; the
// host is part of the file location, so keep it in the projected text ref.
// `file://localhost/...` is still treated as local. Host is lower-cased so
// `file://Server/...` and `file://server/...` collapse to one ref.
const host = url.hostname.toLowerCase();
const isUncHost = host !== '' && host !== 'localhost';

// Drive-letter normalization is local-only: a UNC URI never legitimately
// carries `/C:/...` in its path, so we leave such inputs untouched rather
// than stripping a leading slash that would alter the UNC payload.
if (!isUncHost && /^\/[A-Za-z]:/.test(path)) path = path.slice(1);

if (isUncHost) {
path = `//${host}${path.startsWith('/') ? path : `/${path}`}`;
}

const range = parseLineRange(url.hash) ?? parseLineRange(url.search);
return range !== null ? `${path}:${range}` : path;
}

function parseLineRange(suffix: string): string | null {
if (!suffix) return null;
const body = suffix.replace(/^[#?]/, '');
const match = /^(?:lines?=|L)(\d+)(?:[-:]L?(\d+))?/i.exec(body);
if (!match) return null;
return match[2] !== undefined ? `${match[1]}-${match[2]}` : match[1]!;
}

export function displayBlockToAcpContent(
block: ToolInputDisplay,
): ToolCallContent | null {
Expand Down
9 changes: 8 additions & 1 deletion packages/acp-adapter/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,15 @@
export type { Implementation } from '@agentclientprotocol/sdk';
export type { AvailableCommand, Implementation } from '@agentclientprotocol/sdk';
export {
ACP_BUILTIN_SLASH_COMMAND_NAMES,
ACP_BUILTIN_SLASH_COMMANDS,
isAcpBuiltinSlashCommand,
} from './builtin-commands';
export type { AcpBuiltinSlashCommandName } from './builtin-commands';
export { CURRENT_VERSION, MIN_PROTOCOL_VERSION, negotiateVersion } from './version';
export type { AcpVersionSpec } from './version';
export { TERMINAL_AUTH_METHOD, buildTerminalAuthMethod } from './auth-methods';
export { AcpServer, runAcpServer, runAcpServerWithStream } from './server';
export type { SlashCommandsSnapshot } from './server';
export { AcpSession } from './session';
export {
acpBlocksToPromptParts,
Expand Down
Loading
Loading