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
6 changes: 6 additions & 0 deletions docs/harnesses/capability-boundaries.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 不共享可变列表。
Expand Down
14 changes: 14 additions & 0 deletions packages/adapters/kimi-code/src/kimi-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -762,6 +763,19 @@ export class KimiSession implements HarnessSession {
if (currentAgentMessage) {
completeAgentMessage("commentary");
}
const plan = projectKimiPlanApprovalRequest(turnId, request);
if (plan) {
return new Promise<RequestPermissionResponse>((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<RequestPermissionResponse>((resolve) => {
this.#activeInteractions.set(projected.interaction.interactionId, {
Expand Down
71 changes: 71 additions & 0 deletions packages/adapters/kimi-code/src/plan-approval.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> | undefined)?.planApproval
: undefined;
return typeof selected === "string" && options.some(({ optionId }) => optionId === selected)
? { outcome: { outcome: "selected", optionId: selected } }
: { outcome: { outcome: "cancelled" } };
},
};
}
99 changes: 99 additions & 0 deletions packages/adapters/kimi-code/test/kimi-session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand Down
68 changes: 68 additions & 0 deletions packages/adapters/kimi-code/test/plan-approval.test.ts
Original file line number Diff line number Diff line change
@@ -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" } });
}
});
});
Loading