-
Notifications
You must be signed in to change notification settings - Fork 203
fix(acp): restore legacy permission compatibility and stabilize ACP #395
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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[], | ||
|
|
@@ -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)}" />`; | ||
|
|
@@ -101,28 +87,28 @@ function escapeXmlAttr(s: string): string { | |
| .replace(/'/g, '''); | ||
| } | ||
|
|
||
| /** | ||
| * 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 = decodeURIComponent(url.pathname); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When an ACP client sends a Windows UNC resource link such as Useful? React with 👍 / 👎. |
||
| if (/^\/[A-Za-z]:/.test(path)) path = path.slice(1); | ||
| 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 { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -19,6 +19,7 @@ | |
| import { Buffer } from 'node:buffer'; | ||
|
|
||
| import type { AgentSideConnection } from '@agentclientprotocol/sdk'; | ||
| import { RequestError } from '@agentclientprotocol/sdk'; | ||
| import { | ||
| KaosError, | ||
| type Environment, | ||
|
|
@@ -130,33 +131,32 @@ export class AcpKaos implements Kaos { | |
| } | ||
|
|
||
| /** | ||
| * Read up to `n` bytes from the file. Implemented as | ||
| * `readText → utf8 encode → slice` because ACP only exposes string | ||
| * content. Callers that store non-text data through this path | ||
| * (uncommon) will get re-encoded bytes — acceptable per `Kaos.readBytes` | ||
| * which already permits encoding-dependent return values. | ||
| * Binary reads bypass the ACP text RPC by design: `fs/readTextFile` | ||
| * returns a decoded string and would corrupt or reject non-UTF-8 | ||
| * payloads (images, video, archives — anything `ReadMediaFile` may | ||
| * touch). The ACP bridge only owns the *text* surface; raw bytes | ||
| * stay on the local filesystem via `inner`. | ||
| */ | ||
| async readBytes(path: string, n?: number): Promise<Buffer> { | ||
| readBytes(path: string, n?: number): Promise<Buffer> { | ||
| return this.inner.readBytes(path, n); | ||
|
Comment on lines
+140
to
+141
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
With ACP-backed sessions, ordinary Useful? React with 👍 / 👎. |
||
| } | ||
|
|
||
| /** | ||
| * Return a small UTF-8 header derived from the same ACP text source as | ||
| * `readText` / `readLines`, used only by text-read callers for sniffing. | ||
| * Keep `readBytes` local so binary callers such as ReadMediaFile stay safe. | ||
| */ | ||
| async readTextPreview(path: string, n: number): Promise<Buffer> { | ||
| const text = await this.readText(path); | ||
| const buf = Buffer.from(text, 'utf8'); | ||
| return n !== undefined ? buf.subarray(0, n) : buf; | ||
| return Buffer.from(text.slice(0, n), 'utf8'); | ||
| } | ||
|
|
||
| /** | ||
| * Yield lines from the file. Emulates Python `splitlines(keepends=False)`: | ||
| * splits on `\n`, drops the trailing empty token if the file ended with | ||
| * a newline, and yields nothing for an empty file. Matches | ||
| * {@link LocalKaos.readLines}'s observable output for the trailing-newline | ||
| * case (the local version yields `'line\n'` chunks; here we yield without | ||
| * the `\n` — see below). | ||
| * | ||
| * Note on divergence from `LocalKaos.readLines`: the local impl yields | ||
| * `'a\n'`, `'b\n'`, `'c'` while this impl yields `'a'`, `'b'`, `'c'`. | ||
| * The interface JSDoc says only "Yield lines from the file at `path` | ||
| * one by one" without pinning trailing-newline semantics, so both | ||
| * shapes satisfy it. Tools that depend on the trailing-newline (rare) | ||
| * should adapt. The Python reference's ACP backend does not implement | ||
| * `readLines` separately either. | ||
| * Yield lines from the file, each terminated by its `\n` (the final | ||
| * line has no terminator if the file did not end with `\n`). Matches | ||
| * {@link LocalKaos.readLines} so tools that depend on line terminators | ||
| * (e.g. {@link ReadTool}, which renders CRLF endings) behave identically | ||
| * whether the underlying Kaos is local or ACP-bridged. | ||
| */ | ||
| async *readLines( | ||
| path: string, | ||
|
|
@@ -167,7 +167,7 @@ export class AcpKaos implements Kaos { | |
| let start = 0; | ||
| for (let i = 0; i < text.length; i++) { | ||
| if (text.charCodeAt(i) === 0x0a /* \n */) { | ||
| yield text.slice(start, i); | ||
| yield text.slice(start, i + 1); | ||
| start = i + 1; | ||
| } | ||
| } | ||
|
|
@@ -181,9 +181,11 @@ export class AcpKaos implements Kaos { | |
| * always UTF-8 string content. `mode: 'a'` (append) emulates with a | ||
| * read-then-write fallback: ACP has no native append, and the | ||
| * intended audience (unsaved-buffer scratchpads) rarely needs it. | ||
| * If the prior read fails (e.g. file missing), the write proceeds | ||
| * as if the existing content were empty — matching Python `open('a')` | ||
| * which also creates new files. | ||
| * If the prior read fails because the file does not exist, the write | ||
| * proceeds as if the existing content were empty — matching Python | ||
| * `open('a')` which creates new files. Any other read failure | ||
| * (permission, transport, internal) propagates so we never silently | ||
| * destroy existing content. | ||
| * | ||
| * Returns `data.length` (chars) to match {@link LocalKaos.writeText}'s | ||
| * contract. | ||
|
|
@@ -197,8 +199,8 @@ export class AcpKaos implements Kaos { | |
| let existing = ''; | ||
| try { | ||
| existing = await this.readText(path); | ||
| } catch { | ||
| // ENOENT-style failure → treat as empty (mirrors Python open('a')). | ||
| } catch (err) { | ||
| if (!isNotFoundError(err)) throw err; | ||
| existing = ''; | ||
| } | ||
| await this.acpWrite(path, existing + data); | ||
|
|
@@ -254,3 +256,27 @@ function wrapKaosError(prefix: string, cause: unknown): KaosError { | |
| (err as Error & { cause?: unknown }).cause = cause; | ||
| return err; | ||
| } | ||
|
|
||
| /** | ||
| * Return true iff `err` is a structured "file does not exist" failure on | ||
| * the read side of an ACP append-mode write. We only trust the ACP SDK's | ||
| * `RequestError.resourceNotFound` code (`-32002`), optionally wrapped in a | ||
| * `KaosError` by `readText` above. Message substring matching is intentionally | ||
| * avoided: wrapper messages include the path, so a path or non-ENOENT failure | ||
| * mentioning "not found" could otherwise be misclassified and cause append | ||
| * mode to overwrite existing content. | ||
| */ | ||
| function isNotFoundError(err: unknown): boolean { | ||
| const visited = new Set<unknown>(); | ||
| let cur: unknown = err; | ||
| while (cur !== undefined && cur !== null && !visited.has(cur)) { | ||
| visited.add(cur); | ||
| if (cur instanceof RequestError && cur.code === -32002) return true; | ||
| if (cur instanceof Error) { | ||
| cur = (cur as Error & { cause?: unknown }).cause; | ||
| continue; | ||
| } | ||
| break; | ||
| } | ||
| return false; | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This publishes every TUI built-in command to ACP clients, but the ACP prompt path only intercepts entries present in
skillCommandMapand explicitly passes unknown slash commands such as/clearthrough toSession.prompt. In an ACP client that rendersavailable_commands_update, selecting built-ins like/new,/logout,/exit, or/clearwill 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 👍 / 👎.