From 729e69ffe97d0af7d36ae3eff789d5d10d1147db Mon Sep 17 00:00:00 2001 From: zhaojy Date: Wed, 29 Jul 2026 02:02:59 +0800 Subject: [PATCH 1/2] fix(vscode): open plan files from review --- apps/vscode/shared/bridge.ts | 2 + apps/vscode/shared/legacy-sdk.ts | 14 ++++++- apps/vscode/src/handlers/file.handler.ts | 17 ++++++++ apps/vscode/src/runtime/tool-display.ts | 3 +- apps/vscode/test/bridge-handler.test.ts | 16 +++++++ apps/vscode/test/event-adapter.test.ts | 38 +++++++++++++++++ apps/vscode/test/workspace-paths.test.ts | 29 +++++++++++++ .../src/components/DisplayBlocks.tsx | 42 ++++++++++++++++++- .../src/components/ToolRenderers.tsx | 2 +- apps/vscode/webview-ui/src/services/bridge.ts | 4 ++ 10 files changed, 163 insertions(+), 4 deletions(-) diff --git a/apps/vscode/shared/bridge.ts b/apps/vscode/shared/bridge.ts index d7f9bd88ae..372d68fa1e 100644 --- a/apps/vscode/shared/bridge.ts +++ b/apps/vscode/shared/bridge.ts @@ -50,6 +50,7 @@ export const Methods = { GetProjectFiles: "getProjectFiles", PickMedia: "pickMedia", OpenFile: "openFile", + OpenPlanFile: "openPlanFile", CheckFileExists: "checkFileExists", CheckFilesExist: "checkFilesExist", OpenFileDiff: "openFileDiff", @@ -146,6 +147,7 @@ function validateParams(method: RpcMethod, params: unknown): boolean { case Methods.GetRegisteredWorkDirs: case Methods.BrowseWorkDir: case Methods.ClearTrackedFiles: + case Methods.OpenPlanFile: case Methods.ShowLogs: case Methods.ReloadWebview: return params === undefined; diff --git a/apps/vscode/shared/legacy-sdk.ts b/apps/vscode/shared/legacy-sdk.ts index 03c5bafbda..ae5e310b57 100644 --- a/apps/vscode/shared/legacy-sdk.ts +++ b/apps/vscode/shared/legacy-sdk.ts @@ -37,13 +37,25 @@ export interface ShellBlock { command: string; } +export interface PlanBlock { + type: 'plan'; + plan: string; + path?: string; +} + export interface UnknownBlock { type: string; data?: Record; [key: string]: unknown; } -export type DisplayBlock = BriefBlock | DiffBlock | TodoBlock | ShellBlock | UnknownBlock; +export type DisplayBlock = + | BriefBlock + | DiffBlock + | TodoBlock + | ShellBlock + | PlanBlock + | UnknownBlock; export interface ToolCall { type: 'function'; diff --git a/apps/vscode/src/handlers/file.handler.ts b/apps/vscode/src/handlers/file.handler.ts index abc49cabf2..863ed0cd00 100644 --- a/apps/vscode/src/handlers/file.handler.ts +++ b/apps/vscode/src/handlers/file.handler.ts @@ -75,6 +75,22 @@ const openFile: Handler = async ({ filePath }, return { ok: true }; }; +const openPlanFile: Handler = async (_, ctx) => { + const runtime = ctx.getSession(); + if (runtime === undefined) return { ok: false }; + const plan = await runtime.session.getPlan(); + if (plan === null) return { ok: false }; + + const uri = vscode.Uri.file(plan.path); + try { + await vscode.workspace.fs.stat(uri); + } catch { + return { ok: false }; + } + await vscode.commands.executeCommand("vscode.open", uri); + return { ok: true }; +}; + const openFileDiff: Handler = async ({ filePath }, ctx) => { const sessionId = ctx.getSessionId(); const resolved = await resolveExistingWorkspaceFile(ctx.requireWorkDirUri(), filePath); @@ -180,6 +196,7 @@ export const fileHandlers: Record> = { [Methods.GetProjectFiles]: getProjectFiles, [Methods.PickMedia]: pickMedia, [Methods.OpenFile]: openFile, + [Methods.OpenPlanFile]: openPlanFile, [Methods.OpenFileDiff]: openFileDiff, [Methods.TrackFiles]: trackFiles, [Methods.ClearTrackedFiles]: clearTrackedFiles, diff --git a/apps/vscode/src/runtime/tool-display.ts b/apps/vscode/src/runtime/tool-display.ts index fe77509774..89996fdf31 100644 --- a/apps/vscode/src/runtime/tool-display.ts +++ b/apps/vscode/src/runtime/tool-display.ts @@ -67,9 +67,10 @@ export function toLegacyDisplay(display: ToolInputDisplay): DisplayBlock[] { case "skill_call": case "task": case "task_stop": - case "plan_review": case "goal_start": case "generic": return [{ type: "brief", text: describeToolDisplay(display) }]; + case "plan_review": + return [{ type: "plan", plan: display.plan, path: display.path }]; } } diff --git a/apps/vscode/test/bridge-handler.test.ts b/apps/vscode/test/bridge-handler.test.ts index 51b203823c..2664a7ecf3 100644 --- a/apps/vscode/test/bridge-handler.test.ts +++ b/apps/vscode/test/bridge-handler.test.ts @@ -170,6 +170,22 @@ describe("Webview RPC boundary (validates requests before host dispatch)", () => expect(showLogs).not.toHaveBeenCalled(); }); + it("does not accept a Webview-supplied path for the plan-only open method", async () => { + const result = await bridge.handle( + { + id: "rpc-plan", + method: Methods.OpenPlanFile, + params: { filePath: "/tmp/untrusted-plan.md" }, + }, + "view-1", + ); + + expect(result).toEqual({ + id: "rpc-plan", + error: "Invalid bridge params for method: openPlanFile", + }); + }); + it("does not execute an object-payload handler when a required field has the wrong type", async () => { const result = await bridge.handle( { id: "rpc-1", method: Methods.AddInputHistory, params: { text: 42 } }, diff --git a/apps/vscode/test/event-adapter.test.ts b/apps/vscode/test/event-adapter.test.ts index e910edfa3e..40ed22378c 100644 --- a/apps/vscode/test/event-adapter.test.ts +++ b/apps/vscode/test/event-adapter.test.ts @@ -262,6 +262,44 @@ describe('event adapter (projects SDK events into the legacy Webview contract)', }); }); + it('preserves a plan review body and path in a dedicated display block', () => { + const started = adaptSdkEvent(createEventAdapterState(), { + type: 'tool.call.started', + sessionId: 'session-1', + agentId: 'main', + turnId: 7, + toolCallId: 'plan-1', + name: 'ExitPlanMode', + args: {}, + display: { + kind: 'plan_review', + plan: '# Refactor plan\n\n- Verify the change', + path: '/tmp/kimi-code/plans/refactor.md', + }, + }); + const finished = adaptSdkEvent(started.state, { + type: 'tool.result', + sessionId: 'session-1', + agentId: 'main', + turnId: 7, + toolCallId: 'plan-1', + output: 'Plan approved', + }); + + expect(finished.event).toMatchObject({ + type: 'ToolResult', + payload: { + return_value: { + display: [{ + type: 'plan', + plan: '# Refactor plan\n\n- Verify the change', + path: '/tmp/kimi-code/plans/refactor.md', + }], + }, + }, + }); + }); + it('emits snake-case status fields when agent status changes', () => { const result = adaptSdkEvent(createEventAdapterState(), { type: 'agent.status.updated', diff --git a/apps/vscode/test/workspace-paths.test.ts b/apps/vscode/test/workspace-paths.test.ts index 1e64f43381..b2aafc7997 100644 --- a/apps/vscode/test/workspace-paths.test.ts +++ b/apps/vscode/test/workspace-paths.test.ts @@ -280,6 +280,35 @@ describe("Webview workspace paths (selected-directory containment)", () => { expect(vscodeHost.executeCommand).not.toHaveBeenCalled(); }); + it("opens the current session plan outside the workspace through the plan-only RPC", async () => { + const workDir = join(root, "project"); + const planPath = join(root, "kimi-home", ".kimi-code", "plans", "refactor.md"); + await mkdir(workDir); + await mkdir(join(root, "kimi-home", ".kimi-code", "plans"), { recursive: true }); + await writeFile(planPath, "# Refactor plan"); + const getPlan = vi.fn(async () => ({ + id: "plan-1", + content: "# Refactor plan", + path: planPath, + })); + const ctx = { + ...createContext(vscodeHost.Uri.file(workDir)), + getSession: () => ({ session: { getPlan } }) as unknown as SessionRuntime, + } as HandlerContext; + + const genericResult = await fileHandlers[Methods.OpenFile]!({ filePath: planPath }, ctx); + const planResult = await fileHandlers[Methods.OpenPlanFile]!(undefined, ctx); + + expect(genericResult).toEqual({ ok: false }); + expect(planResult).toEqual({ ok: true }); + expect(getPlan).toHaveBeenCalledOnce(); + expect(vscodeHost.executeCommand).toHaveBeenCalledOnce(); + expect(vscodeHost.executeCommand).toHaveBeenCalledWith( + "vscode.open", + expect.objectContaining({ fsPath: planPath }), + ); + }); + it("omits an outside symlink when an SDK Write event requests baseline capture", async () => { const workDir = join(root, "project"); const outsideRoot = await mkdtemp(join(tmpdir(), "kimi-vscode-baseline-outside-")); diff --git a/apps/vscode/webview-ui/src/components/DisplayBlocks.tsx b/apps/vscode/webview-ui/src/components/DisplayBlocks.tsx index f435e782dc..b4d13a684c 100644 --- a/apps/vscode/webview-ui/src/components/DisplayBlocks.tsx +++ b/apps/vscode/webview-ui/src/components/DisplayBlocks.tsx @@ -1,8 +1,18 @@ import { useMemo } from "react"; import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; import { oneDark, oneLight } from "react-syntax-highlighter/dist/esm/styles/prism"; -import type { DisplayBlock, DiffBlock, TodoBlock, BriefBlock, ShellBlock } from "shared/legacy-sdk"; +import { IconExternalLink } from "@tabler/icons-react"; +import type { + DisplayBlock, + DiffBlock, + TodoBlock, + BriefBlock, + ShellBlock, + PlanBlock, +} from "shared/legacy-sdk"; import { cn } from "@/lib/utils"; +import { bridge } from "@/services"; +import { Markdown } from "./Markdown"; import * as Diff from "diff"; function useIsDark(): boolean { @@ -188,6 +198,34 @@ export function ShellBlockView({ block, maxHeight = "max-h-40" }: ShellBlockProp ); } +interface PlanBlockProps { + block: PlanBlock; + maxHeight?: string; +} + +export function PlanBlockView({ block, maxHeight = "max-h-40" }: PlanBlockProps) { + return ( +
+ {block.path && ( + + )} +
+ +
+
+ ); +} + interface DisplayBlockViewProps { block: DisplayBlock; maxHeight?: string; @@ -201,6 +239,8 @@ export function DisplayBlockView({ block, maxHeight }: DisplayBlockViewProps) { return ; case "brief": return ; + case "plan": + return ; default: return null; } diff --git a/apps/vscode/webview-ui/src/components/ToolRenderers.tsx b/apps/vscode/webview-ui/src/components/ToolRenderers.tsx index 16b0b4eb32..9ac1dec86a 100644 --- a/apps/vscode/webview-ui/src/components/ToolRenderers.tsx +++ b/apps/vscode/webview-ui/src/components/ToolRenderers.tsx @@ -57,7 +57,7 @@ function getRichDisplayBlocks(display?: DisplayBlock[]): DisplayBlock[] { if (!display) { return []; } - return display.filter((b) => b.type === "diff"); + return display.filter((b) => b.type === "diff" || b.type === "plan"); } function CodeBlock({ content, maxLines = 10 }: { content: string; maxLines?: number }) { diff --git a/apps/vscode/webview-ui/src/services/bridge.ts b/apps/vscode/webview-ui/src/services/bridge.ts index 04f90f274b..5d73cca463 100644 --- a/apps/vscode/webview-ui/src/services/bridge.ts +++ b/apps/vscode/webview-ui/src/services/bridge.ts @@ -255,6 +255,10 @@ class Bridge { return this.call<{ ok: boolean }>(Methods.OpenFile, { filePath }); } + openPlanFile() { + return this.call<{ ok: boolean }>(Methods.OpenPlanFile); + } + openFileDiff(filePath: string) { return this.call<{ ok: boolean }>(Methods.OpenFileDiff, { filePath }); } From f6b7f45d9d6e4b1240db5957f42f5835c6c8c2c2 Mon Sep 17 00:00:00 2001 From: "ericzhao.zhao" Date: Wed, 29 Jul 2026 19:43:52 +0800 Subject: [PATCH 2/2] fix(vscode): bind plan reviews to their files --- apps/vscode/shared/bridge.ts | 3 +- apps/vscode/shared/legacy-sdk.ts | 1 + apps/vscode/src/handlers/file.handler.ts | 11 ++--- apps/vscode/src/handlers/session.handler.ts | 6 ++- apps/vscode/src/runtime/event-adapter.ts | 15 ++++-- apps/vscode/src/runtime/kimi-runtime.ts | 21 ++++++++ apps/vscode/src/runtime/replay-adapter.ts | 32 ++++++++++--- apps/vscode/src/runtime/session-runtime.ts | 4 ++ apps/vscode/src/runtime/tool-display.ts | 16 ++++++- apps/vscode/test/event-adapter.test.ts | 31 +++++++----- apps/vscode/test/replay-adapter.test.ts | 48 +++++++++++++++++++ apps/vscode/test/workspace-paths.test.ts | 20 +++++--- .../src/components/DisplayBlocks.tsx | 4 +- apps/vscode/webview-ui/src/services/bridge.ts | 4 +- 14 files changed, 174 insertions(+), 42 deletions(-) diff --git a/apps/vscode/shared/bridge.ts b/apps/vscode/shared/bridge.ts index 372d68fa1e..217b2ca969 100644 --- a/apps/vscode/shared/bridge.ts +++ b/apps/vscode/shared/bridge.ts @@ -147,7 +147,6 @@ function validateParams(method: RpcMethod, params: unknown): boolean { case Methods.GetRegisteredWorkDirs: case Methods.BrowseWorkDir: case Methods.ClearTrackedFiles: - case Methods.OpenPlanFile: case Methods.ShowLogs: case Methods.ReloadWebview: return params === undefined; @@ -213,6 +212,8 @@ function validateParams(method: RpcMethod, params: unknown): boolean { case Methods.CheckFileExists: case Methods.GetImageDataUri: return hasString(params, "filePath"); + case Methods.OpenPlanFile: + return hasNonEmptyString(params, "reference"); case Methods.CheckFilesExist: case Methods.TrackFiles: return hasStringArray(params, "paths"); diff --git a/apps/vscode/shared/legacy-sdk.ts b/apps/vscode/shared/legacy-sdk.ts index ae5e310b57..b6bdc92eb7 100644 --- a/apps/vscode/shared/legacy-sdk.ts +++ b/apps/vscode/shared/legacy-sdk.ts @@ -41,6 +41,7 @@ export interface PlanBlock { type: 'plan'; plan: string; path?: string; + reference?: string; } export interface UnknownBlock { diff --git a/apps/vscode/src/handlers/file.handler.ts b/apps/vscode/src/handlers/file.handler.ts index 863ed0cd00..d38d53754c 100644 --- a/apps/vscode/src/handlers/file.handler.ts +++ b/apps/vscode/src/handlers/file.handler.ts @@ -17,6 +17,7 @@ interface GetProjectFilesParams { } interface PickMediaParams { maxCount?: number; includeVideo?: boolean } interface FilePathParams { filePath: string } +interface PlanReferenceParams { reference: string } interface OptionalFilePathParams { filePath?: string } interface PathsParams { paths: string[] } interface CheckFilesExistParams { paths: string[] } @@ -75,13 +76,11 @@ const openFile: Handler = async ({ filePath }, return { ok: true }; }; -const openPlanFile: Handler = async (_, ctx) => { - const runtime = ctx.getSession(); - if (runtime === undefined) return { ok: false }; - const plan = await runtime.session.getPlan(); - if (plan === null) return { ok: false }; +const openPlanFile: Handler = async ({ reference }, ctx) => { + const planPath = ctx.runtime.resolvePlanFile(reference); + if (planPath === undefined) return { ok: false }; - const uri = vscode.Uri.file(plan.path); + const uri = vscode.Uri.file(planPath); try { await vscode.workspace.fs.stat(uri); } catch { diff --git a/apps/vscode/src/handlers/session.handler.ts b/apps/vscode/src/handlers/session.handler.ts index 817d6960bf..4218e9cbf7 100644 --- a/apps/vscode/src/handlers/session.handler.ts +++ b/apps/vscode/src/handlers/session.handler.ts @@ -139,7 +139,11 @@ export const sessionHandlers: Record> = { if (resumeState?.agents["main"] === undefined) { throw new Error("Session history is unavailable."); } - history = replaySessionToWebviewEvents(resumeState, runtime.id); + history = replaySessionToWebviewEvents( + resumeState, + runtime.id, + (filePath) => ctx.runtime.registerPlanFile(filePath), + ); } catch (error) { await ctx.closeSession(); throw error; diff --git a/apps/vscode/src/runtime/event-adapter.ts b/apps/vscode/src/runtime/event-adapter.ts index 55c2c44524..e2bc41f558 100644 --- a/apps/vscode/src/runtime/event-adapter.ts +++ b/apps/vscode/src/runtime/event-adapter.ts @@ -8,7 +8,7 @@ import type { TurnBegin, } from '../../shared/legacy-sdk'; import type { ErrorPhase, UIStreamEvent } from '../../shared/types'; -import { toLegacyDisplay } from './tool-display'; +import { toLegacyDisplay, type PlanFileRegistrar } from './tool-display'; const DEFAULT_MAIN_AGENT_ID = 'main'; @@ -66,6 +66,7 @@ export interface AdaptSdkEventOptions { readonly pendingInput?: TurnBegin['user_input']; readonly mainAgentId?: string; readonly errorPhase?: ErrorPhase; + readonly registerPlanFile?: PlanFileRegistrar; } export function createEventAdapterState(): EventAdapterState { @@ -154,7 +155,12 @@ export function adaptSdkEvent( }; } - const mapped = mapLegacyWireEvent(state, sdkEvent, mainAgentId); + const mapped = mapLegacyWireEvent( + state, + sdkEvent, + mainAgentId, + options.registerPlanFile, + ); if (mapped.event === undefined) return { state: mapped.state }; const routed = routeSubagentEvent( @@ -197,6 +203,7 @@ function mapLegacyWireEvent( state: EventAdapterState, sdkEvent: Event, mainAgentId: string, + registerPlanFile?: PlanFileRegistrar, ): MappedLegacyWireEvent { switch (sdkEvent.type) { case 'turn.step.started': @@ -245,7 +252,9 @@ function mapLegacyWireEvent( sdkEvent.toolCallId, mainAgentId, ); - const display = sdkEvent.display === undefined ? undefined : toLegacyDisplay(sdkEvent.display); + const display = sdkEvent.display === undefined + ? undefined + : toLegacyDisplay(sdkEvent.display, registerPlanFile); return { state: display === undefined ? state diff --git a/apps/vscode/src/runtime/kimi-runtime.ts b/apps/vscode/src/runtime/kimi-runtime.ts index 9c5a9e5444..ac16289677 100644 --- a/apps/vscode/src/runtime/kimi-runtime.ts +++ b/apps/vscode/src/runtime/kimi-runtime.ts @@ -1,3 +1,5 @@ +import { randomUUID } from "node:crypto"; + import { createKimiHarness, type KimiHarness, @@ -49,6 +51,8 @@ export class KimiRuntime { private readonly log: KimiRuntimeOptions["log"]; private readonly sessions = new Map(); private readonly sessionByView = new Map(); + private readonly planFiles = new Map(); + private readonly planReferences = new Map(); private closed = false; constructor(options: KimiRuntimeOptions) { @@ -76,6 +80,20 @@ export class KimiRuntime { return this.sessions.get(id); } + registerPlanFile(filePath: string): string { + const existing = this.planReferences.get(filePath); + if (existing !== undefined) return existing; + + const reference = randomUUID(); + this.planFiles.set(reference, filePath); + this.planReferences.set(filePath, reference); + return reference; + } + + resolvePlanFile(reference: string): string | undefined { + return this.planFiles.get(reference); + } + async openSession(options: OpenSessionOptions): Promise { this.ensureOpen(); const current = this.getSessionForView(options.webviewId); @@ -218,6 +236,8 @@ export class KimiRuntime { await Promise.all([...this.sessions.values()].map((session) => session.close())); this.sessions.clear(); this.sessionByView.clear(); + this.planFiles.clear(); + this.planReferences.clear(); await this.harness.close(); } @@ -227,6 +247,7 @@ export class KimiRuntime { legacyApproval, broadcast: this.broadcast, captureBaseline: this.captureBaseline, + registerPlanFile: (filePath) => this.registerPlanFile(filePath), log: this.log, }); this.sessions.set(session.id, runtime); diff --git a/apps/vscode/src/runtime/replay-adapter.ts b/apps/vscode/src/runtime/replay-adapter.ts index facd978ac6..56ac912ed4 100644 --- a/apps/vscode/src/runtime/replay-adapter.ts +++ b/apps/vscode/src/runtime/replay-adapter.ts @@ -16,7 +16,7 @@ import type { } from "../../shared/legacy-sdk"; import type { UIStreamEvent } from "../../shared/types"; import { toLegacyToolName } from "./event-adapter"; -import { toLegacyDisplay } from "./tool-display"; +import { toLegacyDisplay, type PlanFileRegistrar } from "./tool-display"; interface SubagentReplayInvocation { readonly parentAgentId: string; @@ -35,24 +35,32 @@ interface SubagentReplayIndex { export function replaySessionToWebviewEvents( state: ResumedSessionState, sessionId: string, + registerPlanFile?: PlanFileRegistrar, ): UIStreamEvent[] { const main = state.agents["main"]; if (main === undefined) throw new Error("Session history is unavailable."); - return replayAgentToWebviewEvents(main, sessionId, buildSubagentReplayIndex(state)); + return replayAgentToWebviewEvents( + main, + sessionId, + buildSubagentReplayIndex(state), + registerPlanFile, + ); } /** Projects the public SDK resume replay into the released Webview protocol. */ export function replayToWebviewEvents( agent: ResumedAgentState, sessionId: string, + registerPlanFile?: PlanFileRegistrar, ): UIStreamEvent[] { - return replayAgentToWebviewEvents(agent, sessionId); + return replayAgentToWebviewEvents(agent, sessionId, undefined, registerPlanFile); } function replayAgentToWebviewEvents( agent: ResumedAgentState, sessionId: string, subagents?: SubagentReplayIndex, + registerPlanFile?: PlanFileRegistrar, ): UIStreamEvent[] { const events: UIStreamEvent[] = []; let turnOpen = false; @@ -130,7 +138,7 @@ function replayAgentToWebviewEvents( for (const call of message.toolCalls) { const display = message.toolCallDisplays?.[call.id]; if (display !== undefined) { - toolDisplays.set(call.id, toLegacyDisplay(display)); + toolDisplays.set(call.id, toLegacyDisplay(display, registerPlanFile)); } const toolCall: ToolCall = { type: "function", @@ -148,6 +156,7 @@ function replayAgentToWebviewEvents( call.id, [], new Set(), + registerPlanFile, )) { events.push(withSession(nested, sessionId)); } @@ -314,6 +323,7 @@ function renderSubagentInvocations( parentToolCallId: string, parentChain: readonly SubagentReplayInvocation[], visited: ReadonlySet, + registerPlanFile?: PlanFileRegistrar, ): LegacyWireEvent[] { const result: LegacyWireEvent[] = []; const invocations = index.byParentCall.get(parentCallKey(parentAgentId, parentToolCallId)) ?? []; @@ -322,7 +332,13 @@ function renderSubagentInvocations( if (visited.has(invocationKey)) continue; const nextVisited = new Set([...visited, invocationKey]); result.push( - ...renderSubagentInvocation(index, invocation, [invocation, ...parentChain], nextVisited), + ...renderSubagentInvocation( + index, + invocation, + [invocation, ...parentChain], + nextVisited, + registerPlanFile, + ), ); } return result; @@ -333,6 +349,7 @@ function renderSubagentInvocation( invocation: SubagentReplayInvocation, chain: readonly SubagentReplayInvocation[], visited: ReadonlySet, + registerPlanFile?: PlanFileRegistrar, ): LegacyWireEvent[] { const events: LegacyWireEvent[] = []; const toolDisplays = new Map(); @@ -359,7 +376,9 @@ function renderSubagentInvocation( for (const call of message.toolCalls) { const toolCallId = scopedReplayToolCallId(invocation.childAgentId, call.id); const display = message.toolCallDisplays?.[call.id]; - if (display !== undefined) toolDisplays.set(toolCallId, toLegacyDisplay(display)); + if (display !== undefined) { + toolDisplays.set(toolCallId, toLegacyDisplay(display, registerPlanFile)); + } emit({ type: "ToolCall", payload: { @@ -378,6 +397,7 @@ function renderSubagentInvocation( call.id, chain, visited, + registerPlanFile, ), ); } diff --git a/apps/vscode/src/runtime/session-runtime.ts b/apps/vscode/src/runtime/session-runtime.ts index a3bd2b4615..f665fb44c1 100644 --- a/apps/vscode/src/runtime/session-runtime.ts +++ b/apps/vscode/src/runtime/session-runtime.ts @@ -35,6 +35,7 @@ export interface SessionRuntimeOptions { filePath: string, webviewIds: readonly string[], ) => void; + readonly registerPlanFile?: (filePath: string) => string; readonly log: (message: string, error?: unknown) => void; } @@ -72,6 +73,7 @@ export class SessionRuntime { private readonly broadcast: RuntimeBroadcast; private readonly captureBaseline: SessionRuntimeOptions["captureBaseline"]; + private readonly registerPlanFile: SessionRuntimeOptions["registerPlanFile"]; private readonly log: SessionRuntimeOptions["log"]; private readonly webviewIds = new Set(); private readonly reverseRpc: ReverseRpcController; @@ -94,6 +96,7 @@ export class SessionRuntime { this.session = options.session; this.broadcast = options.broadcast; this.captureBaseline = options.captureBaseline; + this.registerPlanFile = options.registerPlanFile; this.log = options.log; this.legacyApproval = options.legacyApproval; this.reverseRpc = new ReverseRpcController((event) => this.emitStreamEvent(event)); @@ -470,6 +473,7 @@ export class SessionRuntime { const adapted = adaptSdkEvent(this.adapterState, event, { pendingInput, errorPhase: this.activePrompt?.started === false ? "preflight" : "runtime", + registerPlanFile: this.registerPlanFile, }); this.adapterState = adapted.state; diff --git a/apps/vscode/src/runtime/tool-display.ts b/apps/vscode/src/runtime/tool-display.ts index 89996fdf31..03a89f01e6 100644 --- a/apps/vscode/src/runtime/tool-display.ts +++ b/apps/vscode/src/runtime/tool-display.ts @@ -2,6 +2,8 @@ import type { ToolInputDisplay } from "@moonshot-ai/kimi-code-sdk"; import type { DisplayBlock } from "../../shared/legacy-sdk"; +export type PlanFileRegistrar = (filePath: string) => string; + export function describeToolDisplay(display: ToolInputDisplay): string { switch (display.kind) { case "command": @@ -33,7 +35,10 @@ export function describeToolDisplay(display: ToolInputDisplay): string { } } -export function toLegacyDisplay(display: ToolInputDisplay): DisplayBlock[] { +export function toLegacyDisplay( + display: ToolInputDisplay, + registerPlanFile?: PlanFileRegistrar, +): DisplayBlock[] { switch (display.kind) { case "command": return [{ type: "shell", language: display.language ?? "bash", command: display.command }]; @@ -71,6 +76,13 @@ export function toLegacyDisplay(display: ToolInputDisplay): DisplayBlock[] { case "generic": return [{ type: "brief", text: describeToolDisplay(display) }]; case "plan_review": - return [{ type: "plan", plan: display.plan, path: display.path }]; + return [{ + type: "plan", + plan: display.plan, + path: display.path, + ...(display.path === undefined || registerPlanFile === undefined + ? {} + : { reference: registerPlanFile(display.path) }), + }]; } } diff --git a/apps/vscode/test/event-adapter.test.ts b/apps/vscode/test/event-adapter.test.ts index 40ed22378c..10ddc03e61 100644 --- a/apps/vscode/test/event-adapter.test.ts +++ b/apps/vscode/test/event-adapter.test.ts @@ -263,20 +263,24 @@ describe('event adapter (projects SDK events into the legacy Webview contract)', }); it('preserves a plan review body and path in a dedicated display block', () => { - const started = adaptSdkEvent(createEventAdapterState(), { - type: 'tool.call.started', - sessionId: 'session-1', - agentId: 'main', - turnId: 7, - toolCallId: 'plan-1', - name: 'ExitPlanMode', - args: {}, - display: { - kind: 'plan_review', - plan: '# Refactor plan\n\n- Verify the change', - path: '/tmp/kimi-code/plans/refactor.md', + const started = adaptSdkEvent( + createEventAdapterState(), + { + type: 'tool.call.started', + sessionId: 'session-1', + agentId: 'main', + turnId: 7, + toolCallId: 'plan-1', + name: 'ExitPlanMode', + args: {}, + display: { + kind: 'plan_review', + plan: '# Refactor plan\n\n- Verify the change', + path: '/tmp/kimi-code/plans/refactor.md', + }, }, - }); + { registerPlanFile: () => 'reviewed-plan' }, + ); const finished = adaptSdkEvent(started.state, { type: 'tool.result', sessionId: 'session-1', @@ -294,6 +298,7 @@ describe('event adapter (projects SDK events into the legacy Webview contract)', type: 'plan', plan: '# Refactor plan\n\n- Verify the change', path: '/tmp/kimi-code/plans/refactor.md', + reference: 'reviewed-plan', }], }, }, diff --git a/apps/vscode/test/replay-adapter.test.ts b/apps/vscode/test/replay-adapter.test.ts index 34ea632b82..a122fd3928 100644 --- a/apps/vscode/test/replay-adapter.test.ts +++ b/apps/vscode/test/replay-adapter.test.ts @@ -27,6 +27,7 @@ function message( content: ContentPart[], options: { readonly toolCalls?: ToolCall[]; + readonly toolCallDisplays?: ReplayMessage["toolCallDisplays"]; readonly toolCallId?: string; readonly isError?: boolean; readonly origin?: ReplayMessage["origin"]; @@ -36,6 +37,7 @@ function message( role, content, toolCalls: options.toolCalls ?? [], + toolCallDisplays: options.toolCallDisplays, toolCallId: options.toolCallId, isError: options.isError, origin: options.origin, @@ -297,6 +299,52 @@ describe("replay adapter (renders the public SDK resume state for the Webview)", }); }); + it("binds a resumed plan review to its host-issued file reference", () => { + const agent = resumedAgent([ + record(message("user", [{ type: "text", text: "Review the plan" }], { + origin: { kind: "user" }, + })), + record(message("assistant", [], { + toolCalls: [{ + type: "function", + id: "plan-1", + name: "ExitPlanMode", + arguments: "{}", + }], + toolCallDisplays: { + "plan-1": { + kind: "plan_review", + plan: "# Historical plan", + path: "/tmp/kimi-code/plans/historical.md", + }, + }, + }), 2), + record(message("tool", [{ type: "text", text: "Plan approved" }], { + toolCallId: "plan-1", + }), 3), + ]); + + const events = replayToWebviewEvents( + agent, + "session-1", + (filePath) => `reference:${filePath}`, + ); + + expect(events).toContainEqual(expect.objectContaining({ + type: "ToolResult", + payload: expect.objectContaining({ + return_value: expect.objectContaining({ + display: [{ + type: "plan", + plan: "# Historical plan", + path: "/tmp/kimi-code/plans/historical.md", + reference: "reference:/tmp/kimi-code/plans/historical.md", + }], + }), + }), + })); + }); + it("renders a failed tool result in the open turn", () => { const events = replay([ record(message("user", [{ type: "text", text: "Run it" }], { origin: { kind: "user" } })), diff --git a/apps/vscode/test/workspace-paths.test.ts b/apps/vscode/test/workspace-paths.test.ts index b2aafc7997..b54de805dd 100644 --- a/apps/vscode/test/workspace-paths.test.ts +++ b/apps/vscode/test/workspace-paths.test.ts @@ -280,28 +280,36 @@ describe("Webview workspace paths (selected-directory containment)", () => { expect(vscodeHost.executeCommand).not.toHaveBeenCalled(); }); - it("opens the current session plan outside the workspace through the plan-only RPC", async () => { + it("opens the reviewed plan outside the workspace even when the active plan has changed", async () => { const workDir = join(root, "project"); const planPath = join(root, "kimi-home", ".kimi-code", "plans", "refactor.md"); await mkdir(workDir); await mkdir(join(root, "kimi-home", ".kimi-code", "plans"), { recursive: true }); await writeFile(planPath, "# Refactor plan"); const getPlan = vi.fn(async () => ({ - id: "plan-1", - content: "# Refactor plan", - path: planPath, + id: "plan-2", + content: "# Newer plan", + path: join(root, "kimi-home", ".kimi-code", "plans", "newer.md"), })); + const resolvePlanFile = vi.fn((reference: string) => ( + reference === "reviewed-plan" ? planPath : undefined + )); const ctx = { ...createContext(vscodeHost.Uri.file(workDir)), + runtime: { resolvePlanFile } as unknown as HandlerContext["runtime"], getSession: () => ({ session: { getPlan } }) as unknown as SessionRuntime, } as HandlerContext; const genericResult = await fileHandlers[Methods.OpenFile]!({ filePath: planPath }, ctx); - const planResult = await fileHandlers[Methods.OpenPlanFile]!(undefined, ctx); + const unknownResult = await fileHandlers[Methods.OpenPlanFile]!({ reference: "forged" }, ctx); + const planResult = await fileHandlers[Methods.OpenPlanFile]!({ reference: "reviewed-plan" }, ctx); expect(genericResult).toEqual({ ok: false }); + expect(unknownResult).toEqual({ ok: false }); expect(planResult).toEqual({ ok: true }); - expect(getPlan).toHaveBeenCalledOnce(); + expect(getPlan).not.toHaveBeenCalled(); + expect(resolvePlanFile).toHaveBeenCalledWith("forged"); + expect(resolvePlanFile).toHaveBeenCalledWith("reviewed-plan"); expect(vscodeHost.executeCommand).toHaveBeenCalledOnce(); expect(vscodeHost.executeCommand).toHaveBeenCalledWith( "vscode.open", diff --git a/apps/vscode/webview-ui/src/components/DisplayBlocks.tsx b/apps/vscode/webview-ui/src/components/DisplayBlocks.tsx index b4d13a684c..174d1efec9 100644 --- a/apps/vscode/webview-ui/src/components/DisplayBlocks.tsx +++ b/apps/vscode/webview-ui/src/components/DisplayBlocks.tsx @@ -206,11 +206,11 @@ interface PlanBlockProps { export function PlanBlockView({ block, maxHeight = "max-h-40" }: PlanBlockProps) { return (
- {block.path && ( + {block.path && block.reference && (