From f1e44d35ff19f1f448b30fa8c6c11139538c6b5d Mon Sep 17 00:00:00 2001 From: likuang Date: Tue, 3 Nov 2026 02:52:13 +0800 Subject: [PATCH] fix: present Kimi plan approval as native Codex choices --- docs/harnesses/capability-boundaries.md | 6 ++ .../adapters/kimi-code/src/kimi-session.ts | 14 +++ .../adapters/kimi-code/src/plan-approval.ts | 71 +++++++++++++ .../kimi-code/test/kimi-session.test.ts | 99 +++++++++++++++++++ .../kimi-code/test/plan-approval.test.ts | 68 +++++++++++++ 5 files changed, 258 insertions(+) create mode 100644 packages/adapters/kimi-code/src/plan-approval.ts create mode 100644 packages/adapters/kimi-code/test/plan-approval.test.ts diff --git a/docs/harnesses/capability-boundaries.md b/docs/harnesses/capability-boundaries.md index 33ecb7e9c..bf326109a 100644 --- a/docs/harnesses/capability-boundaries.md +++ b/docs/harnesses/capability-boundaries.md @@ -12,6 +12,12 @@ 以上实现归各 Harness 插件所有,不在公共 Adapter、Host 或 Renderer 中添加 Harness 专用分支。跨插件参考实现不等于共用原生协议;各自保留配置确认、取消、持久化和权限语义。 +## Kimi 计划审批 + +Kimi Code ACP 的 `ExitPlanMode` 请求包含原生 `plan_approve` / `plan_opt_N`、`plan_revise` 和 `plan_reject_and_exit` 选项时,Adapter 使用公共 Question 交互投影到 Codex 原生选择界面。计划正文、选项顺序和标签保留,选中结果原样回传 ACP `optionId`;修改计划、拒绝并退出和取消保持各自语义,不合并成同一个拒绝动作。 + +识别依赖工具名和完整的计划选项格式。其他工具审批、未知选项、重复标签或不完整格式仍走既有审批路径;普通会话权限、其他提问和公共审批投影不受此映射影响。 + ## Kimi Thinking 配置 Kimi 通过 ACP `configOptions` 提供当前会话、当前 Model 的 Thinking 列表和选中值。插件在创建、恢复、配置命令响应及 `config_option_update` 通知中读取这些事实,将列表保存在会话内存状态的 `availableThinkingOptions` 中,沿公共状态事件更新 Desktop;不轮询,也不在启动检查时遍历切换 Model。不同会话或 Host 不共享可变列表。 diff --git a/packages/adapters/kimi-code/src/kimi-session.ts b/packages/adapters/kimi-code/src/kimi-session.ts index 04ff95eae..0e18d1d7a 100644 --- a/packages/adapters/kimi-code/src/kimi-session.ts +++ b/packages/adapters/kimi-code/src/kimi-session.ts @@ -96,6 +96,7 @@ import { stripAnsi, KIMI_DEFAULT_COMMANDS, } from "./slash-commands.js"; +import { projectKimiPlanApprovalRequest } from "./plan-approval.js"; const kimiHarnessId: HarnessId = harnessIdSchema.parse("kimi-code"); const defaultNativeTurnFlushTimeoutMs = 6_000; @@ -762,6 +763,19 @@ export class KimiSession implements HarnessSession { if (currentAgentMessage) { completeAgentMessage("commentary"); } + const plan = projectKimiPlanApprovalRequest(turnId, request); + if (plan) { + return new Promise((resolve) => { + this.#activeInteractions.set(plan.interaction.interactionId, { + id: plan.interaction.interactionId, + type: "question", + interaction: plan.interaction, + metadata: plan.schemaProperties, + resolve: (value) => resolve(plan.parseResponse(value as CreateElicitationResponse)), + }); + this.#channel.emit({ kind: "interaction", interaction: plan.interaction }); + }); + } const projected = projectKimiApprovalRequest(turnId, request); return new Promise((resolve) => { this.#activeInteractions.set(projected.interaction.interactionId, { diff --git a/packages/adapters/kimi-code/src/plan-approval.ts b/packages/adapters/kimi-code/src/plan-approval.ts new file mode 100644 index 000000000..68b74f0e1 --- /dev/null +++ b/packages/adapters/kimi-code/src/plan-approval.ts @@ -0,0 +1,71 @@ +import { randomUUID } from "node:crypto"; + +import type { + CreateElicitationResponse, + RequestPermissionRequest, + RequestPermissionResponse, +} from "@agentclientprotocol/sdk"; +import { hostInteractionIdSchema, type HostTurnId } from "@codexhost/shared-contracts"; + +import { readAcpToolContentText, type ProjectedElicitation } from "./projection.js"; + +/** Kimi Code 2.4 ACP represents plan review as distinct plan_* permission options. */ +export function projectKimiPlanApprovalRequest( + turnId: HostTurnId, + request: RequestPermissionRequest, +): + | (ProjectedElicitation & { + parseResponse(response: CreateElicitationResponse): RequestPermissionResponse; + }) + | null { + const { options } = request; + if ( + request.toolCall.title !== "ExitPlanMode" || + !options.some(({ optionId, kind }) => optionId === "plan_revise" && kind === "reject_once") || + !options.some( + ({ optionId, kind }) => optionId === "plan_reject_and_exit" && kind === "reject_once", + ) || + !options.some(({ kind }) => kind === "allow_once") || + new Set(options.map(({ optionId }) => optionId)).size !== options.length || + new Set(options.map(({ name }) => name)).size !== options.length || + !options.every( + ({ optionId, kind, name }) => + name.trim().length > 0 && + (kind === "allow_once" + ? optionId === "plan_approve" || /^plan_opt_\d+$/.test(optionId) + : kind === "reject_once" && + (optionId === "plan_revise" || optionId === "plan_reject_and_exit")), + ) + ) + return null; + + return { + interaction: { + type: "question", + interactionId: hostInteractionIdSchema.parse(randomUUID()), + turnId, + title: "Kimi Code 计划审批", + questions: [ + { + id: "planApproval", + type: "choice", + prompt: readAcpToolContentText(request.toolCall.content) || "ExitPlanMode", + options: options.map(({ optionId, name }) => ({ value: optionId, label: name })), + multiple: false, + allowOther: false, + optional: false, + }, + ], + }, + schemaProperties: { planApproval: { type: "string" } }, + parseResponse(response) { + const selected = + response.action === "accept" + ? (response.content as Record | undefined)?.planApproval + : undefined; + return typeof selected === "string" && options.some(({ optionId }) => optionId === selected) + ? { outcome: { outcome: "selected", optionId: selected } } + : { outcome: { outcome: "cancelled" } }; + }, + }; +} diff --git a/packages/adapters/kimi-code/test/kimi-session.test.ts b/packages/adapters/kimi-code/test/kimi-session.test.ts index 371bf37f8..f094a2699 100644 --- a/packages/adapters/kimi-code/test/kimi-session.test.ts +++ b/packages/adapters/kimi-code/test/kimi-session.test.ts @@ -24,6 +24,7 @@ import type { HostQuestionInteraction, } from "@codexhost/harness-adapter"; +import { projectCodexQuestionRequest } from "@codexhost/protocol-core"; import { KimiSession } from "../src/kimi-session.js"; import { createKimiNativeTurnRef } from "../src/history.js"; import type { KimiAcpTransportLike } from "../src/kimi-adapter.js"; @@ -1124,6 +1125,104 @@ describe("KimiSession", () => { }); }); + it.each([ + "plan_approve", + "plan_opt_0", + "plan_opt_1", + "plan_revise", + "plan_reject_and_exit", + "cancel", + "close", + ])("bridges plan review through native Codex choices: %s", async (selection) => { + const transport = new MockKimiTransport(); + let nativeResponse: RequestPermissionResponse | undefined; + const options: RequestPermissionRequest["options"] = [ + ...(selection === "plan_approve" + ? [{ optionId: "plan_approve", name: "Approve", kind: "allow_once" as const }] + : [ + { optionId: "plan_opt_0", name: "Implement A", kind: "allow_once" as const }, + { optionId: "plan_opt_1", name: "Implement B", kind: "allow_once" as const }, + ]), + { optionId: "plan_revise", name: "Revise", kind: "reject_once" }, + { optionId: "plan_reject_and_exit", name: "Reject and Exit", kind: "reject_once" }, + ]; + transport.promptMock = vi.fn(async (_text, handler) => { + nativeResponse = await handler.onPermission({ + sessionId: "session-plan", + toolCall: { + toolCallId: "1:exit-plan", + title: "ExitPlanMode", + content: [{ type: "content", content: { type: "text", text: "Review the plan" } }], + }, + options, + }); + return { stopReason: "end_turn" }; + }); + const session = new KimiSession({ + transport, + sessionId: "session-plan", + cwd: process.cwd(), + initialState: {}, + }); + try { + await session.execute({ + type: "turn.start", + turnId: hostTurnIdSchema.parse("plan-turn"), + input: [{ type: "text", text: "Review plan" }], + }); + let question: HostQuestionInteraction | undefined; + for await (const output of session.outputs) { + if (output.kind === "interaction" && output.interaction.type === "question") { + question = output.interaction; + break; + } + } + expect(question).toBeDefined(); + if (!question) throw new Error("Missing plan question"); + const projected = projectCodexQuestionRequest({ + threadId: "thread-plan", + itemId: "plan-item", + interaction: question, + }); + expect(projected.request.method).toBe("item/tool/requestUserInput"); + expect(question.questions[0]).toMatchObject({ + prompt: "Review the plan", + multiple: false, + allowOther: false, + options: options.map(({ optionId, name }) => ({ value: optionId, label: name })), + }); + const invalid = await session.execute({ + type: "interaction.respond", + interactionId: question.interactionId, + response: { type: "question", answers: { planApproval: ["unknown"] } }, + }); + expect(invalid).toMatchObject({ ok: false, error: { code: "invalidRequest" } }); + expect(nativeResponse).toBeUndefined(); + if (selection === "close") await session.close(); + else { + const label = options.find(({ optionId }) => optionId === selection)?.name; + const response = projected.parseResponse({ + answers: selection === "cancel" ? {} : { planApproval: { answers: [label] } }, + }); + expect( + await session.execute({ + type: "interaction.respond", + interactionId: question.interactionId, + response, + }), + ).toMatchObject({ ok: true }); + } + expect(nativeResponse).toEqual({ + outcome: + selection === "cancel" || selection === "close" + ? { outcome: "cancelled" } + : { outcome: "selected", optionId: selection }, + }); + } finally { + await session.close(); + } + }); + it("bridges Elicitation (Question) interaction with single and multi choice", async () => { const transport = new MockKimiTransport(); let elicitationResolvedWith: CreateElicitationResponse | null = null; diff --git a/packages/adapters/kimi-code/test/plan-approval.test.ts b/packages/adapters/kimi-code/test/plan-approval.test.ts new file mode 100644 index 000000000..360a5e65a --- /dev/null +++ b/packages/adapters/kimi-code/test/plan-approval.test.ts @@ -0,0 +1,68 @@ +import type { RequestPermissionRequest } from "@agentclientprotocol/sdk"; +import { hostTurnIdSchema } from "@codexhost/shared-contracts"; +import { describe, expect, it } from "vitest"; + +import { projectKimiPlanApprovalRequest } from "../src/plan-approval.js"; + +// Kimi Code 2.4.0 ACP approval.ts: plan_review uses plan_* IDs and two reject_once exits. +const request: RequestPermissionRequest = { + sessionId: "plan-session", + toolCall: { toolCallId: "1:exit-plan", title: "ExitPlanMode" }, + options: [ + { optionId: "plan_approve", name: "Approve", kind: "allow_once" }, + { optionId: "plan_revise", name: "Revise", kind: "reject_once" }, + { optionId: "plan_reject_and_exit", name: "Reject and Exit", kind: "reject_once" }, + ], +}; +const turnId = hostTurnIdSchema.parse("plan-turn"); + +describe("Kimi plan approval isolation", () => { + it.each(["Bash", "Write", "AskUserQuestion", "exitplanmode", undefined])( + "does not intercept other tool titles: %s", + (title) => { + expect( + projectKimiPlanApprovalRequest(turnId, { + ...request, + toolCall: { + toolCallId: request.toolCall.toolCallId, + ...(title === undefined ? {} : { title }), + }, + }), + ).toBeNull(); + }, + ); + + it.each( + ( + [ + [], + [{ optionId: "approve_once", name: "Approve", kind: "allow_once" }], + request.options.slice(0, 2), + request.options.slice(1), + [...request.options, request.options[0]], + [...request.options, { optionId: "unknown", name: "Unknown", kind: "allow_once" }], + request.options.map((option) => ({ ...option, name: "Same label" })), + request.options.map((option) => ({ ...option, name: " " })), + request.options.map((option) => ({ ...option, kind: "allow_always" })), + ] as RequestPermissionRequest["options"][] + ).map((options) => ({ options })), + )( + "retains the existing approval path for unsupported or ambiguous options: %j", + ({ options }) => { + expect(projectKimiPlanApprovalRequest(turnId, { ...request, options })).toBeNull(); + }, + ); + + it("never selects an undeclared option or treats a dismissal as approval", () => { + const projected = projectKimiPlanApprovalRequest(turnId, request); + expect(projected).not.toBeNull(); + for (const response of [ + { action: "cancel" }, + { action: "decline" }, + { action: "accept", content: { planApproval: "unknown" } }, + { action: "accept", content: {} }, + ] as const) { + expect(projected?.parseResponse(response)).toEqual({ outcome: { outcome: "cancelled" } }); + } + }); +});