diff --git a/.changeset/README.md b/.changeset/README.md index 9dfaa2c8e..3dae788b2 100644 --- a/.changeset/README.md +++ b/.changeset/README.md @@ -23,6 +23,11 @@ All other workspace packages are private internal packages, are not published to - `@moonshot-ai/vis` - `@moonshot-ai/vis-server` - `@moonshot-ai/vis-web` +- `@moonshot-ai/acp-adapter` +- `kimi-code` (VS Code extension) +- `@moonshot-ai/kimi-code-vscode-agent-sdk` +- `@moonshot-ai/kimi-code-vscode-webview` +- `@moonshot-ai/kimi-code-vscode-display-model` Version impact from internal dependencies must be judged manually. The published artifacts for CLI and SDK bundle internal workspace packages into the artifact itself; runtime `dependencies` of published packages must not include any `@moonshot-ai/*` internal workspace packages. diff --git a/.changeset/config.json b/.changeset/config.json index af6143de9..181d6ebca 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -9,7 +9,12 @@ "ignore": [ "@moonshot-ai/vis", "@moonshot-ai/vis-server", - "@moonshot-ai/vis-web" + "@moonshot-ai/vis-web", + "@moonshot-ai/acp-adapter", + "kimi-code", + "@moonshot-ai/kimi-code-vscode-agent-sdk", + "@moonshot-ai/kimi-code-vscode-webview", + "@moonshot-ai/kimi-code-vscode-display-model" ], "snapshot": { "useCalculatedVersion": true, diff --git a/.changeset/kimi-acp-extension-notifications.md b/.changeset/kimi-acp-extension-notifications.md new file mode 100644 index 000000000..fcdc5f594 --- /dev/null +++ b/.changeset/kimi-acp-extension-notifications.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +Expose Kimi ACP extension notifications for compaction, interrupted steps, and subagent activity. diff --git a/.changeset/shared-slash-command-registry.md b/.changeset/shared-slash-command-registry.md new file mode 100644 index 000000000..79494ba37 --- /dev/null +++ b/.changeset/shared-slash-command-registry.md @@ -0,0 +1,6 @@ +--- +"@moonshot-ai/kimi-code-sdk": minor +"@moonshot-ai/kimi-code": patch +--- + +Share slash command metadata across CLI and ACP surfaces and add SDK clear-context support. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c7ceac2ad..04ae65aff 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -59,6 +59,7 @@ jobs: - run: pnpm install --frozen-lockfile - run: pnpm run lint + - run: pnpm -C apps/vscode run lint - run: pnpm run sherif typecheck: diff --git a/.gitignore b/.gitignore index 691c675b7..c6490311a 100644 --- a/.gitignore +++ b/.gitignore @@ -12,4 +12,11 @@ coverage/ .kimi-stash-dir plugins/cdn/ superpowers + +# VS Code extension build artifacts +apps/vscode/dist/ +apps/vscode/out/ +apps/vscode/.vscode-test/ +apps/vscode/.vscode-test-web/ +apps/vscode/*.vsix .worktrees/ diff --git a/.oxlintrc.json b/.oxlintrc.json index 77a86ac9a..4f4801184 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -148,6 +148,7 @@ "dist/", "coverage/", "node_modules/", + "apps/vscode/", "apps/*/scripts/", "docs/smoke-archive/", "plugins/curated/superpowers/", diff --git a/AGENTS.md b/AGENTS.md index ffe93c6aa..383900553 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,6 +15,7 @@ This is a TypeScript monorepo built for agent-assisted development. Keep the roo ## Project Map - `apps/kimi-code`: the CLI / TUI application. It consumes core capabilities through `@moonshot-ai/kimi-code-sdk` and must not depend directly on `@moonshot-ai/agent-core`. When writing or modifying its terminal UI, use the `write-tui` skill (`.agents/skills/write-tui/SKILL.md`). +- `apps/vscode`: the VS Code extension host, private agent SDK, React webview, and VS Code display model. Its runtime must not depend directly on `@moonshot-ai/agent-core`; shared ACP semantics should stay protocol-level, while VS Code display semantics stay under `apps/vscode/agent-display-model`. - `apps/vis`, `apps/vis/server`, `apps/vis/web`: visual debugging tools for sessions and replays. - `packages/agent-core`: the unified agent engine, including Agent, Session, profile, skills, tools, plan, permission, background, records, and other core capabilities. - `packages/node-sdk`: the public TypeScript SDK and harness. diff --git a/apps/kimi-code/src/tui/commands/registry.ts b/apps/kimi-code/src/tui/commands/registry.ts index 464cc770d..63c4b0b8a 100644 --- a/apps/kimi-code/src/tui/commands/registry.ts +++ b/apps/kimi-code/src/tui/commands/registry.ts @@ -1,4 +1,9 @@ import type { AutocompleteItem } from '@earendil-works/pi-tui'; +import { + getSlashCommandsForSurface, + type SlashCommandDescriptor, + type SlashCommandName, +} from '@moonshot-ai/acp-adapter'; import { completeLeadingArg, type ArgCompletionSpec } from './complete-args'; import type { KimiSlashCommand, SlashCommandAvailability } from './types'; @@ -41,143 +46,17 @@ export function swarmArgumentCompletions(argumentPrefix: string): AutocompleteIt return completeLeadingArg(SWARM_ARG_COMPLETIONS, argumentPrefix); } -export const BUILTIN_SLASH_COMMANDS = [ - { - name: 'yolo', - aliases: ['yes'], - description: 'Toggle auto-approve mode', - priority: 100, - availability: 'always', - }, - { - name: 'auto', - aliases: [], - description: 'Toggle auto permission mode', - priority: 100, - availability: 'always', - }, - { - name: 'permission', - aliases: [], - description: 'Select permission mode', - priority: 100, - availability: 'always', - }, - { - name: 'settings', - aliases: ['config'], - description: 'Open TUI settings', - priority: 100, - availability: 'always', - }, - { - name: 'plan', - aliases: [], - description: 'Toggle plan mode', - priority: 100, +type TuiSlashCommandExtension = Pick; + +const TUI_SLASH_COMMAND_EXTENSIONS: Partial> = { + plan: { availability: (args) => (args.trim().toLowerCase() === 'clear' ? 'idle-only' : 'always'), }, - { - name: 'swarm', - aliases: [], - description: 'Toggle swarm mode or run one task in swarm mode', - priority: 100, + swarm: { completeArgs: swarmArgumentCompletions, availability: 'idle-only', }, - { - name: 'model', - aliases: [], - description: 'Switch LLM model', - priority: 100, - availability: 'always', - }, - { - name: 'provider', - aliases: ['providers'], - description: 'Manage AI providers (add / delete / refresh)', - priority: 95, - availability: 'always', - }, - { - name: 'btw', - aliases: [], - description: 'Ask a forked side agent a question', - priority: 90, - availability: 'always', - }, - { - name: 'help', - aliases: ['h', '?'], - description: 'Show available commands and shortcuts', - priority: 80, - availability: 'always', - }, - { - name: 'new', - aliases: ['clear'], - description: 'Start a fresh session in the current workspace', - priority: 80, - }, - { - name: 'sessions', - aliases: ['resume'], - description: 'Browse and resume sessions', - priority: 80, - }, - { - name: 'tasks', - aliases: ['task'], - description: 'Browse background tasks', - priority: 80, - availability: 'always', - }, - { - name: 'mcp', - aliases: [], - description: 'Show MCP server status', - priority: 60, - availability: 'always', - }, - { - name: 'plugins', - aliases: [], - description: 'Manage plugins', - priority: 60, - availability: 'always', - }, - { - name: 'experiments', - aliases: ['experimental'], - description: 'Manage experimental features', - priority: 60, - availability: 'idle-only', - }, - { - name: 'reload', - aliases: [], - description: 'Reload session and apply config.toml settings plus tui.toml UI preferences', - priority: 60, - availability: 'idle-only', - }, - { - name: 'reload-tui', - aliases: [], - description: 'Reload only tui.toml UI preferences', - priority: 60, - availability: 'always', - }, - { - name: 'compact', - aliases: [], - description: 'Compact the conversation context', - priority: 80, - }, - { - name: 'goal', - aliases: [], - description: 'Start or manage an autonomous goal', - priority: 80, + goal: { // No argumentHint: the menu description stays as short as every other // command's. The subcommands (status/pause/resume/cancel/replace) surface in // the argument autocomplete list once the user types `/goal ` (see @@ -193,104 +72,24 @@ export const BUILTIN_SLASH_COMMANDS = [ : 'idle-only'; }, }, - { - name: 'init', - aliases: [], - description: 'Analyze the codebase and generate AGENTS.md', - }, - { - name: 'fork', - aliases: [], - description: 'Fork the current session', - priority: 80, - }, - { - name: 'title', - aliases: ['rename'], - description: 'Set or show session title', - priority: 60, - availability: 'always', - }, - { - name: 'usage', - aliases: [], - description: 'Show session tokens + context window + plan quotas', - priority: 60, - availability: 'always', - }, - { - name: 'status', - aliases: [], - description: 'Show current session and runtime status', - priority: 60, - availability: 'always', - }, - { - name: 'feedback', - aliases: [], - description: 'Send feedback to make Kimi Code better', - priority: 60, - availability: 'always', - }, - { - name: 'undo', - aliases: [], - description: 'Withdraw the last prompt from the transcript', - priority: 80, - availability: 'idle-only', - }, - { - name: 'editor', - aliases: [], - description: 'Set the external editor for Ctrl-G', - priority: 60, - availability: 'always', - }, - { - name: 'theme', - aliases: [], - description: 'Set the terminal UI theme', - priority: 60, - availability: 'always', - }, - { - name: 'logout', - aliases: ['disconnect'], - description: 'Log out of a configured provider', - priority: 40, - }, - { - name: 'login', - aliases: [], - description: 'Select a platform and authenticate', - priority: 40, - }, - { - name: 'export-md', - aliases: ['export'], - description: 'Export current session as a Markdown file', - priority: 40, - }, - { - name: 'export-debug-zip', - aliases: [], - description: 'Export current session as a debug ZIP archive', - priority: 40, - }, - { - name: 'exit', - aliases: ['quit', 'q'], - description: 'Exit the application', - priority: 20, - }, - { - name: 'version', - aliases: [], - description: 'Show version information', - priority: 20, - availability: 'always', - }, -] as const satisfies readonly KimiSlashCommand[]; +}; + +function toKimiSlashCommand(command: SlashCommandDescriptor): KimiSlashCommand { + const extension = TUI_SLASH_COMMAND_EXTENSIONS[command.name as SlashCommandName]; + return { + name: command.name as SlashCommandName, + aliases: command.aliases ?? [], + description: command.description, + priority: command.priority, + availability: extension?.availability ?? command.availability, + ...(extension?.completeArgs ? { completeArgs: extension.completeArgs } : {}), + ...(extension?.experimentalFlag ? { experimentalFlag: extension.experimentalFlag } : {}), + }; +} + +export const BUILTIN_SLASH_COMMANDS: readonly KimiSlashCommand[] = getSlashCommandsForSurface('tui').map((command) => + toKimiSlashCommand(command), +); export type BuiltinSlashCommand = (typeof BUILTIN_SLASH_COMMANDS)[number]; export type BuiltinSlashCommandName = BuiltinSlashCommand['name']; diff --git a/apps/vscode/.vscodeignore b/apps/vscode/.vscodeignore new file mode 100644 index 000000000..965f2afd0 --- /dev/null +++ b/apps/vscode/.vscodeignore @@ -0,0 +1,30 @@ +# Source files +src/** +shared/** +agent-sdk/** +agent-display-model/** +webview-ui/** +webview-ui/src/** +webview-ui/public/** + +# Node modules +node_modules/** +webview-ui/node_modules/** + +# Build configs and scripts +tsconfig.json +esbuild.js +dev.js +eslint.config.mjs +scripts/** +webview-ui/tsconfig.json +webview-ui/vite.config.ts +webview-ui/components.json + +# Other +AGENTS.md +docs/** +.vscode-test.mjs +.gitignore +**/*.map +*.vsix diff --git a/apps/vscode/LICENSE b/apps/vscode/LICENSE new file mode 100644 index 000000000..6102a1bc7 --- /dev/null +++ b/apps/vscode/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Moonshot AI + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/apps/vscode/agent-display-model/package.json b/apps/vscode/agent-display-model/package.json new file mode 100644 index 000000000..6fb7d1e3a --- /dev/null +++ b/apps/vscode/agent-display-model/package.json @@ -0,0 +1,25 @@ +{ + "name": "@moonshot-ai/kimi-code-vscode-display-model", + "version": "0.1.0", + "description": "VS Code display state reducer for Kimi agent events", + "license": "MIT", + "type": "module", + "private": true, + "imports": { + "#/*": [ + "./src/*.ts", + "./src/*/index.ts" + ] + }, + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + } + }, + "scripts": { + "test": "vitest run", + "typecheck": "tsc -p tsconfig.json --noEmit", + "clean": "rm -rf dist" + } +} diff --git a/apps/vscode/agent-display-model/src/copy-content.ts b/apps/vscode/agent-display-model/src/copy-content.ts new file mode 100644 index 000000000..8811d87b9 --- /dev/null +++ b/apps/vscode/agent-display-model/src/copy-content.ts @@ -0,0 +1,96 @@ +import type { DisplayBlock, DisplayPart, DisplayPlanEntry, DisplayTodoItem } from './model'; + +function nonEmptyText(text: string | null | undefined): string | null { + if (!text || text.trim().length === 0) return null; + return text; +} + +function formatPlanEntry(entry: DisplayPlanEntry): string { + const priority = entry.priority ? ` (${entry.priority})` : ''; + return `- [${entry.status}] ${entry.content}${priority}`; +} + +function formatTodoItem(item: DisplayTodoItem): string { + return `- [${item.status}] ${item.title}`; +} + +function summarizeDisplayBlock(block: DisplayBlock): string | null { + switch (block.type) { + case 'brief': + return nonEmptyText(block.text); + case 'diff': + return `Diff: ${block.path}`; + case 'todo': { + const items = block.items.map(formatTodoItem); + return items.length > 0 ? ['Todo:', ...items].join('\n') : null; + } + case 'command': { + const lines = [`Command (${block.language}): ${block.command}`]; + if (block.cwd) lines.push(`cwd: ${block.cwd}`); + if (block.danger) lines.push(`Danger: ${block.danger}`); + if (block.description) lines.push(block.description); + return lines.join('\n'); + } + case 'file-op': { + const detail = block.detail ? `\n${block.detail}` : ''; + return `${block.operation} ${block.path}${detail}`; + } + case 'file-content': + return [`File: ${block.path}`, block.content].join('\n'); + case 'url-fetch': + return `${block.method ?? 'GET'} ${block.url}`; + case 'search': { + const scope = block.scope ? `\nscope: ${block.scope}` : ''; + return `Search: ${block.query}${scope}`; + } + case 'invocation': { + const description = block.description ? `\n${block.description}` : ''; + return `${block.kind}: ${block.name}${description}`; + } + case 'background-task': { + const description = block.description ? `: ${block.description}` : ''; + return `Background task ${block.taskId} (${block.kind}, ${block.status})${description}`; + } + } +} + +function summarizeDisplayBlocks(blocks: DisplayBlock[] | undefined): string | null { + const summaries = (blocks ?? []).map(summarizeDisplayBlock).filter((block): block is string => block !== null); + return summaries.length > 0 ? summaries.join('\n\n') : null; +} + +function formatToolCall(part: Extract): string | null { + const sections = [`Tool: ${part.name}`, `Status: ${part.status}`]; + const args = nonEmptyText(part.argumentsText); + const result = nonEmptyText(part.resultText); + const display = summarizeDisplayBlocks(part.displayBlocks); + + if (args) sections.push('', 'Arguments:', args); + if (result) sections.push('', 'Result:', result); + if (display) sections.push('', 'Display:', display); + + return sections.join('\n'); +} + +export function getDisplayPartCopyContent(part: DisplayPart): string | null { + switch (part.type) { + case 'text': + return nonEmptyText(part.text); + case 'thinking': + return part.finished ? nonEmptyText(part.text) : null; + case 'media': + return `[${part.kind}${part.id ? ` ${part.id}` : part.url ? ` ${part.url}` : ''}]`; + case 'plan': { + const entries = part.plan.entries.map(formatPlanEntry); + return entries.length > 0 ? entries.join('\n') : null; + } + case 'tool-call': + return formatToolCall(part); + case 'approval': + case 'compaction': + case 'error': + case 'interrupt': + case 'status': + return null; + } +} diff --git a/apps/vscode/agent-display-model/src/effects.ts b/apps/vscode/agent-display-model/src/effects.ts new file mode 100644 index 000000000..cf9918745 --- /dev/null +++ b/apps/vscode/agent-display-model/src/effects.ts @@ -0,0 +1,10 @@ +import type { DisplayApprovalPart, DisplayAvailableCommand, DisplayStatusViewModel } from './model'; + +export type DisplayEffect = + | { type: 'TrackFiles'; paths: string[] } + | { type: 'ClearTrackedFiles' } + | { type: 'OpenApproval'; request: DisplayApprovalPart } + | { type: 'ClearApprovals' } + | { type: 'UpdateStatus'; status: DisplayStatusViewModel | null } + | { type: 'UpdateAvailableCommands'; commands: DisplayAvailableCommand[] } + | { type: 'Notify'; level: 'info' | 'warning' | 'error'; message: string }; diff --git a/apps/vscode/agent-display-model/src/events.ts b/apps/vscode/agent-display-model/src/events.ts new file mode 100644 index 000000000..40c305993 --- /dev/null +++ b/apps/vscode/agent-display-model/src/events.ts @@ -0,0 +1,65 @@ +import type { + DisplayApprovalPart, + DisplayAvailableCommand, + DisplayBlock, + DisplayCompactionTrigger, + DisplayErrorModel, + DisplayMediaPart, + DisplayPart, + DisplayPlanViewModel, + DisplayRole, + DisplayStatusViewModel, + DisplayTokenUsage, +} from './model'; + +export type DisplayEvent = + | { type: 'conversation.reset' } + | { type: 'message.begin'; id?: string; role: DisplayRole; text?: string } + | { type: 'turn.begin'; userText: string; parts?: DisplayPart[] } + | { type: 'turn.complete' } + | { type: 'turn.error'; error: DisplayErrorModel } + | { type: 'turn.interrupted'; reason?: string; message?: string } + | { type: 'step.begin'; n: number } + | { type: 'content.append'; kind: 'text' | 'thinking'; text: string } + | { type: 'content.append'; kind: 'media'; media: DisplayMediaPart } + | { + type: 'tool.call'; + id: string; + name: string; + argumentsText?: string | null; + status?: 'pending' | 'running'; + } + | { type: 'tool.call.delta'; id: string; argumentsPart: string } + | { + type: 'tool.result'; + id: string; + isError?: boolean; + output?: string; + message?: string; + displayBlocks?: DisplayBlock[]; + } + | { type: 'plan.replace'; plan: DisplayPlanViewModel } + | { type: 'approval.request'; request: DisplayApprovalPart } + | { type: 'approval.resolved'; requestId: string | number } + | { type: 'approval.clear' } + | { type: 'status.update'; status: DisplayStatusViewModel } + | { type: 'usage.add'; usage: DisplayTokenUsage } + | { type: 'compaction.begin'; trigger?: DisplayCompactionTrigger; instruction?: string; message?: string } + | { + type: 'compaction.end'; + status?: 'completed' | 'cancelled' | 'blocked'; + trigger?: DisplayCompactionTrigger; + instruction?: string; + summary?: string; + compactedCount?: number; + tokensBefore?: number; + tokensAfter?: number; + message?: string; + } + | { type: 'step.interrupted'; reason?: string; message?: string } + | { type: 'available_commands.update'; commands: DisplayAvailableCommand[] } + | { + type: 'subagent.event'; + parentToolCallId: string; + event: DisplayEvent; + }; diff --git a/apps/vscode/agent-display-model/src/index.ts b/apps/vscode/agent-display-model/src/index.ts new file mode 100644 index 000000000..971caea9f --- /dev/null +++ b/apps/vscode/agent-display-model/src/index.ts @@ -0,0 +1,70 @@ +export type { + DisplayEffect, +} from './effects'; +export type { DisplayEvent } from './events'; +export { createEmptyTokenUsage, createInitialDisplayState } from './model'; +export type { + DisplayApprovalOption, + DisplayApprovalPart, + DisplayAvailableCommand, + DisplayBackgroundTaskBlock, + DisplayBlock, + DisplayBriefBlock, + DisplayCommandBlock, + DisplayCompactionPart, + DisplayCompactionTrigger, + DisplayDiffBlock, + DisplayFileContentBlock, + DisplayFileOperation, + DisplayFileOperationBlock, + DisplayInvocationBlock, + DisplayInvocationKind, + DisplaySearchBlock, + DisplayErrorModel, + DisplayErrorPart, + DisplayErrorPhase, + DisplayInterruptPart, + DisplayMediaKind, + DisplayMediaPart, + DisplayMessage, + DisplayMessageStatus, + DisplayPart, + DisplayPlanEntry, + DisplayPlanPart, + DisplayPlanViewModel, + DisplayRole, + DisplayState, + DisplayStatusPart, + DisplayStatusViewModel, + DisplayStep, + DisplayTextPart, + DisplayThinkingPart, + DisplayTodoBlock, + DisplayTodoItem, + DisplayTodoStatus, + DisplayTokenUsage, + DisplayToolCallPart, + DisplayToolStatus, + DisplayUrlFetchBlock, +} from './model'; +export { finalizeDisplayStateForHistory, reduceDisplayEvent, type DisplayReduction } from './reducer'; +export { getDisplayPartCopyContent } from './copy-content'; +export { + extractDisplayJsonFromText, + extractDisplayTodoItems, + findTodoDisplayBlock, + getDisplayToolKind, + getGenericToolCallSummary, + getRichToolDisplayBlocks, + getTodoItemsForToolCall, + getToolCallSummary, + isTaskToolName, + isTodoToolName, + normalizeDisplayTodoItems, + normalizeDisplayTodoStatus, + displayTodoItemTitle, + parseDisplayJsonValue, + parseDisplayTodoListText, + parseToolArguments, + type DisplayToolKind, +} from './tool-display'; diff --git a/apps/vscode/agent-display-model/src/model.ts b/apps/vscode/agent-display-model/src/model.ts new file mode 100644 index 000000000..033d68ef4 --- /dev/null +++ b/apps/vscode/agent-display-model/src/model.ts @@ -0,0 +1,271 @@ +export type DisplayRole = 'user' | 'assistant' | 'system'; +export type DisplayMessageStatus = 'streaming' | 'completed' | 'interrupted' | 'error'; +export type DisplayToolStatus = 'pending' | 'running' | 'success' | 'error' | 'cancelled'; +export type DisplayTodoStatus = 'pending' | 'in_progress' | 'done'; +export type DisplayMediaKind = 'image' | 'audio' | 'video'; +export type DisplayCompactionTrigger = 'manual' | 'auto'; + +export interface DisplayTokenUsage { + inputOther: number; + output: number; + inputCacheRead: number; + inputCacheCreation: number; +} + +export interface DisplayStatusViewModel { + contextUsage?: number | null; + contextTokens?: number | null; + maxContextTokens?: number | null; + tokenUsage?: DisplayTokenUsage | null; + messageId?: string | null; +} + +export interface DisplayPlanEntry { + content: string; + status: 'pending' | 'in_progress' | 'completed'; + priority?: 'low' | 'medium' | 'high'; +} + +export interface DisplayAvailableCommand { + name: string; + description: string; + group?: string; +} + +export type DisplayErrorPhase = 'preflight' | 'runtime'; + +export interface DisplayErrorModel { + code: string; + message: string; + phase: DisplayErrorPhase; + details?: Record; +} + +export interface DisplayPlanViewModel { + entries: DisplayPlanEntry[]; +} + +export interface DisplayBriefBlock { + type: 'brief'; + text: string; +} + +export interface DisplayDiffBlock { + type: 'diff'; + path: string; + oldText: string; + newText: string; +} + +export interface DisplayTodoItem { + title: string; + status: DisplayTodoStatus; +} + +export interface DisplayTodoBlock { + type: 'todo'; + items: DisplayTodoItem[]; +} + +export interface DisplayCommandBlock { + type: 'command'; + language: string; + command: string; + cwd?: string; + description?: string; + danger?: string; +} + +export type DisplayFileOperation = 'read' | 'write' | 'edit' | 'glob' | 'grep'; + +export interface DisplayFileOperationBlock { + type: 'file-op'; + operation: DisplayFileOperation; + path: string; + detail?: string; +} + +export interface DisplayFileContentBlock { + type: 'file-content'; + path: string; + content: string; + language?: string; +} + +export interface DisplayUrlFetchBlock { + type: 'url-fetch'; + url: string; + method?: string; +} + +export interface DisplaySearchBlock { + type: 'search'; + query: string; + scope?: string; +} + +export type DisplayInvocationKind = 'agent' | 'skill'; + +export interface DisplayInvocationBlock { + type: 'invocation'; + kind: DisplayInvocationKind; + name: string; + description?: string; +} + +export interface DisplayBackgroundTaskBlock { + type: 'background-task'; + taskId: string; + kind: string; + status: string; + description?: string; +} + +export type DisplayBlock = + | DisplayBriefBlock + | DisplayDiffBlock + | DisplayTodoBlock + | DisplayCommandBlock + | DisplayFileOperationBlock + | DisplayFileContentBlock + | DisplayUrlFetchBlock + | DisplaySearchBlock + | DisplayInvocationBlock + | DisplayBackgroundTaskBlock; + +export interface DisplayTextPart { + type: 'text'; + text: string; + finished?: boolean; +} + +export interface DisplayThinkingPart { + type: 'thinking'; + text: string; + finished?: boolean; +} + +export interface DisplayMediaPart { + type: 'media'; + kind: DisplayMediaKind; + url: string; + id?: string | null; +} + +export interface DisplayToolCallPart { + type: 'tool-call'; + id: string; + name: string; + argumentsText?: string | null; + status: DisplayToolStatus; + resultText?: string; + displayBlocks?: DisplayBlock[]; + children?: DisplayStep[]; +} + +export interface DisplayPlanPart { + type: 'plan'; + plan: DisplayPlanViewModel; +} + +export interface DisplayCompactionPart { + type: 'compaction'; + status: 'running' | 'completed' | 'cancelled' | 'blocked'; + trigger?: DisplayCompactionTrigger; + instruction?: string; + summary?: string; + compactedCount?: number; + tokensBefore?: number; + tokensAfter?: number; + message?: string; +} + +export interface DisplayErrorPart { + type: 'error'; + error: DisplayErrorModel; +} + +export interface DisplayApprovalOption { + optionId: string; + name: string; + kind?: string; +} + +export interface DisplayApprovalPart { + type: 'approval'; + requestId: string | number; + toolCallId: string; + sender: string; + action: string; + description: string; + displayBlocks?: DisplayBlock[]; + options?: DisplayApprovalOption[]; +} + +export interface DisplayStatusPart { + type: 'status'; + status: DisplayStatusViewModel; +} + +export interface DisplayInterruptPart { + type: 'interrupt'; + reason?: string; + message?: string; +} + +export type DisplayPart = + | DisplayTextPart + | DisplayThinkingPart + | DisplayMediaPart + | DisplayToolCallPart + | DisplayPlanPart + | DisplayCompactionPart + | DisplayErrorPart + | DisplayApprovalPart + | DisplayStatusPart + | DisplayInterruptPart; + +export interface DisplayStep { + id: string; + n: number; + parts: DisplayPart[]; +} + +export interface DisplayMessage { + id: string; + role: DisplayRole; + parts: DisplayPart[]; + steps?: DisplayStep[]; + status?: DisplayMessageStatus; + createdAt?: number; +} + +export interface DisplayState { + messages: DisplayMessage[]; + plan: DisplayPlanViewModel | null; + status: DisplayStatusViewModel | null; + pendingApprovals: DisplayApprovalPart[]; + tokenUsage: DisplayTokenUsage; + activeTokenUsage: DisplayTokenUsage; + availableCommands: DisplayAvailableCommand[]; + isStreaming: boolean; + isCompacting: boolean; +} + +export function createEmptyTokenUsage(): DisplayTokenUsage { + return { inputOther: 0, output: 0, inputCacheRead: 0, inputCacheCreation: 0 }; +} + +export function createInitialDisplayState(): DisplayState { + return { + messages: [], + plan: null, + status: null, + pendingApprovals: [], + tokenUsage: createEmptyTokenUsage(), + activeTokenUsage: createEmptyTokenUsage(), + availableCommands: [], + isStreaming: false, + isCompacting: false, + }; +} diff --git a/apps/vscode/agent-display-model/src/reducer.ts b/apps/vscode/agent-display-model/src/reducer.ts new file mode 100644 index 000000000..8a71b94e9 --- /dev/null +++ b/apps/vscode/agent-display-model/src/reducer.ts @@ -0,0 +1,454 @@ +import type { DisplayEffect } from './effects'; +import type { DisplayEvent } from './events'; +import { + createEmptyTokenUsage, + type DisplayApprovalPart, + type DisplayBlock, + type DisplayMessage, + type DisplayMessageStatus, + type DisplayPart, + type DisplayState, + type DisplayStep, + type DisplayTokenUsage, + type DisplayToolCallPart, +} from './model'; + +export interface DisplayReduction { + state: DisplayState; + effects: DisplayEffect[]; +} + +interface MutableState { + state: DisplayState; + effects: DisplayEffect[]; +} + +function cloneState(state: DisplayState): DisplayState { + return { + ...state, + messages: state.messages.map((message) => ({ + ...message, + parts: message.parts.map(clonePart), + steps: message.steps?.map(cloneStep), + })), + pendingApprovals: state.pendingApprovals.map(clonePart) as DisplayApprovalPart[], + tokenUsage: { ...state.tokenUsage }, + activeTokenUsage: { ...state.activeTokenUsage }, + availableCommands: state.availableCommands.map((command) => ({ ...command })), + plan: state.plan ? { entries: state.plan.entries.map((entry) => ({ ...entry })) } : null, + status: state.status ? { ...state.status, tokenUsage: state.status.tokenUsage ? { ...state.status.tokenUsage } : state.status.tokenUsage } : null, + }; +} + +function cloneStep(step: DisplayStep): DisplayStep { + return { ...step, parts: step.parts.map(clonePart) }; +} + +function cloneDisplayBlock(block: DisplayBlock): DisplayBlock { + if (block.type === 'todo') { + return { ...block, items: block.items.map((item) => ({ ...item })) }; + } + return { ...block }; +} + +function clonePart(part: T): T { + if (part.type === 'tool-call') { + return { + ...part, + displayBlocks: part.displayBlocks?.map(cloneDisplayBlock), + children: part.children?.map(cloneStep), + } as T; + } + if (part.type === 'plan') { + return { ...part, plan: { entries: part.plan.entries.map((entry) => ({ ...entry })) } } as T; + } + if (part.type === 'approval') { + return { + ...part, + displayBlocks: part.displayBlocks?.map(cloneDisplayBlock), + options: part.options?.map((option) => ({ ...option })), + } as T; + } + if (part.type === 'error') { + return { ...part, error: { ...part.error, details: part.error.details ? { ...part.error.details } : part.error.details } } as T; + } + if (part.type === 'media') { + return { ...part } as T; + } + return { ...part }; +} + +function nextId(state: DisplayState, prefix: string): string { + return `${prefix}-${state.messages.length + 1}`; +} + +function ensureAssistant(ctx: MutableState): DisplayMessage { + let message = ctx.state.messages.at(-1); + if (!message || message.role !== 'assistant') { + message = { id: nextId(ctx.state, 'assistant'), role: 'assistant', parts: [], steps: [], status: 'streaming' }; + ctx.state.messages.push(message); + } + return message; +} + +function ensureCurrentStep(message: DisplayMessage, n = 1): DisplayStep { + if (!message.steps) message.steps = []; + let step = message.steps.at(-1); + if (!step) { + step = { id: `step-${message.steps.length + 1}`, n, parts: [] }; + message.steps.push(step); + } + return step; +} + +function finishTextParts(parts: DisplayPart[]): void { + for (const part of parts) { + if ((part.type === 'text' || part.type === 'thinking') && part.finished !== true) { + part.finished = true; + } + if (part.type === 'tool-call' && part.children) { + for (const child of part.children) finishTextParts(child.parts); + } + } +} + +function appendContent(ctx: MutableState, kind: 'text' | 'thinking', text: string): void { + if (!text) return; + const message = ensureAssistant(ctx); + const step = ensureCurrentStep(message); + if (kind === 'text') finishTextParts(step.parts.filter((part) => part.type === 'thinking')); + const last = step.parts.at(-1); + if (last?.type === kind && last.finished !== true) { + if (kind === 'text') last.text += text; + else last.text += text; + return; + } + step.parts.push(kind === 'text' ? { type: 'text', text } : { type: 'thinking', text }); +} + +function appendMedia(ctx: MutableState, media: DisplayPart & { type: 'media' }): void { + const message = ensureAssistant(ctx); + currentStepParts(message).push({ ...media }); +} + +function addTokenUsage(target: DisplayTokenUsage, source: DisplayTokenUsage): void { + target.inputOther += source.inputOther; + target.output += source.output; + target.inputCacheRead += source.inputCacheRead; + target.inputCacheCreation += source.inputCacheCreation; +} + +function findToolPart(parts: DisplayPart[], id: string): DisplayToolCallPart | null { + for (const part of parts) { + if (part.type === 'tool-call') { + if (part.id === id) return part; + if (part.children) { + for (const child of part.children) { + const found = findToolPart(child.parts, id); + if (found) return found; + } + } + } + } + return null; +} + +function upsertToolCall(ctx: MutableState, id: string, name: string, argumentsText: string | null | undefined, status: 'pending' | 'running'): void { + const message = ensureAssistant(ctx); + const step = ensureCurrentStep(message); + const existing = findToolPart(step.parts, id) ?? (message.steps ? findToolPart(message.steps.flatMap((item) => item.parts), id) : null); + if (existing) { + existing.name = name; + if (argumentsText !== undefined) existing.argumentsText = argumentsText; + existing.status = status; + return; + } + finishTextParts(step.parts); + step.parts.push({ type: 'tool-call', id, name, argumentsText: argumentsText ?? null, status }); +} + +function toolStatus(isError: boolean | undefined, status: DisplayToolCallPart['status'] = 'success'): DisplayToolCallPart['status'] { + if (isError === true) return 'error'; + return status; +} + +function extractPaths(displayBlocks?: DisplayBlock[]): string[] { + return (displayBlocks ?? []).filter((block): block is Extract => block.type === 'diff').map((block) => block.path); +} + +function applyToolResult( + ctx: MutableState, + id: string, + isError: boolean | undefined, + output: string | undefined, + messageText: string | undefined, + displayBlocks: DisplayBlock[] | undefined, +): void { + const message = ensureAssistant(ctx); + const part = message.steps ? findToolPart(message.steps.flatMap((step) => step.parts), id) : null; + if (!part) return; + part.status = toolStatus(isError); + part.resultText = output ?? messageText; + part.displayBlocks = displayBlocks?.map(cloneDisplayBlock); + const paths = extractPaths(displayBlocks); + if (paths.length > 0) ctx.effects.push({ type: 'TrackFiles', paths }); +} + +function currentStepParts(message: DisplayMessage): DisplayPart[] { + return ensureCurrentStep(message).parts; +} + +function applyPlan(ctx: MutableState, plan: DisplayEvent & { type: 'plan.replace' }): void { + const message = ensureAssistant(ctx); + const step = ensureCurrentStep(message); + ctx.state.plan = { entries: plan.plan.entries.map((entry) => ({ ...entry })) }; + const existing = step.parts.find((part): part is Extract => part.type === 'plan'); + if (existing) existing.plan = ctx.state.plan; + else step.parts.push({ type: 'plan', plan: ctx.state.plan }); +} + +function applyStatus(ctx: MutableState, status: NonNullable): void { + ctx.state.status = { ...status, tokenUsage: status.tokenUsage ? { ...status.tokenUsage } : status.tokenUsage }; + ctx.effects.push({ type: 'UpdateStatus', status: ctx.state.status }); + if (status.tokenUsage) { + ctx.state.activeTokenUsage = { + inputOther: status.tokenUsage.inputOther, + output: status.tokenUsage.output, + inputCacheRead: status.tokenUsage.inputCacheRead, + inputCacheCreation: status.tokenUsage.inputCacheCreation, + }; + } + const message = ensureAssistant(ctx); + const step = ensureCurrentStep(message); + step.parts.push({ type: 'status', status: ctx.state.status }); +} + +function applySubagentEvent(ctx: MutableState, parentToolCallId: string, event: DisplayEvent): void { + const message = ensureAssistant(ctx); + const parent = message.steps ? findToolPart(message.steps.flatMap((step) => step.parts), parentToolCallId) : null; + if (!parent) return; + if (!parent.children) parent.children = []; + const childState: DisplayState = { + ...ctx.state, + messages: [{ id: 'subagent', role: 'assistant', parts: [], steps: parent.children }], + pendingApprovals: [], + }; + const reduction = reduceDisplayEvent(childState, event); + parent.children = reduction.state.messages[0]?.steps ?? []; + if (event.type === 'approval.request') { + const request = reduction.state.pendingApprovals[0]; + if (request) { + ctx.state.pendingApprovals = [...ctx.state.pendingApprovals.filter((item) => item.requestId !== request.requestId), request]; + } + } else if (event.type === 'approval.resolved') { + ctx.state.pendingApprovals = ctx.state.pendingApprovals.filter((item) => item.requestId !== event.requestId); + } + ctx.effects.push(...reduction.effects); +} + +function finalizeTurn(ctx: MutableState, status: DisplayMessageStatus): void { + const message = ctx.state.messages.at(-1); + if (message?.role === 'assistant') { + message.status = status; + if (message.steps) for (const step of message.steps) finishTextParts(step.parts); + } + finalizeTurnState(ctx); +} + +function finalizeTurnState(ctx: MutableState): void { + addTokenUsage(ctx.state.tokenUsage, ctx.state.activeTokenUsage); + ctx.state.activeTokenUsage = createEmptyTokenUsage(); + ctx.state.isStreaming = false; + ctx.state.isCompacting = false; + ctx.state.pendingApprovals = []; + ctx.effects.push({ type: 'ClearApprovals' }); +} + +function rollbackPreflightTurn(ctx: MutableState): void { + const lastMessage = ctx.state.messages.at(-1); + if (lastMessage?.role === 'assistant' && isEmptyAssistant(lastMessage)) { + ctx.state.messages.pop(); + } + + if (ctx.state.messages.at(-1)?.role === 'user') { + ctx.state.messages.pop(); + } + + finalizeTurnState(ctx); +} + +function isEmptyAssistant(message: DisplayMessage): boolean { + return message.parts.length === 0 && (message.steps?.every((step) => step.parts.length === 0) ?? true); +} + +export function finalizeDisplayStateForHistory(state: DisplayState): DisplayState { + const next = cloneState(state); + if (!next.isStreaming) { + return next; + } + + addTokenUsage(next.tokenUsage, next.activeTokenUsage); + next.activeTokenUsage = createEmptyTokenUsage(); + next.isStreaming = false; + next.isCompacting = false; + + for (const message of next.messages) { + if (message.role !== 'assistant') { + continue; + } + + if (message.status === 'streaming') { + message.status = 'completed'; + } + + finishTextParts(message.parts); + if (message.steps) { + for (const step of message.steps) { + finishTextParts(step.parts); + } + } + } + + return next; +} + +export function reduceDisplayEvent(state: DisplayState, event: DisplayEvent): DisplayReduction { + const ctx: MutableState = { state: cloneState(state), effects: [] }; + + switch (event.type) { + case 'conversation.reset': + ctx.state.messages = []; + ctx.state.plan = null; + ctx.state.status = null; + ctx.state.pendingApprovals = []; + ctx.state.tokenUsage = createEmptyTokenUsage(); + ctx.state.activeTokenUsage = createEmptyTokenUsage(); + ctx.state.availableCommands = []; + ctx.state.isStreaming = false; + ctx.state.isCompacting = false; + ctx.effects.push({ type: 'ClearApprovals' }); + ctx.effects.push({ type: 'ClearTrackedFiles' }); + break; + case 'turn.begin': { + const cleaned = event.userText.trim(); + if (cleaned || event.parts?.length) { + ctx.state.messages.push({ id: nextId(ctx.state, 'user'), role: 'user', parts: event.parts?.map(clonePart) ?? [{ type: 'text', text: cleaned }], status: 'completed' }); + ctx.state.messages.push({ id: nextId(ctx.state, 'assistant'), role: 'assistant', parts: [], steps: [], status: 'streaming' }); + ctx.state.isStreaming = true; + } + break; + } + case 'turn.complete': + finalizeTurn(ctx, 'completed'); + break; + case 'turn.error': + if (event.error.phase === 'preflight') { + rollbackPreflightTurn(ctx); + } else { + currentStepParts(ensureAssistant(ctx)).push({ + type: 'error', + error: { ...event.error, details: event.error.details ? { ...event.error.details } : event.error.details }, + }); + finalizeTurn(ctx, 'error'); + } + break; + case 'turn.interrupted': + currentStepParts(ensureAssistant(ctx)).push({ type: 'interrupt', reason: event.reason, message: event.message }); + finalizeTurn(ctx, 'interrupted'); + break; + case 'message.begin': { + if (event.text) ctx.state.messages.push({ id: event.id ?? nextId(ctx.state, event.role), role: event.role, parts: [{ type: 'text', text: event.text }], status: 'streaming' }); + else ctx.state.messages.push({ id: event.id ?? nextId(ctx.state, event.role), role: event.role, parts: [], steps: [], status: 'streaming' }); + break; + } + case 'step.begin': { + const message = ensureAssistant(ctx); + if (message.steps) for (const step of message.steps) finishTextParts(step.parts); + if (!message.steps) message.steps = []; + message.steps.push({ id: `step-${message.steps.length + 1}`, n: event.n, parts: [] }); + break; + } + case 'content.append': + if (event.kind === 'media') appendMedia(ctx, event.media); + else appendContent(ctx, event.kind, event.text); + break; + case 'tool.call': + upsertToolCall(ctx, event.id, event.name, event.argumentsText, event.status ?? 'running'); + break; + case 'tool.call.delta': { + const message = ensureAssistant(ctx); + const tool = message.steps ? findToolPart(message.steps.flatMap((step) => step.parts), event.id) : null; + if (!tool) { + upsertToolCall(ctx, event.id, 'tool', event.argumentsPart, 'pending'); + } else { + tool.argumentsText = `${tool.argumentsText ?? ''}${event.argumentsPart}`; + } + break; + } + case 'tool.result': + applyToolResult(ctx, event.id, event.isError, event.output, event.message, event.displayBlocks); + break; + case 'plan.replace': + applyPlan(ctx, event); + break; + case 'approval.request': { + const request = clonePart(event.request); + ctx.state.pendingApprovals = [...ctx.state.pendingApprovals.filter((item) => item.requestId !== request.requestId), request]; + currentStepParts(ensureAssistant(ctx)).push(request); + ctx.effects.push({ type: 'OpenApproval', request }); + break; + } + case 'approval.resolved': + ctx.state.pendingApprovals = ctx.state.pendingApprovals.filter((item) => item.requestId !== event.requestId); + break; + case 'approval.clear': + ctx.state.pendingApprovals = []; + ctx.effects.push({ type: 'ClearApprovals' }); + break; + case 'status.update': + applyStatus(ctx, event.status); + break; + case 'usage.add': + addTokenUsage(ctx.state.activeTokenUsage, event.usage); + break; + case 'compaction.begin': + ctx.state.isCompacting = true; + currentStepParts(ensureAssistant(ctx)).push({ + type: 'compaction', + status: 'running', + trigger: event.trigger, + instruction: event.instruction, + message: event.message, + }); + break; + case 'compaction.end': + ctx.state.isCompacting = false; + currentStepParts(ensureAssistant(ctx)).push({ + type: 'compaction', + status: event.status ?? 'completed', + trigger: event.trigger, + instruction: event.instruction, + summary: event.summary, + compactedCount: event.compactedCount, + tokensBefore: event.tokensBefore, + tokensAfter: event.tokensAfter, + message: event.message, + }); + break; + case 'step.interrupted': + currentStepParts(ensureAssistant(ctx)).push({ type: 'interrupt', reason: event.reason, message: event.message }); + finalizeTurn(ctx, 'interrupted'); + break; + case 'available_commands.update': { + const commands = event.commands.map((command) => ({ ...command })); + ctx.state.availableCommands = commands; + ctx.effects.push({ type: 'UpdateAvailableCommands', commands }); + break; + } + case 'subagent.event': + applySubagentEvent(ctx, event.parentToolCallId, event.event); + break; + } + + return { state: ctx.state, effects: ctx.effects }; +} diff --git a/apps/vscode/agent-display-model/src/tool-display.ts b/apps/vscode/agent-display-model/src/tool-display.ts new file mode 100644 index 000000000..9a87eb26c --- /dev/null +++ b/apps/vscode/agent-display-model/src/tool-display.ts @@ -0,0 +1,256 @@ +import type { DisplayBlock, DisplayTodoItem, DisplayTodoStatus, DisplayToolCallPart } from './model'; + +const TODO_KEYS = ['items', 'entries', 'todos', 'todo', 'list', 'todo_list', 'raw', 'output', 'message'] as const; +const GENERIC_SUMMARY_KEYS = ['query', 'pattern', 'regex', 'path', 'file', 'directory', 'root', 'cwd', 'command', 'cmd', 'description', 'raw'] as const; + +export type DisplayToolKind = 'shell' | 'read-file' | 'write-file' | 'replace-file' | 'glob' | 'todo' | 'task' | 'generic'; + +interface ToolCallLike { + name: string; + argumentsText?: string | null; + resultText?: string; + displayBlocks?: DisplayBlock[]; +} + +export function parseToolArguments(argumentsText?: string | null): Record { + if (!argumentsText) { + return {}; + } + + try { + const parsed = JSON.parse(argumentsText) as unknown; + return parsed && typeof parsed === 'object' ? (parsed as Record) : { raw: argumentsText }; + } catch { + return { raw: argumentsText }; + } +} + +export function getDisplayToolKind(name: string): DisplayToolKind { + switch (name) { + case 'Shell': + return 'shell'; + case 'ReadFile': + return 'read-file'; + case 'WriteFile': + return 'write-file'; + case 'StrReplaceFile': + return 'replace-file'; + case 'Glob': + return 'glob'; + case 'Task': + case 'Agent': + return 'task'; + case 'SetTodoList': + return 'todo'; + default: + return isTodoToolName(name) ? 'todo' : 'generic'; + } +} + +export function isTaskToolName(name: string): boolean { + return name === 'Task' || name === 'Agent'; +} + +export function isTodoToolName(name: string): boolean { + const normalized = name.toLowerCase().replace(/[_\s-]+/g, ''); + return normalized === 'settodolist' || normalized === 'todolist' || normalized === 'updatingtodolist' || normalized === 'updatetodolist' || normalized === 'updatetodos'; +} + +export function getToolCallSummary(name: string, argumentsText?: string | null): string { + const args = parseToolArguments(argumentsText); + + switch (name) { + case 'Shell': + return getSummaryString(args['command']) ?? 'command'; + case 'ReadFile': + case 'WriteFile': + case 'StrReplaceFile': + return pathBasename(getSummaryString(args['path'])) ?? 'file'; + case 'Glob': + return getSummaryString(args['pattern']) ?? 'pattern'; + case 'Task': + case 'Agent': + return getSummaryString(args['description']) ?? 'subagent task'; + case 'SetTodoList': + return 'Update Todos'; + default: + if (isTodoToolName(name)) { + return 'Update Todos'; + } + return getGenericToolCallSummary(args); + } +} + +export function getGenericToolCallSummary(args: Record): string { + for (const key of GENERIC_SUMMARY_KEYS) { + const value = getSummaryString(args[key]); + if (value) { + return value; + } + } + return ''; +} + +export function getRichToolDisplayBlocks(blocks?: readonly T[]): T[] { + return (blocks ?? []).filter((block) => block.type !== 'brief'); +} + +export function findTodoDisplayBlock(blocks?: readonly T[]): Extract | null { + const block = (blocks ?? []).find((candidate) => candidate.type === 'todo'); + return block ? (block as Extract) : null; +} + +export function normalizeDisplayTodoStatus(status: unknown): DisplayTodoStatus { + const normalized = typeof status === 'string' ? status.trim().toLowerCase().replace(/[\s-]+/g, '_') : status; + if (normalized === 'done' || normalized === 'completed' || normalized === 'complete' || normalized === 'finished') { + return 'done'; + } + if (normalized === 'in_progress' || normalized === 'active' || normalized === 'running') { + return 'in_progress'; + } + return 'pending'; +} + +export function displayTodoItemTitle(value: unknown): string { + if (typeof value === 'string') { + return value.trim(); + } + if (!value || typeof value !== 'object') { + return ''; + } + + const item = value as Record; + const title = item['title'] ?? item['content'] ?? item['text'] ?? item['name'] ?? item['task']; + return typeof title === 'string' ? title.trim() : ''; +} + +export function normalizeDisplayTodoItems(value: unknown): DisplayTodoItem[] { + if (!Array.isArray(value)) { + return []; + } + + return value.flatMap((entry): DisplayTodoItem[] => { + const title = displayTodoItemTitle(entry); + if (!title) { + return []; + } + + const status = entry && typeof entry === 'object' ? normalizeDisplayTodoStatus((entry as Record)['status']) : 'pending'; + return [{ title, status }]; + }); +} + +export function parseDisplayJsonValue(text: string): unknown { + try { + return JSON.parse(text); + } catch { + return null; + } +} + +export function extractDisplayJsonFromText(text: string): unknown { + const parsed = parseDisplayJsonValue(text); + if (parsed !== null) { + return parsed; + } + + const arrayMatch = text.match(/\[[\s\S]*\]/); + if (arrayMatch) { + const candidate = parseDisplayJsonValue(arrayMatch[0]); + if (candidate !== null) { + return candidate; + } + } + + const objectMatch = text.match(/\{[\s\S]*\}/); + if (objectMatch) { + const candidate = parseDisplayJsonValue(objectMatch[0]); + if (candidate !== null) { + return candidate; + } + } + + return null; +} + +export function parseDisplayTodoListText(text: string): DisplayTodoItem[] { + const items: DisplayTodoItem[] = []; + for (const line of text.split('\n')) { + const match = /^\s*(?:[-*]\s*)?\[([^\]]+)\]\s*(.+)$/.exec(line); + if (!match) { + continue; + } + + const title = match[2]?.trim(); + if (title) { + items.push({ title, status: normalizeDisplayTodoStatus(match[1]) }); + } + } + return items; +} + +export function extractDisplayTodoItems(value: unknown): DisplayTodoItem[] { + if (value === undefined || value === null) { + return []; + } + if (typeof value === 'string') { + const textItems = parseDisplayTodoListText(value); + if (textItems.length > 0) { + return textItems; + } + return extractDisplayTodoItems(extractDisplayJsonFromText(value)); + } + if (Array.isArray(value)) { + return normalizeDisplayTodoItems(value); + } + if (typeof value !== 'object') { + return []; + } + + const record = value as Record; + for (const key of TODO_KEYS) { + const items = extractDisplayTodoItems(record[key]); + if (items.length > 0) { + return items; + } + } + + const singleTitle = displayTodoItemTitle(record); + if (singleTitle) { + return [{ title: singleTitle, status: normalizeDisplayTodoStatus(record['status']) }]; + } + + return []; +} + +export function getTodoItemsForToolCall(part: ToolCallLike | DisplayToolCallPart): DisplayTodoItem[] { + const todoBlock = findTodoDisplayBlock(part.displayBlocks); + const blockItems = normalizeDisplayTodoItems(todoBlock && typeof todoBlock === 'object' ? (todoBlock as { items?: unknown }).items : undefined); + if (blockItems.length > 0) { + return blockItems; + } + + const outputItems = extractDisplayTodoItems(part.resultText); + if (outputItems.length > 0) { + return outputItems; + } + + return extractDisplayTodoItems(parseToolArguments(part.argumentsText)); +} + +function getSummaryString(value: unknown): string | null { + if (typeof value === 'string' && value.trim()) { + return value.trim(); + } + if (typeof value === 'number' || typeof value === 'boolean') { + return String(value); + } + return null; +} + +function pathBasename(path: string | null): string | null { + if (!path) { + return null; + } + return path.split('/').pop() ?? path; +} diff --git a/apps/vscode/agent-display-model/test/copy-content.test.ts b/apps/vscode/agent-display-model/test/copy-content.test.ts new file mode 100644 index 000000000..a9e596cfe --- /dev/null +++ b/apps/vscode/agent-display-model/test/copy-content.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'vitest'; + +import { getDisplayPartCopyContent, type DisplayPart } from '../src'; + +describe('getDisplayPartCopyContent', () => { + it('copies text parts', () => { + expect(getDisplayPartCopyContent({ type: 'text', text: 'hello' })).toBe('hello'); + expect(getDisplayPartCopyContent({ type: 'text', text: ' ' })).toBeNull(); + }); + + it('copies only finished thinking parts', () => { + expect(getDisplayPartCopyContent({ type: 'thinking', text: 'draft' })).toBeNull(); + expect(getDisplayPartCopyContent({ type: 'thinking', text: 'final', finished: true })).toBe('final'); + }); + + it('serializes media parts as stable placeholders', () => { + expect(getDisplayPartCopyContent({ type: 'media', kind: 'image', url: 'data:image/png;base64,abc', id: 'img-1' })).toBe('[image img-1]'); + }); + + it('serializes plan parts', () => { + const part: DisplayPart = { + type: 'plan', + plan: { + entries: [ + { content: 'Inspect codebase', status: 'completed', priority: 'high' }, + { content: 'Implement change', status: 'in_progress' }, + ], + }, + }; + + expect(getDisplayPartCopyContent(part)).toBe('- [completed] Inspect codebase (high)\n- [in_progress] Implement change'); + }); + + it('serializes tool call parts with display block summaries', () => { + const part: DisplayPart = { + type: 'tool-call', + id: 'tool-1', + name: 'Edit', + status: 'success', + argumentsText: '{"path":"a.ts"}', + resultText: 'ok', + displayBlocks: [ + { type: 'brief', text: 'Updated a.ts' }, + { type: 'diff', path: 'a.ts', oldText: 'old', newText: 'new' }, + { type: 'todo', items: [{ title: 'Review diff', status: 'done' }] }, + { type: 'command', language: 'bash', command: 'pnpm test', cwd: '/repo', danger: 'none', description: 'Run tests' }, + { type: 'file-op', operation: 'read', path: 'a.ts', detail: 'Inspect changes' }, + { type: 'file-content', path: 'a.ts', content: 'const a = 1;' }, + { type: 'url-fetch', url: 'https://example.com', method: 'POST' }, + { type: 'search', query: 'TODO', scope: 'src' }, + { type: 'invocation', kind: 'skill', name: 'review', description: 'Review diff' }, + { type: 'background-task', taskId: 'task-1', kind: 'shell', status: 'running', description: 'Run tests' }, + ], + }; + + expect(getDisplayPartCopyContent(part)).toBe( + 'Tool: Edit\nStatus: success\n\nArguments:\n{"path":"a.ts"}\n\nResult:\nok\n\nDisplay:\nUpdated a.ts\n\nDiff: a.ts\n\nTodo:\n- [done] Review diff\n\nCommand (bash): pnpm test\ncwd: /repo\nDanger: none\nRun tests\n\nread a.ts\nInspect changes\n\nFile: a.ts\nconst a = 1;\n\nPOST https://example.com\n\nSearch: TODO\nscope: src\n\nskill: review\nReview diff\n\nBackground task task-1 (shell, running): Run tests', + ); + }); + + it('does not copy non-output display parts', () => { + const parts: DisplayPart[] = [ + { + type: 'approval', + requestId: '1', + toolCallId: 'tool-1', + sender: 'agent', + action: 'run', + description: 'Run command', + }, + { type: 'compaction', status: 'completed' }, + { type: 'error', error: { code: 'ERROR', message: 'failed', phase: 'runtime' } }, + { type: 'interrupt', reason: 'cancelled' }, + { type: 'status', status: { contextUsage: 0.1 } }, + ]; + + for (const part of parts) { + expect(getDisplayPartCopyContent(part)).toBeNull(); + } + }); +}); diff --git a/apps/vscode/agent-display-model/test/display-fixtures.test.ts b/apps/vscode/agent-display-model/test/display-fixtures.test.ts new file mode 100644 index 000000000..792546184 --- /dev/null +++ b/apps/vscode/agent-display-model/test/display-fixtures.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; + +import { createInitialDisplayState, reduceDisplayEvent, type DisplayEffect, type DisplayState } from '../src'; +import { displayReducerFixtures } from './fixtures/display-reducer-fixtures'; + +function reduceFixture(events: typeof displayReducerFixtures[number]['events']) { + let state = createInitialDisplayState(); + const effects: DisplayEffect[] = []; + + for (const event of events) { + const reduction = reduceDisplayEvent(state, event); + state = reduction.state; + effects.push(...reduction.effects); + } + + return { effects, state }; +} + +describe('shared display reducer fixtures', () => { + for (const fixture of displayReducerFixtures) { + it(fixture.name, () => { + const { effects, state } = reduceFixture(fixture.events); + + expect(state).toMatchObject(fixture.expectedState); + expect(effects).toEqual(fixture.expectedEffects); + }); + } +}); diff --git a/apps/vscode/agent-display-model/test/fixtures/display-reducer-fixtures.ts b/apps/vscode/agent-display-model/test/fixtures/display-reducer-fixtures.ts new file mode 100644 index 000000000..91dfa6251 --- /dev/null +++ b/apps/vscode/agent-display-model/test/fixtures/display-reducer-fixtures.ts @@ -0,0 +1,560 @@ +import type { DisplayEffect, DisplayEvent, DisplayState } from '../../src'; + +export interface DisplayReducerFixture { + name: string; + events: DisplayEvent[]; + expectedState: DisplayState; + expectedEffects: DisplayEffect[]; +} + +const emptyTokenUsage = { + inputOther: 0, + output: 0, + inputCacheRead: 0, + inputCacheCreation: 0, +}; + +export const displayReducerFixtures = [ + { + name: 'streams assistant text and thinking into a completed turn', + events: [ + { type: 'turn.begin', userText: 'draft' }, + { type: 'step.begin', n: 1 }, + { type: 'content.append', kind: 'thinking', text: 'Inspect' }, + { type: 'content.append', kind: 'text', text: 'Answer' }, + { type: 'turn.complete' }, + ], + expectedState: { + messages: [ + { id: 'user-1', role: 'user', parts: [{ type: 'text', text: 'draft' }], status: 'completed' }, + { + id: 'assistant-2', + role: 'assistant', + parts: [], + steps: [ + { + id: 'step-1', + n: 1, + parts: [ + { type: 'thinking', text: 'Inspect', finished: true }, + { type: 'text', text: 'Answer', finished: true }, + ], + }, + ], + status: 'completed', + }, + ], + plan: null, + status: null, + pendingApprovals: [], + tokenUsage: emptyTokenUsage, + activeTokenUsage: emptyTokenUsage, + availableCommands: [], + isStreaming: false, + isCompacting: false, + }, + expectedEffects: [{ type: 'ClearApprovals' }], + }, + { + name: 'tracks tool calls, rich display blocks, and file tracking effects', + events: [ + { type: 'turn.begin', userText: 'edit' }, + { type: 'step.begin', n: 1 }, + { type: 'tool.call', id: 'tool-1', name: 'Shell', argumentsText: '{"command":"pwd"}', status: 'running' }, + { + type: 'tool.result', + id: 'tool-1', + isError: false, + output: '/repo', + message: 'pwd', + displayBlocks: [ + { type: 'brief', text: 'Current directory' }, + { type: 'diff', path: 'src/a.ts', oldText: 'old', newText: 'new' }, + ], + }, + { type: 'turn.complete' }, + ], + expectedState: { + messages: [ + { id: 'user-1', role: 'user', parts: [{ type: 'text', text: 'edit' }], status: 'completed' }, + { + id: 'assistant-2', + role: 'assistant', + parts: [], + steps: [ + { + id: 'step-1', + n: 1, + parts: [ + { + type: 'tool-call', + id: 'tool-1', + name: 'Shell', + argumentsText: '{"command":"pwd"}', + status: 'success', + resultText: '/repo', + displayBlocks: [ + { type: 'brief', text: 'Current directory' }, + { type: 'diff', path: 'src/a.ts', oldText: 'old', newText: 'new' }, + ], + }, + ], + }, + ], + status: 'completed', + }, + ], + plan: null, + status: null, + pendingApprovals: [], + tokenUsage: emptyTokenUsage, + activeTokenUsage: emptyTokenUsage, + availableCommands: [], + isStreaming: false, + isCompacting: false, + }, + expectedEffects: [ + { type: 'TrackFiles', paths: ['src/a.ts'] }, + { type: 'ClearApprovals' }, + ], + }, + { + name: 'keeps plan, status, usage, and available commands with display effects', + events: [ + { type: 'turn.begin', userText: 'status' }, + { type: 'step.begin', n: 1 }, + { + type: 'plan.replace', + plan: { + entries: [ + { content: 'Inspect', status: 'completed', priority: 'high' }, + { content: 'Implement', status: 'in_progress' }, + ], + }, + }, + { + type: 'status.update', + status: { + contextUsage: 0.5, + contextTokens: 50, + maxContextTokens: 100, + tokenUsage: { inputOther: 1, output: 2, inputCacheRead: 3, inputCacheCreation: 4 }, + messageId: 'msg-1', + }, + }, + { type: 'usage.add', usage: { inputOther: 0, output: 5, inputCacheRead: 0, inputCacheCreation: 0 } }, + { + type: 'available_commands.update', + commands: [ + { name: 'review', description: 'Review changes', group: 'code' }, + { name: 'test', description: 'Run tests' }, + ], + }, + { type: 'turn.complete' }, + ], + expectedState: { + messages: [ + { id: 'user-1', role: 'user', parts: [{ type: 'text', text: 'status' }], status: 'completed' }, + { + id: 'assistant-2', + role: 'assistant', + parts: [], + steps: [ + { + id: 'step-1', + n: 1, + parts: [ + { + type: 'plan', + plan: { + entries: [ + { content: 'Inspect', status: 'completed', priority: 'high' }, + { content: 'Implement', status: 'in_progress' }, + ], + }, + }, + { + type: 'status', + status: { + contextUsage: 0.5, + contextTokens: 50, + maxContextTokens: 100, + tokenUsage: { inputOther: 1, output: 2, inputCacheRead: 3, inputCacheCreation: 4 }, + messageId: 'msg-1', + }, + }, + ], + }, + ], + status: 'completed', + }, + ], + plan: { + entries: [ + { content: 'Inspect', status: 'completed', priority: 'high' }, + { content: 'Implement', status: 'in_progress' }, + ], + }, + status: { + contextUsage: 0.5, + contextTokens: 50, + maxContextTokens: 100, + tokenUsage: { inputOther: 1, output: 2, inputCacheRead: 3, inputCacheCreation: 4 }, + messageId: 'msg-1', + }, + pendingApprovals: [], + tokenUsage: { inputOther: 1, output: 7, inputCacheRead: 3, inputCacheCreation: 4 }, + activeTokenUsage: emptyTokenUsage, + availableCommands: [ + { name: 'review', description: 'Review changes', group: 'code' }, + { name: 'test', description: 'Run tests' }, + ], + isStreaming: false, + isCompacting: false, + }, + expectedEffects: [ + { + type: 'UpdateStatus', + status: { + contextUsage: 0.5, + contextTokens: 50, + maxContextTokens: 100, + tokenUsage: { inputOther: 1, output: 2, inputCacheRead: 3, inputCacheCreation: 4 }, + messageId: 'msg-1', + }, + }, + { + type: 'UpdateAvailableCommands', + commands: [ + { name: 'review', description: 'Review changes', group: 'code' }, + { name: 'test', description: 'Run tests' }, + ], + }, + { type: 'ClearApprovals' }, + ], + }, + { + name: 'opens and resolves approval requests without leaving stale pending approvals', + events: [ + { type: 'turn.begin', userText: 'approve' }, + { + type: 'approval.request', + request: { + type: 'approval', + requestId: 0, + toolCallId: 'tool-1', + sender: 'agent', + action: 'Edit', + description: 'Edit a file', + displayBlocks: [{ type: 'diff', path: 'a.ts', oldText: 'old', newText: 'new' }], + options: [{ optionId: 'allow', name: 'Allow', kind: 'approve' }], + }, + }, + { type: 'approval.resolved', requestId: 0 }, + { type: 'turn.complete' }, + ], + expectedState: { + messages: [ + { id: 'user-1', role: 'user', parts: [{ type: 'text', text: 'approve' }], status: 'completed' }, + { + id: 'assistant-2', + role: 'assistant', + parts: [], + steps: [ + { + id: 'step-1', + n: 1, + parts: [ + { + type: 'approval', + requestId: 0, + toolCallId: 'tool-1', + sender: 'agent', + action: 'Edit', + description: 'Edit a file', + displayBlocks: [{ type: 'diff', path: 'a.ts', oldText: 'old', newText: 'new' }], + options: [{ optionId: 'allow', name: 'Allow', kind: 'approve' }], + }, + ], + }, + ], + status: 'completed', + }, + ], + plan: null, + status: null, + pendingApprovals: [], + tokenUsage: emptyTokenUsage, + activeTokenUsage: emptyTokenUsage, + availableCommands: [], + isStreaming: false, + isCompacting: false, + }, + expectedEffects: [ + { + type: 'OpenApproval', + request: { + type: 'approval', + requestId: 0, + toolCallId: 'tool-1', + sender: 'agent', + action: 'Edit', + description: 'Edit a file', + displayBlocks: [{ type: 'diff', path: 'a.ts', oldText: 'old', newText: 'new' }], + options: [{ optionId: 'allow', name: 'Allow', kind: 'approve' }], + }, + }, + { type: 'ClearApprovals' }, + ], + }, + { + name: 'records compaction lifecycle and interruptions as terminal display parts', + events: [ + { type: 'turn.begin', userText: 'compact' }, + { type: 'step.begin', n: 1 }, + { type: 'compaction.begin' }, + { type: 'compaction.end', status: 'completed' }, + { type: 'turn.interrupted', reason: 'STOPPED_BY_USER', message: 'Stopped by user.' }, + ], + expectedState: { + messages: [ + { id: 'user-1', role: 'user', parts: [{ type: 'text', text: 'compact' }], status: 'completed' }, + { + id: 'assistant-2', + role: 'assistant', + parts: [], + steps: [ + { + id: 'step-1', + n: 1, + parts: [ + { type: 'compaction', status: 'running' }, + { type: 'compaction', status: 'completed' }, + { type: 'interrupt', reason: 'STOPPED_BY_USER', message: 'Stopped by user.' }, + ], + }, + ], + status: 'interrupted', + }, + ], + plan: null, + status: null, + pendingApprovals: [], + tokenUsage: emptyTokenUsage, + activeTokenUsage: emptyTokenUsage, + availableCommands: [], + isStreaming: false, + isCompacting: false, + }, + expectedEffects: [{ type: 'ClearApprovals' }], + }, + { + name: 'nests subagent child steps under the parent task tool and forwards child effects', + events: [ + { type: 'turn.begin', userText: 'delegate' }, + { type: 'step.begin', n: 1 }, + { type: 'tool.call', id: 'task-1', name: 'Task', argumentsText: '{"prompt":"inspect"}', status: 'running' }, + { type: 'subagent.event', parentToolCallId: 'task-1', event: { type: 'step.begin', n: 1 } }, + { type: 'subagent.event', parentToolCallId: 'task-1', event: { type: 'content.append', kind: 'text', text: 'child output' } }, + { + type: 'subagent.event', + parentToolCallId: 'task-1', + event: { + type: 'status.update', + status: { + contextUsage: 0.25, + contextTokens: 25, + maxContextTokens: 100, + tokenUsage: { inputOther: 1, output: 2, inputCacheRead: 3, inputCacheCreation: 4 }, + messageId: null, + }, + }, + }, + { + type: 'subagent.event', + parentToolCallId: 'task-1', + event: { + type: 'approval.request', + request: { + type: 'approval', + requestId: 'child-approval', + toolCallId: 'child-tool', + sender: 'agent', + action: 'Shell', + description: 'Run child command', + }, + }, + }, + { type: 'subagent.event', parentToolCallId: 'task-1', event: { type: 'approval.resolved', requestId: 'child-approval' } }, + { type: 'turn.complete' }, + ], + expectedState: { + messages: [ + { id: 'user-1', role: 'user', parts: [{ type: 'text', text: 'delegate' }], status: 'completed' }, + { + id: 'assistant-2', + role: 'assistant', + parts: [], + steps: [ + { + id: 'step-1', + n: 1, + parts: [ + { + type: 'tool-call', + id: 'task-1', + name: 'Task', + argumentsText: '{"prompt":"inspect"}', + status: 'running', + children: [ + { + id: 'step-1', + n: 1, + parts: [ + { type: 'text', text: 'child output' }, + { + type: 'status', + status: { + contextUsage: 0.25, + contextTokens: 25, + maxContextTokens: 100, + tokenUsage: { inputOther: 1, output: 2, inputCacheRead: 3, inputCacheCreation: 4 }, + messageId: null, + }, + }, + { + type: 'approval', + requestId: 'child-approval', + toolCallId: 'child-tool', + sender: 'agent', + action: 'Shell', + description: 'Run child command', + }, + ], + }, + ], + }, + ], + }, + ], + status: 'completed', + }, + ], + plan: null, + status: null, + pendingApprovals: [], + tokenUsage: emptyTokenUsage, + activeTokenUsage: emptyTokenUsage, + availableCommands: [], + isStreaming: false, + isCompacting: false, + }, + expectedEffects: [ + { + type: 'UpdateStatus', + status: { + contextUsage: 0.25, + contextTokens: 25, + maxContextTokens: 100, + tokenUsage: { inputOther: 1, output: 2, inputCacheRead: 3, inputCacheCreation: 4 }, + messageId: null, + }, + }, + { + type: 'OpenApproval', + request: { + type: 'approval', + requestId: 'child-approval', + toolCallId: 'child-tool', + sender: 'agent', + action: 'Shell', + description: 'Run child command', + }, + }, + { type: 'ClearApprovals' }, + ], + }, + { + name: 'preserves user and assistant media display parts through display events', + events: [ + { + type: 'turn.begin', + userText: 'look\n[image img-1]', + parts: [ + { type: 'text', text: 'look' }, + { type: 'media', kind: 'image', url: 'data:image/png;base64,abc', id: 'img-1' }, + ], + }, + { type: 'step.begin', n: 1 }, + { type: 'content.append', kind: 'media', media: { type: 'media', kind: 'video', url: 'data:video/mp4;base64,abc', id: 'vid-1' } }, + { type: 'turn.complete' }, + ], + expectedState: { + messages: [ + { + id: 'user-1', + role: 'user', + parts: [ + { type: 'text', text: 'look' }, + { type: 'media', kind: 'image', url: 'data:image/png;base64,abc', id: 'img-1' }, + ], + status: 'completed', + }, + { + id: 'assistant-2', + role: 'assistant', + parts: [], + steps: [ + { + id: 'step-1', + n: 1, + parts: [{ type: 'media', kind: 'video', url: 'data:video/mp4;base64,abc', id: 'vid-1' }], + }, + ], + status: 'completed', + }, + ], + plan: null, + status: null, + pendingApprovals: [], + tokenUsage: emptyTokenUsage, + activeTokenUsage: emptyTokenUsage, + availableCommands: [], + isStreaming: false, + isCompacting: false, + }, + expectedEffects: [{ type: 'ClearApprovals' }], + }, + { + name: 'rolls back empty preflight turns without leaving stale assistant placeholders', + events: [ + { type: 'turn.begin', userText: 'first' }, + { type: 'step.begin', n: 1 }, + { type: 'content.append', kind: 'text', text: 'ok' }, + { type: 'turn.complete' }, + { type: 'turn.begin', userText: 'second' }, + { type: 'turn.error', error: { code: 'HANDSHAKE_TIMEOUT', message: 'Connection timed out.', phase: 'preflight' } }, + ], + expectedState: { + messages: [ + { id: 'user-1', role: 'user', parts: [{ type: 'text', text: 'first' }], status: 'completed' }, + { + id: 'assistant-2', + role: 'assistant', + parts: [], + steps: [{ id: 'step-1', n: 1, parts: [{ type: 'text', text: 'ok', finished: true }] }], + status: 'completed', + }, + ], + plan: null, + status: null, + pendingApprovals: [], + tokenUsage: emptyTokenUsage, + activeTokenUsage: emptyTokenUsage, + availableCommands: [], + isStreaming: false, + isCompacting: false, + }, + expectedEffects: [{ type: 'ClearApprovals' }, { type: 'ClearApprovals' }], + }, +] satisfies DisplayReducerFixture[]; diff --git a/apps/vscode/agent-display-model/test/reducer.test.ts b/apps/vscode/agent-display-model/test/reducer.test.ts new file mode 100644 index 000000000..2f49fa7ca --- /dev/null +++ b/apps/vscode/agent-display-model/test/reducer.test.ts @@ -0,0 +1,376 @@ +import { describe, expect, it } from 'vitest'; + +import { createInitialDisplayState, finalizeDisplayStateForHistory, reduceDisplayEvent, type DisplayState } from '../src'; + +function reduceAll(events: Parameters[1][], state: DisplayState = createInitialDisplayState()) { + let current = state; + const effects = []; + for (const event of events) { + const reduction = reduceDisplayEvent(current, event); + current = reduction.state; + effects.push(...reduction.effects); + } + return { state: current, effects }; +} + +describe('reduceDisplayEvent', () => { + it('builds user/assistant messages and streams text', () => { + const { state } = reduceAll([ + { type: 'turn.begin', userText: ' hello ' }, + { type: 'step.begin', n: 1 }, + { type: 'content.append', kind: 'thinking', text: 'thinking' }, + { type: 'content.append', kind: 'text', text: 'answer' }, + ]); + + expect(state.messages).toHaveLength(2); + expect(state.messages[0]?.role).toBe('user'); + expect(state.isStreaming).toBe(true); + const assistant = state.messages[1]; + expect(assistant?.steps?.[0]?.parts).toEqual([ + { type: 'thinking', text: 'thinking', finished: true }, + { type: 'text', text: 'answer' }, + ]); + }); + + it('keeps user media parts and appends assistant media parts', () => { + const { state } = reduceAll([ + { + type: 'turn.begin', + userText: 'look\n[image img-1]', + parts: [ + { type: 'text', text: 'look' }, + { type: 'media', kind: 'image', url: 'data:image/png;base64,abc', id: 'img-1' }, + ], + }, + { type: 'step.begin', n: 1 }, + { type: 'content.append', kind: 'media', media: { type: 'media', kind: 'video', url: 'data:video/mp4;base64,abc' } }, + ]); + + expect(state.messages[0]?.parts).toEqual([ + { type: 'text', text: 'look' }, + { type: 'media', kind: 'image', url: 'data:image/png;base64,abc', id: 'img-1' }, + ]); + expect(state.messages[1]?.steps?.[0]?.parts).toContainEqual({ type: 'media', kind: 'video', url: 'data:video/mp4;base64,abc' }); + }); + + it('tracks tool calls and emits file tracking effects for diff results', () => { + const { state, effects } = reduceAll([ + { type: 'turn.begin', userText: 'edit file' }, + { type: 'tool.call', id: 'tool-1', name: 'Edit', argumentsText: '{"path":"a.ts"}' }, + { + type: 'tool.result', + id: 'tool-1', + output: 'ok', + displayBlocks: [{ type: 'diff', path: 'a.ts', oldText: 'old', newText: 'new' }], + }, + ]); + + const tool = state.messages[1]?.steps?.[0]?.parts[0]; + expect(tool).toMatchObject({ type: 'tool-call', id: 'tool-1', status: 'success', resultText: 'ok' }); + expect(effects).toContainEqual({ type: 'TrackFiles', paths: ['a.ts'] }); + }); + + it('updates status and token usage', () => { + const { state, effects } = reduceAll([ + { type: 'turn.begin', userText: 'status' }, + { + type: 'status.update', + status: { + contextUsage: 0.5, + contextTokens: 50, + maxContextTokens: 100, + tokenUsage: { inputOther: 1, output: 2, inputCacheRead: 3, inputCacheCreation: 4 }, + }, + }, + ]); + + expect(state.status?.contextUsage).toBe(0.5); + expect(state.activeTokenUsage.output).toBe(2); + expect(effects).toContainEqual({ type: 'UpdateStatus', status: state.status }); + }); + + it('finalizes streaming state on turn completion', () => { + const { state, effects } = reduceAll([ + { type: 'turn.begin', userText: 'complete' }, + { type: 'step.begin', n: 1 }, + { type: 'content.append', kind: 'thinking', text: 'draft' }, + { + type: 'status.update', + status: { + tokenUsage: { inputOther: 1, output: 2, inputCacheRead: 3, inputCacheCreation: 4 }, + }, + }, + { type: 'turn.complete' }, + ]); + + expect(state.isStreaming).toBe(false); + expect(state.activeTokenUsage.output).toBe(0); + expect(state.tokenUsage.output).toBe(2); + expect(state.messages[1]?.status).toBe('completed'); + expect(state.messages[1]?.steps?.[0]?.parts).toContainEqual({ type: 'thinking', text: 'draft', finished: true }); + expect(effects).toContainEqual({ type: 'ClearApprovals' }); + }); + + it('finalizes history display state without mutating the source state', () => { + const { state } = reduceAll([ + { type: 'turn.begin', userText: 'history' }, + { type: 'step.begin', n: 1 }, + { type: 'content.append', kind: 'text', text: 'draft' }, + { + type: 'status.update', + status: { + tokenUsage: { inputOther: 1, output: 2, inputCacheRead: 3, inputCacheCreation: 4 }, + }, + }, + ]); + + const finalized = finalizeDisplayStateForHistory(state); + + expect(state.isStreaming).toBe(true); + expect(state.activeTokenUsage.output).toBe(2); + expect(state.messages[1]?.steps?.[0]?.parts).toEqual([ + { type: 'text', text: 'draft' }, + { type: 'status', status: state.status }, + ]); + expect(finalized.isStreaming).toBe(false); + expect(finalized.isCompacting).toBe(false); + expect(finalized.activeTokenUsage).toEqual({ inputOther: 0, output: 0, inputCacheRead: 0, inputCacheCreation: 0 }); + expect(finalized.tokenUsage).toEqual({ inputOther: 1, output: 2, inputCacheRead: 3, inputCacheCreation: 4 }); + expect(finalized.messages[1]?.status).toBe('completed'); + expect(finalized.messages[1]?.steps?.[0]?.parts).toEqual([ + { type: 'text', text: 'draft', finished: true }, + { type: 'status', status: finalized.status }, + ]); + }); + + it('stores compaction metadata on display parts', () => { + const { state } = reduceAll([ + { type: 'turn.begin', userText: 'compact' }, + { type: 'compaction.begin', trigger: 'manual', instruction: 'summarize aggressively', message: 'starting compaction' }, + { + type: 'compaction.end', + status: 'completed', + trigger: 'manual', + summary: 'compacted 42 items', + compactedCount: 42, + tokensBefore: 12000, + tokensAfter: 3000, + message: 'compaction done', + }, + ]); + + expect(state.isCompacting).toBe(false); + expect(state.messages[1]?.steps?.[0]?.parts).toContainEqual({ + type: 'compaction', + status: 'running', + trigger: 'manual', + instruction: 'summarize aggressively', + message: 'starting compaction', + }); + expect(state.messages[1]?.steps?.[0]?.parts).toContainEqual({ + type: 'compaction', + status: 'completed', + trigger: 'manual', + summary: 'compacted 42 items', + compactedCount: 42, + tokensBefore: 12000, + tokensAfter: 3000, + message: 'compaction done', + }); + }); + + it('stores runtime errors as display error parts and finalizes the turn', () => { + const { state } = reduceAll([ + { type: 'turn.begin', userText: 'error' }, + { + type: 'turn.error', + error: { + code: 'RUNTIME_ERROR', + message: 'Runtime failed', + phase: 'runtime', + details: { category: 'protocol', context: { requestId: 'req-1' } }, + }, + }, + ]); + + expect(state.isStreaming).toBe(false); + expect(state.messages[1]?.status).toBe('error'); + expect(state.messages[1]?.steps?.[0]?.parts).toContainEqual({ + type: 'error', + error: { + code: 'RUNTIME_ERROR', + message: 'Runtime failed', + phase: 'runtime', + details: { category: 'protocol', context: { requestId: 'req-1' } }, + }, + }); + }); + + it('rolls back empty preflight turns without leaving display error parts', () => { + const { state } = reduceAll([ + { type: 'turn.begin', userText: 'first' }, + { type: 'content.append', kind: 'text', text: 'ok' }, + { type: 'turn.complete' }, + { type: 'turn.begin', userText: 'second' }, + { type: 'turn.error', error: { code: 'HANDSHAKE_TIMEOUT', message: 'Connection timed out.', phase: 'preflight' } }, + ]); + + expect(state.isStreaming).toBe(false); + expect(state.messages).toHaveLength(2); + expect(state.messages[0]?.parts).toEqual([{ type: 'text', text: 'first' }]); + expect(state.messages[1]?.status).toBe('completed'); + }); + + it('stores interruptions as display interrupt parts and finalizes the turn', () => { + const { state } = reduceAll([ + { type: 'turn.begin', userText: 'interrupt' }, + { type: 'turn.interrupted', reason: 'TURN_INTERRUPTED', message: 'Stopped by user.' }, + ]); + + expect(state.isStreaming).toBe(false); + expect(state.messages[1]?.status).toBe('interrupted'); + expect(state.messages[1]?.steps?.[0]?.parts).toContainEqual({ + type: 'interrupt', + reason: 'TURN_INTERRUPTED', + message: 'Stopped by user.', + }); + }); + + it('nests subagent steps under the parent tool call', () => { + const { state } = reduceAll([ + { type: 'turn.begin', userText: 'delegate' }, + { type: 'tool.call', id: 'task-1', name: 'Task', argumentsText: '{"prompt":"inspect"}' }, + { type: 'subagent.event', parentToolCallId: 'task-1', event: { type: 'step.begin', n: 1 } }, + { + type: 'subagent.event', + parentToolCallId: 'task-1', + event: { type: 'content.append', kind: 'text', text: 'child output' }, + }, + ]); + + const parent = state.messages[1]?.steps?.[0]?.parts[0]; + expect(parent?.type).toBe('tool-call'); + expect(parent && parent.type === 'tool-call' ? parent.children?.[0]?.parts : undefined).toEqual([ + { type: 'text', text: 'child output' }, + ]); + }); + + it('opens approval requests with display blocks and dynamic options', () => { + const { state, effects } = reduceAll([ + { type: 'turn.begin', userText: 'approve' }, + { + type: 'approval.request', + request: { + type: 'approval', + requestId: 0, + toolCallId: 'tool-1', + sender: 'agent', + action: 'Edit', + description: 'Edit a file', + displayBlocks: [{ type: 'diff', path: 'a.ts', oldText: 'old', newText: 'new' }], + options: [{ optionId: 'allow', name: 'Allow', kind: 'approve' }], + }, + }, + ]); + + expect(state.pendingApprovals).toEqual([ + { + type: 'approval', + requestId: 0, + toolCallId: 'tool-1', + sender: 'agent', + action: 'Edit', + description: 'Edit a file', + displayBlocks: [{ type: 'diff', path: 'a.ts', oldText: 'old', newText: 'new' }], + options: [{ optionId: 'allow', name: 'Allow', kind: 'approve' }], + }, + ]); + expect(effects).toContainEqual({ type: 'OpenApproval', request: state.pendingApprovals[0] }); + + const resolved = reduceDisplayEvent(state, { type: 'approval.resolved', requestId: 0 }); + expect(resolved.state.pendingApprovals).toEqual([]); + }); + + it('clones nested approval display blocks', () => { + const displayBlocks = [ + { type: 'todo' as const, items: [{ title: 'Review diff', status: 'done' as const }] }, + { type: 'command' as const, language: 'bash', command: 'pnpm test', description: 'Run tests' }, + ]; + const { state } = reduceAll([ + { + type: 'approval.request', + request: { + type: 'approval', + requestId: 0, + toolCallId: 'tool-1', + sender: 'agent', + action: 'Shell', + description: 'Run tests', + displayBlocks, + }, + }, + ]); + + (displayBlocks[0] as { items: Array<{ title: string }> }).items[0]!.title = 'Changed locally'; + + expect(state.pendingApprovals[0]?.displayBlocks).toEqual([ + { type: 'todo', items: [{ title: 'Review diff', status: 'done' }] }, + { type: 'command', language: 'bash', command: 'pnpm test', description: 'Run tests' }, + ]); + }); + + it('tracks pending approvals for subagent events', () => { + const request = { + type: 'approval' as const, + requestId: 'approval-1', + toolCallId: 'tool-child', + sender: 'agent', + action: 'Shell', + description: 'Run command', + }; + const { state } = reduceAll([ + { type: 'turn.begin', userText: 'delegate' }, + { type: 'tool.call', id: 'task-1', name: 'Task', argumentsText: '{"prompt":"inspect"}' }, + { type: 'subagent.event', parentToolCallId: 'task-1', event: { type: 'approval.request', request } }, + ]); + + expect(state.pendingApprovals).toEqual([request]); + + const resolved = reduceDisplayEvent(state, { + type: 'subagent.event', + parentToolCallId: 'task-1', + event: { type: 'approval.resolved', requestId: 'approval-1' }, + }); + + expect(resolved.state.pendingApprovals).toEqual([]); + }); + + it('stores available commands with display metadata', () => { + const { state, effects } = reduceAll([ + { + type: 'available_commands.update', + commands: [ + { name: 'review', description: 'Review changes', group: 'code' }, + { name: 'test', description: 'Run tests' }, + ], + }, + ]); + + expect(state.availableCommands).toEqual([ + { name: 'review', description: 'Review changes', group: 'code' }, + { name: 'test', description: 'Run tests' }, + ]); + expect(effects).toContainEqual({ type: 'UpdateAvailableCommands', commands: state.availableCommands }); + }); + + it('clears approvals, tracked files, and available commands on reset', () => { + const { state, effects } = reduceAll([ + { type: 'available_commands.update', commands: [{ name: 'review', description: 'Review changes' }] }, + { type: 'conversation.reset' }, + ]); + + expect(state.availableCommands).toEqual([]); + expect(effects).toContainEqual({ type: 'ClearApprovals' }); + expect(effects).toContainEqual({ type: 'ClearTrackedFiles' }); + }); +}); diff --git a/apps/vscode/agent-display-model/test/tool-display.test.ts b/apps/vscode/agent-display-model/test/tool-display.test.ts new file mode 100644 index 000000000..ad873e48f --- /dev/null +++ b/apps/vscode/agent-display-model/test/tool-display.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest'; + +import { + extractDisplayTodoItems, + getRichToolDisplayBlocks, + getTodoItemsForToolCall, + getToolCallSummary, + isTaskToolName, + isTodoToolName, + parseToolArguments, +} from '../src'; + +describe('tool display helpers', () => { + it('parses tool arguments and creates stable tool summaries', () => { + expect(parseToolArguments('{"command":"pnpm test"}')).toEqual({ command: 'pnpm test' }); + expect(parseToolArguments('not-json')).toEqual({ raw: 'not-json' }); + expect(getToolCallSummary('Shell', '{"command":"pnpm test"}')).toBe('pnpm test'); + expect(getToolCallSummary('ReadFile', '{"path":"/repo/src/file.ts"}')).toBe('file.ts'); + expect(getToolCallSummary('CustomTool', '{"query":"needle"}')).toBe('needle'); + }); + + it('classifies task and todo tool names', () => { + expect(isTaskToolName('Task')).toBe(true); + expect(isTaskToolName('Agent')).toBe(true); + expect(isTodoToolName('SetTodoList')).toBe(true); + expect(isTodoToolName('Update Todos')).toBe(true); + expect(getToolCallSummary('Update-Todos', '{}')).toBe('Update Todos'); + }); + + it('extracts todo items from markdown and wrapped JSON', () => { + expect(extractDisplayTodoItems('- [done] Done item\n- [pending] Pending item')).toEqual([ + { title: 'Done item', status: 'done' }, + { title: 'Pending item', status: 'pending' }, + ]); + + expect(extractDisplayTodoItems('Updated todos: [{"content":"Inspect","status":"active"}]')).toEqual([ + { title: 'Inspect', status: 'in_progress' }, + ]); + }); + + it('prefers explicit todo display blocks for tool calls', () => { + expect( + getTodoItemsForToolCall({ + name: 'SetTodoList', + argumentsText: '{"items":[{"title":"Args","status":"pending"}]}', + resultText: '- [done] Result', + displayBlocks: [{ type: 'todo', items: [{ title: 'Display', status: 'done' }] }], + }), + ).toEqual([{ title: 'Display', status: 'done' }]); + }); + + it('filters brief display blocks without dropping approval display semantics', () => { + expect( + getRichToolDisplayBlocks([ + { type: 'brief', text: 'summary' }, + { type: 'diff', path: 'a.ts', oldText: 'old', newText: 'new' }, + { type: 'todo', items: [{ title: 'Review', status: 'pending' }] }, + { type: 'command', language: 'bash', command: 'pnpm test' }, + { type: 'file-op', operation: 'write', path: 'a.ts' }, + ]), + ).toEqual([ + { type: 'diff', path: 'a.ts', oldText: 'old', newText: 'new' }, + { type: 'todo', items: [{ title: 'Review', status: 'pending' }] }, + { type: 'command', language: 'bash', command: 'pnpm test' }, + { type: 'file-op', operation: 'write', path: 'a.ts' }, + ]); + }); +}); diff --git a/apps/vscode/agent-display-model/tsconfig.json b/apps/vscode/agent-display-model/tsconfig.json new file mode 100644 index 000000000..1d7a4c2a9 --- /dev/null +++ b/apps/vscode/agent-display-model/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../../tsconfig.json", + "include": ["src", "test"] +} diff --git a/apps/vscode/agent-display-model/vitest.config.ts b/apps/vscode/agent-display-model/vitest.config.ts new file mode 100644 index 000000000..1b8d2a300 --- /dev/null +++ b/apps/vscode/agent-display-model/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + name: 'vscode-display-model', + include: ['test/**/*.test.ts'], + }, +}); diff --git a/apps/vscode/agent-sdk/.npmignore b/apps/vscode/agent-sdk/.npmignore new file mode 100644 index 000000000..98873c51e --- /dev/null +++ b/apps/vscode/agent-sdk/.npmignore @@ -0,0 +1,26 @@ +# Source files (only publish dist) +*.ts +!*.d.ts +tsconfig.json +tsup.config.ts + +# Tests +*.test.ts +*.spec.ts +__tests__/ +coverage/ + +# Development +.eslintrc* +.prettierrc* +.editorconfig +.gitignore + +# IDE +.vscode/ +.idea/ + +# Misc +*.log +.DS_Store +Thumbs.db diff --git a/apps/vscode/agent-sdk/README.md b/apps/vscode/agent-sdk/README.md new file mode 100644 index 000000000..a04d19339 --- /dev/null +++ b/apps/vscode/agent-sdk/README.md @@ -0,0 +1,567 @@ +# @moonshot-ai/kimi-code-vscode-agent-sdk + +TypeScript SDK for interacting with Kimi Code CLI via ACP (Agent Client Protocol) over stdio. Targets `kimi --version >= 0.14.0`. + +## Usage + +This is a private workspace package for `apps/vscode`. + +## Quick Start + +```typescript +import { createSession } from '@moonshot-ai/kimi-code-vscode-agent-sdk'; + +const session = createSession({ + workDir: '/path/to/project', + model: 'kimi-latest', + thinking: true, +}); + +const turn = session.prompt('Explain this codebase'); + +for await (const event of turn) { + if (event.type === 'ContentPart' && event.payload.type === 'text') { + process.stdout.write(event.payload.text); + } +} + +await session.close(); +``` + +## API Reference + +### Session Management + +#### `createSession(options: SessionOptions): Session` + +Creates a new session instance. + +```typescript +interface SessionOptions { + workDir: string; // Working directory path + sessionId?: string; // Optional session ID (auto-generated if omitted) + model?: string; // Model identifier + thinking?: boolean; // Enable thinking mode + mode?: AgentMode; // ACP mode: 'default' | 'plan' | 'auto' | 'yolo' + yoloMode?: boolean; // Compatibility alias for mode === 'yolo' + executable?: string; // Path to CLI executable (default: "kimi") + env?: Record; // Environment variables +} + +type AgentMode = 'default' | 'plan' | 'auto' | 'yolo'; +``` + +`mode` maps directly to ACP `session/set_config_option { configId: "mode" }`. +`yoloMode` is kept for compatibility and is equivalent to `mode: "yolo"` when +true, otherwise `mode: "default"`. + +#### `Session` + +```typescript +interface Session { + readonly sessionId: string; + readonly workDir: string; + readonly state: SessionState; // 'idle' | 'active' | 'closed' + + // Configurable properties + model: string | undefined; + thinking: boolean; + mode: AgentMode; + yoloMode: boolean; + executable: string; + env: Record; + + // Methods + prompt(content: string | ContentPart[]): Turn; + close(): Promise; + [Symbol.asyncDispose](): Promise; +} +``` + +#### `Turn` + +Represents an ongoing conversation turn. + +```typescript +interface Turn { + [Symbol.asyncIterator](): AsyncIterator; + interrupt(): Promise; + // requestId is the ACP JSON-RPC id and may be a number (incl. 0); pass it back + // unchanged. ApprovalResult is a fixed ApprovalResponse or a dynamic { optionId }. + approve(requestId: string | number, response: ApprovalResult): Promise; + readonly result: Promise; +} +``` + +#### `prompt(content, options): Promise<{ result, events }>` + +One-shot prompt helper for simple use cases. + +```typescript +import { prompt } from '@moonshot-ai/kimi-code-vscode-agent-sdk'; + +const { result, events } = await prompt('What does this code do?', { + workDir: '/path/to/project', + model: 'kimi-latest', +}); +``` + +--- + +### Stream Events + +Events emitted during a turn: + +| Event Type | Payload | Description | +|------------|---------|-------------| +| `TurnBegin` | `{ user_input }` | Turn started | +| `StepBegin` | `{ n }` | New step started | +| `StepInterrupted` | `{}` | Step was interrupted | +| `ContentPart` | `ContentPart` | Text or thinking content | +| `ToolCall` | `ToolCall` | Tool invocation started | +| `ToolCallPart` | `{ tool_call_id, arguments_part }` | Streaming tool arguments | +| `ToolResult` | `ToolResult` | Tool execution result | +| `SubagentEvent` | `SubagentEvent` | Nested agent event | +| `StatusUpdate` | `StatusUpdate` | Token usage and context info | +| `CompactionBegin` | `{}` | Context compaction started | +| `CompactionEnd` | `{}` | Context compaction finished | +| `Plan` | `Plan` | Plan / TodoList update (replaces the whole plan) | +| `ConfigOptionUpdate` | `ConfigOptionUpdate` | Server-reported config option change | +| `AvailableCommandsUpdate` | `AvailableCommandsUpdate` | Dynamic slash commands (built-in Skills, 0.14+) | +| `ApprovalRequest` | `ApprovalRequestPayload` | Tool needs approval | +| `ApprovalRequestResolved` | `ApprovalRequestResolved` | An approval request was answered | + +`AvailableCommandsUpdate` is wired through the SDK, but compatible `kimi` builds may +legitimately report an empty command list. + +### ACP → legacy event contract + +`AcpLegacyEventTranslator` is the VS Code ACP compatibility layer. It currently +maps these ACP notifications/requests and Kimi extension notifications into the +legacy `StreamEvent` shape: + +| ACP input | Legacy output | +|---|---| +| `session/update.user_message_chunk` | `TurnBegin` + `StepBegin` | +| `session/update.agent_message_chunk` | `ContentPart` text | +| `session/update.agent_thought_chunk` | `ContentPart` think | +| `session/update.tool_call` | `ToolCall` | +| `session/update.tool_call_update` | `ToolCall`, `ToolCallPart`, terminal `ToolResult` | +| `session/update.plan` | `Plan` | +| `session/update.config_option_update` | `ConfigOptionUpdate` | +| `session/update.available_commands_update` | `AvailableCommandsUpdate` | +| `session/update.usage_update` | `StatusUpdate` | +| `session/request_permission` | `ApprovalRequest` | +| `session/prompt` response `stopReason: "max_turn_requests"` | `RunResult.status: "max_steps_reached"` | +| `kimi/step_interrupted` | `StepInterrupted` | +| `kimi/compaction` `phase: "started"` | `CompactionBegin` | +| `kimi/compaction` `phase: "completed"` / `"cancelled"` / `"blocked"` | `CompactionEnd` | +| `kimi/subagent_event` | `SubagentEvent` | + +`StatusUpdate` is produced from ACP's experimental `usage_update` notification. +Because ACP 0.23 does not define standard compaction, step interruption, or +subagent activity variants, those flows use Kimi extension notifications until a +future ACP server-side wire contract supersedes them. + +Unknown ACP `session/update` variants are intentionally ignored for forward +compatibility. When `KIMI_CODE_DEBUG_ACP=1`, the protocol client logs the unknown +`sessionUpdate` name, and `tests/fixtures/acp-legacy/session-update-unknown.json` +locks the current legacy output to an empty event list. + +This package uses `@moonshot-ai/acp-adapter/protocol` only for type-only ACP wire +shapes. It still does not value-import the shared ACP adapter root entrypoint, +because that entrypoint also exports runtime server/session code. + +--- + +### Content Types + +#### `ContentPart` + +```typescript +type ContentPart = + | { type: 'text'; text: string } + | { type: 'think'; think: string; encrypted?: string | null } + | { type: 'image_url'; image_url: { url: string; id?: string | null } } + | { type: 'audio_url'; audio_url: { url: string; id?: string | null } } + | { type: 'video_url'; video_url: { url: string; id?: string | null } }; +``` + +#### `ToolCall` + +```typescript +interface ToolCall { + type: 'function'; + id: string; + function: { + name: string; + arguments: string | null; + }; + extras?: Record | null; +} +``` + +#### `ToolResult` + +```typescript +interface ToolResult { + tool_call_id: string; + return_value: { + is_error: boolean; + output: string | ContentPart[]; + message: string; + display: DisplayBlock[]; + extras?: Record | null; + }; +} +``` + +#### `DisplayBlock` + +```typescript +type DisplayBlock = + | { type: 'brief'; text: string } + | { type: 'diff'; path: string; old_text: string; new_text: string } + | { type: 'todo'; items: Array<{ title: string; status: 'pending' | 'in_progress' | 'done' }> } + | { type: string; data: Record }; // Unknown block +``` + +#### `RunResult` + +```typescript +interface RunResult { + status: 'finished' | 'cancelled' | 'max_steps_reached'; + steps?: number; +} +``` + +#### `ApprovalResponse` + +```typescript +type ApprovalResponse = 'approve' | 'approve_for_session' | 'reject'; +type ApprovalResult = ApprovalResponse | { optionId: string }; + +interface ApprovalRequestResolved { + request_id: string | number; + response: ApprovalResult; +} +``` + +--- + +### Session Storage + +#### `listSessions(workDir: string): Promise` + +Lists all sessions for a workspace. + +```typescript +interface SessionInfo { + id: string; + workDir: string; + contextFile: string; + updatedAt: number; // Timestamp in milliseconds + brief: string; // First user message preview +} +``` + +#### `deleteSession(workDir: string, sessionId: string): Promise` + +Deletes a session. Returns `true` if successful. + +#### `parseSessionEvents(workDir: string, sessionId: string): Promise` + +Parses and returns all events from a session's history. + +--- + +### Configuration + +#### `parseConfig(): KimiConfig` + +Reads and parses the CLI configuration file. + +```typescript +interface KimiConfig { + defaultModel: string | null; + defaultThinking: boolean; + models: ModelConfig[]; +} + +interface ModelConfig { + id: string; + name: string; + capabilities: string[]; // 'thinking' | 'always_thinking' | 'image_in' | 'video_in' +} +``` + +#### `saveDefaultModel(modelId: string, thinking?: boolean): void` + +Updates the default model in the configuration file. + +#### `getModelById(models: ModelConfig[], modelId: string): ModelConfig | undefined` + +Finds a model by ID. + +#### `getModelThinkingMode(model: ModelConfig): ThinkingMode` + +Returns the thinking mode for a model. + +```typescript +type ThinkingMode = 'none' | 'switch' | 'always'; +``` + +#### `isModelThinking(models: ModelConfig[], modelId: string): boolean` + +Checks if a model supports thinking. + +--- + +### MCP Server Management + +MCP servers are configured by the Kimi Code CLI. Run `kimi` in a terminal and use `/mcp-config` to add, edit, authenticate, or test servers. The VS Code extension only keeps a read-only MCP guide page and intentionally does not expose add/update/remove/auth/test bridge entry points. + +#### `MCPServerConfig` + +```typescript +interface MCPServerConfig { + name: string; + transport: 'http' | 'stdio'; + url?: string; // For HTTP transport + command?: string; // For stdio transport + args?: string[]; + env?: Record; + headers?: Record; + auth?: 'oauth'; +} +``` + +--- + +### File Paths + +#### `KimiPaths` + +Utility object for Kimi CLI file paths. + +```typescript +const KimiPaths = { + home: string; // ~/.kimi-code + config: string; // ~/.kimi-code/config.toml + mcpConfig: string; // ~/.kimi-code/mcp.json + sessionsDir(workDir: string): string; // Session storage directory + sessionDir(workDir: string, sessionId: string): string; + shadowGitDir(workDir: string, sessionId: string): string; +}; +``` + +--- + +### Error Handling + +All errors extend `AgentSdkError`: + +```typescript +abstract class AgentSdkError extends Error { + abstract readonly code: string; + abstract readonly category: ErrorCategory; + readonly cause?: unknown; + readonly context?: Record; +} + +type ErrorCategory = 'transport' | 'protocol' | 'session' | 'cli'; +``` + +#### Error Classes + +| Class | Category | Codes | +|-------|----------|-------| +| `TransportError` | transport | `SPAWN_FAILED`, `STDIN_NOT_WRITABLE`, `PROCESS_CRASHED`, `CLI_NOT_FOUND`, `ALREADY_STARTED`, `HANDSHAKE_TIMEOUT` | +| `ProtocolError` | protocol | `INVALID_JSON`, `SCHEMA_MISMATCH`, `UNKNOWN_EVENT_TYPE`, `UNKNOWN_REQUEST_TYPE`, `REQUEST_TIMEOUT`, `REQUEST_CANCELLED` | +| `SessionError` | session | `SESSION_CLOSED`, `SESSION_BUSY`, `TURN_INTERRUPTED`, `APPROVAL_FAILED` | +| `CliError` | cli | `AUTH_REQUIRED`, `INVALID_STATE`, `LLM_NOT_SET`, `LLM_NOT_SUPPORTED`, `CHAT_PROVIDER_ERROR`, `CONFIG_ERROR`, `INVALID_PARAMS`, `UNKNOWN` | + +#### Error Utilities + +```typescript +// Check if error is from this SDK +isAgentSdkError(err: unknown): err is AgentSdkError + +// Get error code (returns 'UNKNOWN' for non-SDK errors) +getErrorCode(err: unknown): string + +// Get error category (returns 'unknown' for non-SDK errors) +getErrorCategory(err: unknown): ErrorCategory | 'unknown' +``` + +--- + +### Utility Functions + +#### `extractBrief(display?: DisplayBlock[]): string` + +Extracts brief text from display blocks. + +#### `extractTextFromContentParts(parts: ContentPart[]): string` + +Extracts all text content from content parts. + +#### `formatContentOutput(output: string | ContentPart[]): string` + +Formats content output as a string. + +--- + +## Usage Examples + +### Handling Tool Approvals + +```typescript +const turn = session.prompt('Delete all .tmp files'); + +for await (const event of turn) { + if (event.type === 'ApprovalRequest') { + const { id, action, description } = event.payload; + console.log(`Approval needed: ${action} - ${description}`); + + // Approve or reject + await turn.approve(id, 'approve'); + } +} +``` + +### Streaming with Token Usage + +```typescript +for await (const event of turn) { + if (event.type === 'StatusUpdate') { + const { token_usage, context_usage } = event.payload; + if (token_usage) { + console.log(`Tokens: ${token_usage.input_other} in, ${token_usage.output} out`); + } + } +} +``` + +### Handling Subagent Events + +```typescript +for await (const event of turn) { + if (event.type === 'SubagentEvent') { + const { task_tool_call_id, event: subEvent } = event.payload; + console.log(`Subagent ${task_tool_call_id}: ${subEvent.type}`); + } +} +``` + +### Interrupting a Turn + +```typescript +const turn = session.prompt('Long running task...'); + +// Interrupt after 10 seconds +setTimeout(() => turn.interrupt(), 10000); + +for await (const event of turn) { + // Handle events until interrupted +} + +const result = await turn.result; +console.log(result.status); // 'cancelled' +``` + +### Multi-turn Conversation with Image Input + +```typescript +import { createSession, type ContentPart } from '@moonshot-ai/kimi-code-vscode-agent-sdk'; + +async function analyzeImage() { + const session = createSession({ + workDir: process.cwd(), + model: 'kimi-vision', + thinking: true, + }); + + // First turn: send image with question + const imageContent: ContentPart[] = [ + { type: 'text', text: 'What is shown in this image?' }, + { type: 'image_url', image_url: { url: 'data:image/png;base64,iVBORw0KGgo...' } }, + ]; + + const turn1 = session.prompt(imageContent); + for await (const event of turn1) { + if (event.type === 'ContentPart' && event.payload.type === 'text') { + process.stdout.write(event.payload.text); + } + } + + // Second turn: follow-up question (session maintains context) + const turn2 = session.prompt('Can you identify any potential issues?'); + for await (const event of turn2) { + if (event.type === 'ContentPart' && event.payload.type === 'text') { + process.stdout.write(event.payload.text); + } + } + + await session.close(); +} +``` + +### Resuming a Previous Session + +```typescript +import { + createSession, + listSessions, + parseSessionEvents, + type StreamEvent +} from '@moonshot-ai/kimi-code-vscode-agent-sdk'; + +async function resumeSession(workDir: string) { + // List existing sessions + const sessions = await listSessions(workDir); + + if (sessions.length === 0) { + console.log('No previous sessions found'); + return; + } + + // Get the most recent session + const latestSession = sessions[0]; + console.log(`Resuming session: ${latestSession.brief}`); + + // Load session history + const history = await parseSessionEvents(workDir, latestSession.id); + + // Display previous messages + for (const event of history) { + if (event.type === 'TurnBegin') { + const input = event.payload.user_input; + const text = typeof input === 'string' + ? input + : input.filter(p => p.type === 'text').map(p => p.text).join('\n'); + console.log(`\nUser: ${text}`); + } + if (event.type === 'ContentPart' && event.payload.type === 'text') { + process.stdout.write(event.payload.text); + } + } + + // Create session with existing ID to continue conversation + const session = createSession({ + workDir, + sessionId: latestSession.id, + model: 'kimi-latest', + }); + + // Continue the conversation + const turn = session.prompt('Please continue from where we left off'); + for await (const event of turn) { + if (event.type === 'ContentPart' && event.payload.type === 'text') { + process.stdout.write(event.payload.text); + } + } + + await session.close(); +} +``` diff --git a/apps/vscode/agent-sdk/acp-legacy-events.ts b/apps/vscode/agent-sdk/acp-legacy-events.ts new file mode 100644 index 000000000..05f946c52 --- /dev/null +++ b/apps/vscode/agent-sdk/acp-legacy-events.ts @@ -0,0 +1,857 @@ +/** + * ACP → VS Code legacy StreamEvent compatibility layer. + * + * This module intentionally translates ACP `session/update` notifications and + * `session/request_permission` requests into the legacy event shape consumed by + * the VS Code webview. It is not the long-term public agent event contract. + */ +import type { + ContentBlock as AcpProtocolContentBlock, + KimiCompactionNotification, + KimiNestedDisplayEvent, + KimiStepInterruptedNotification, + KimiSubagentNotification, + RequestPermissionRequest as AcpProtocolPermissionRequest, + SessionNotification as AcpProtocolSessionNotification, + SessionUpdate as AcpProtocolSessionUpdate, + ToolCallContent as AcpProtocolToolCallContent, +} from "@moonshot-ai/acp-adapter/protocol"; +import { cleanSystemTags } from "./utils"; +import type { AgentMode, CompactionBegin, CompactionEnd, DisplayBlock, StreamEvent, TodoBlock, WireEvent } from "./schema"; + +type UnknownAcpSessionUpdate = { sessionUpdate: string; [key: string]: unknown }; +type AcpPermissionToolCall = NonNullable; +type AcpPermissionOption = AcpProtocolPermissionRequest["options"][number]; + +export type AcpSessionNotification = Omit & { + sessionId?: string; + update?: AcpSessionUpdate; +}; + +export type AcpSessionUpdate = AcpProtocolSessionUpdate | UnknownAcpSessionUpdate; + +export type AcpContentBlock = AcpProtocolContentBlock | { type: string; [key: string]: unknown }; + +export type AcpToolCallContent = + | AcpProtocolToolCallContent + | { type: "todo"; items?: unknown[]; entries?: unknown[]; todo?: unknown; todos?: unknown } + | { type: string; [key: string]: unknown }; + +export interface AcpPlanEntry { + content?: string; + status?: string; + priority?: string; +} + +export interface AcpPermissionRequest extends Omit { + sessionId?: string; + toolCall?: Partial & { content?: AcpToolCallContent[] }; + options?: Array & { optionId?: string; name?: string; kind?: string }>; +} + +export interface AcpTranslateOptions { + /** + * ACP session/load replay emits user_message_chunk events that should be + * shown in history. Live prompts already emit a synthetic TurnBegin locally, + * so user echo notifications from the active prompt stream are suppressed. + */ + suppressUserEcho?: boolean; + /** + * Called for ACP session/update variants not explicitly mapped by this + * compatibility layer. The default behavior remains to ignore them. + */ + onUnknownSessionUpdate?: (update: AcpSessionUpdate) => void; +} + +export class AcpLegacyEventTranslator { + private toolArgs = new Map(); + private toolTitles = new Map(); + private seenTools = new Set(); + + reset(): void { + this.toolArgs.clear(); + this.toolTitles.clear(); + this.seenTools.clear(); + } + + sessionUpdateToEvents(notification: AcpSessionNotification, options: AcpTranslateOptions = {}): StreamEvent[] { + const update = notification.update; + if (!update) { + return []; + } + + switch (update.sessionUpdate) { + case "user_message_chunk": { + if (options.suppressUserEcho) { + return []; + } + const text = cleanSystemTags(contentText(("content" in update ? update.content : undefined) as AcpContentBlock | undefined)); + return text ? [{ type: "TurnBegin", payload: { user_input: text } }, { type: "StepBegin", payload: { n: 1 } }] : []; + } + case "agent_message_chunk": { + const text = contentText(("content" in update ? update.content : undefined) as AcpContentBlock | undefined); + return text ? [{ type: "ContentPart", payload: { type: "text", text } }] : []; + } + case "agent_thought_chunk": { + const think = thoughtText(("content" in update ? update.content : undefined) as AcpContentBlock | undefined); + return think.trim() ? [{ type: "ContentPart", payload: { type: "think", think } }] : []; + } + case "tool_call": + return typeof update.toolCallId === "string" ? [this.toolCallToEvent(update as Extract)] : []; + case "tool_call_update": + return typeof update.toolCallId === "string" ? this.toolCallUpdateToEvents(update as Extract) : []; + case "plan": + return [{ type: "Plan", payload: { entries: normalizePlanEntries(Array.isArray(update.entries) ? (update.entries as AcpPlanEntry[]) : undefined) } }]; + case "config_option_update": + return configOptionUpdateToEvents(Array.isArray(update.configOptions) ? update.configOptions : undefined); + case "available_commands_update": + return availableCommandsUpdateToEvents(Array.isArray(update.availableCommands) ? update.availableCommands : undefined); + case "usage_update": + return usageUpdateToEvents(update as Extract); + default: + options.onUnknownSessionUpdate?.(update); + return []; + } + } + + extensionNotificationToEvents(method: string, params: unknown): StreamEvent[] { + switch (method) { + case "kimi/step_interrupted": + return isStepInterruptedNotification(params) ? [{ type: "StepInterrupted", payload: {} }] : []; + case "kimi/compaction": + return compactionNotificationToEvents(params); + case "kimi/subagent_event": + return subagentNotificationToEvents(params); + default: + return []; + } + } + + permissionRequestToEvent(id: string | number, request: AcpPermissionRequest): StreamEvent { + const toolCall = request.toolCall; + const display = toolContentToDisplay(toolCall?.content); + const description = display.map(displayBlockSummary).filter(Boolean).join("\n") || toolCall?.title || "Permission requested"; + const options = + request.options?.map((o) => ({ + optionId: typeof o.optionId === "string" ? o.optionId : "", + name: typeof o.name === "string" ? o.name : o.optionId || "Option", + kind: typeof o.kind === "string" ? o.kind : undefined, + })) ?? []; + + return { + type: "ApprovalRequest", + payload: { + id, + tool_call_id: toolCall?.toolCallId ?? String(id), + sender: toolCall?.title ?? "Kimi", + action: permissionAction(request), + description, + display, + options: options.length > 0 ? options : undefined, + }, + }; + } + + private toolCallToEvent(update: Extract): StreamEvent { + const toolCallId = update.toolCallId; + const args = toolInputText(update.content, update.rawInput); + this.seenTools.add(toolCallId); + this.toolArgs.set(toolCallId, args); + this.toolTitles.set(toolCallId, update.title || "tool"); + return { + type: "ToolCall", + payload: { + type: "function", + id: toolCallId, + function: { + name: update.title || "tool", + arguments: args || null, + }, + extras: { + kind: update.kind, + status: update.status, + }, + }, + }; + } + + private toolCallUpdateToEvents(update: Extract): StreamEvent[] { + const events: StreamEvent[] = []; + const toolCallId = update.toolCallId; + if (!this.seenTools.has(toolCallId)) { + events.push( + this.toolCallToEvent({ + sessionUpdate: "tool_call", + toolCallId, + title: update.title || "tool", + status: update.status ?? undefined, + content: update.content ?? undefined, + rawInput: update.rawInput, + }), + ); + } + + if (update.title && update.title !== this.toolTitles.get(toolCallId)) { + this.toolTitles.set(toolCallId, update.title); + events.push({ + type: "ToolCall", + payload: { + type: "function", + id: toolCallId, + function: { + name: update.title, + arguments: this.toolArgs.get(toolCallId) || null, + }, + extras: { + status: update.status, + }, + }, + }); + } + + const status = update.status; + if (status === "completed" || status === "failed") { + events.push({ + type: "ToolResult", + payload: { + tool_call_id: toolCallId, + return_value: { + is_error: status === "failed", + output: toolOutputText(update.content ?? undefined, update.rawOutput), + message: update.title || "", + display: toolResultDisplay(update.content ?? undefined, update.rawOutput), + extras: { status }, + }, + }, + }); + return events; + } + + const nextArgs = toolInputText(update.content ?? undefined, update.rawInput); + if (!nextArgs) { + return events; + } + const previous = this.toolArgs.get(toolCallId) ?? ""; + const argumentsPart = nextArgs.startsWith(previous) ? nextArgs.slice(previous.length) : nextArgs; + this.toolArgs.set(toolCallId, nextArgs); + if (argumentsPart) { + events.push({ type: "ToolCallPart", payload: { tool_call_id: toolCallId, arguments_part: argumentsPart } }); + } + return events; + } +} + +function configOptionUpdateToEvents(configOptions: unknown[] | undefined): StreamEvent[] { + if (!Array.isArray(configOptions)) { + return []; + } + return [ + { + type: "ConfigOptionUpdate", + payload: { configOptions: configOptions.filter((option): option is Record => option !== null && typeof option === "object" && !Array.isArray(option)) }, + }, + ]; +} + +function normalizeSlashCommandName(name: unknown): string { + if (typeof name !== "string") { + return ""; + } + return name.startsWith("/") ? name.slice(1) : name; +} + +function availableCommandsUpdateToEvents(availableCommands: unknown[] | undefined): StreamEvent[] { + if (!Array.isArray(availableCommands)) { + return []; + } + return [ + { + type: "AvailableCommandsUpdate", + payload: { + availableCommands: availableCommands + .filter((cmd): cmd is Record => cmd !== null && typeof cmd === "object" && !Array.isArray(cmd)) + .map((cmd) => ({ + name: normalizeSlashCommandName(cmd.name), + description: typeof cmd.description === "string" ? cmd.description : "", + group: typeof cmd.group === "string" ? cmd.group : undefined, + })) + .filter((cmd) => cmd.name.length > 0), + }, + }, + ]; +} + +function usageUpdateToEvents(update: Extract): StreamEvent[] { + const used = readFiniteNumber(update.used); + const size = readFiniteNumber(update.size); + const computedContextUsage = used !== undefined && size !== undefined && size > 0 ? used / size : undefined; + const meta = update._meta && typeof update._meta === "object" ? (update._meta as Record) : undefined; + const contextUsageMeta = readFiniteNumber(meta?.contextUsage); + + return [ + { + type: "StatusUpdate", + payload: { + context_usage: contextUsageMeta ?? computedContextUsage ?? null, + context_tokens: used ?? null, + max_context_tokens: size ?? null, + token_usage: tokenUsageFromMeta(meta?.currentTurn) ?? null, + message_id: null, + }, + }, + ]; +} + +function compactionDetails(notification: Partial | undefined): CompactionBegin { + const details: CompactionBegin = {}; + if (notification?.trigger) details.trigger = notification.trigger; + if (notification?.instruction) details.instruction = notification.instruction; + if (notification?.message) details.message = notification.message; + return details; +} + +function compactionNotificationToEvents(params: unknown): StreamEvent[] { + const notification = asRecord(params) as Partial | undefined; + const phase = notification?.phase; + const details = compactionDetails(notification); + + if (phase === "started") { + return [{ type: "CompactionBegin", payload: details }]; + } + if (phase === "completed" || phase === "cancelled" || phase === "blocked") { + const payload: CompactionEnd = { ...details, status: phase }; + if (notification?.result?.summary) payload.summary = notification.result.summary; + if (typeof notification?.result?.compactedCount === "number") payload.compactedCount = notification.result.compactedCount; + if (typeof notification?.result?.tokensBefore === "number") payload.tokensBefore = notification.result.tokensBefore; + if (typeof notification?.result?.tokensAfter === "number") payload.tokensAfter = notification.result.tokensAfter; + return [{ type: "CompactionEnd", payload }]; + } + return []; +} + +function subagentNotificationToEvents(params: unknown): StreamEvent[] { + const notification = asRecord(params) as Partial | undefined; + const parentToolCallId = notification?.parentToolCallId; + const phase = notification?.phase; + const subagentId = notification?.subagentId; + if (!notification || typeof parentToolCallId !== "string" || typeof phase !== "string" || typeof subagentId !== "string") { + return []; + } + + const nestedEvent = nestedDisplayEvent(params); + const event: WireEvent | null = + nestedEvent ?? + (phase === "started" + ? ({ type: "StepBegin", payload: { n: 1 } } satisfies WireEvent) + : phase === "completed" && typeof notification.resultSummary === "string" + ? ({ type: "ContentPart", payload: { type: "text", text: notification.resultSummary } } satisfies WireEvent) + : phase === "failed" && typeof notification.error === "string" + ? ({ type: "ContentPart", payload: { type: "text", text: notification.error } } satisfies WireEvent) + : phase === "suspended" && typeof notification.reason === "string" + ? ({ type: "ContentPart", payload: { type: "text", text: `Subagent suspended: ${notification.reason}` } } satisfies WireEvent) + : null); + + return event ? [{ type: "SubagentEvent", payload: { task_tool_call_id: parentToolCallId, event } }] : []; +} + +function nestedDisplayEvent(params: unknown): WireEvent | null { + const notification = asRecord(params); + const event = asRecord(notification?.event) as KimiNestedDisplayEvent | undefined; + if (!event || typeof event.type !== "string") { + return null; + } + + switch (event.type) { + case "StepBegin": { + const payload = asRecord(event.payload); + return typeof payload?.n === "number" ? { type: "StepBegin", payload: { n: payload.n } } : null; + } + case "ContentPart": { + const payload = asRecord(event.payload); + if (payload?.type === "text" && typeof payload.text === "string") { + return { type: "ContentPart", payload: { type: "text", text: payload.text } }; + } + if (payload?.type === "think" && typeof payload.think === "string") { + return { type: "ContentPart", payload: { type: "think", think: payload.think } }; + } + return null; + } + case "ToolCall": { + const payload = asRecord(event.payload); + const fn = asRecord(payload?.function); + return typeof payload?.type === "string" && typeof payload.id === "string" && typeof fn?.name === "string" + ? { + type: "ToolCall", + payload: { + type: "function", + id: payload.id, + function: { + name: fn.name, + arguments: typeof fn.arguments === "string" || fn.arguments === null ? fn.arguments : null, + }, + extras: asRecord(payload.extras) ?? null, + }, + } + : null; + } + case "ToolCallPart": { + const payload = asRecord(event.payload); + return typeof payload?.tool_call_id === "string" + ? { + type: "ToolCallPart", + payload: { + tool_call_id: payload.tool_call_id, + arguments_part: typeof payload.arguments_part === "string" || payload.arguments_part === null ? payload.arguments_part : undefined, + }, + } + : null; + } + case "ToolResult": { + const payload = asRecord(event.payload); + const returnValue = asRecord(payload?.return_value); + return typeof payload?.tool_call_id === "string" && returnValue + ? { + type: "ToolResult", + payload: { + tool_call_id: payload.tool_call_id, + return_value: { + is_error: returnValue.is_error === true, + output: stringify(returnValue.output), + message: typeof returnValue.message === "string" ? returnValue.message : "", + display: Array.isArray(returnValue.display) ? (returnValue.display as DisplayBlock[]) : [], + extras: asRecord(returnValue.extras) ?? null, + }, + }, + } + : null; + } + case "StatusUpdate": { + const payload = asRecord(event.payload); + const tokenUsage = tokenUsageFromMeta(payload?.token_usage); + return { + type: "StatusUpdate", + payload: { + context_usage: readFiniteNumber(payload?.context_usage) ?? null, + context_tokens: readFiniteNumber(payload?.context_tokens) ?? null, + max_context_tokens: readFiniteNumber(payload?.max_context_tokens) ?? null, + token_usage: tokenUsage ?? null, + message_id: typeof payload?.message_id === "string" || payload?.message_id === null ? payload.message_id : null, + }, + }; + } + default: + return null; + } +} + +function isStepInterruptedNotification(params: unknown): params is KimiStepInterruptedNotification { + const notification = asRecord(params); + return ( + typeof notification?.turnId === "number" && + typeof notification.step === "number" && + typeof notification.reason === "string" + ); +} + +function asRecord(value: unknown): Record | undefined { + return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : undefined; +} + +function tokenUsageFromMeta(value: unknown) { + if (!value || typeof value !== "object") { + return undefined; + } + + const record = value as Record; + const inputOther = readFiniteNumber(record.inputOther) ?? readFiniteNumber(record.input_other); + const output = readFiniteNumber(record.output); + const inputCacheRead = readFiniteNumber(record.inputCacheRead) ?? readFiniteNumber(record.input_cache_read); + const inputCacheCreation = readFiniteNumber(record.inputCacheCreation) ?? readFiniteNumber(record.input_cache_creation); + + if (inputOther === undefined && output === undefined && inputCacheRead === undefined && inputCacheCreation === undefined) { + return undefined; + } + + return { + input_other: inputOther ?? 0, + output: output ?? 0, + input_cache_read: inputCacheRead ?? 0, + input_cache_creation: inputCacheCreation ?? 0, + }; +} + +function readFiniteNumber(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +function contentText(content: AcpContentBlock | undefined): string { + return content?.type === "text" && typeof content.text === "string" ? content.text : ""; +} + +function thoughtText(content: AcpContentBlock | undefined): string { + const text = contentText(content); + if (text) { + return text; + } + if (!content || typeof content !== "object") { + return ""; + } + + const record = content as Record; + for (const key of ["text", "thought", "thinking", "reasoning"] as const) { + if (typeof record[key] === "string") { + return record[key]; + } + } + return ""; +} + +function toolInputText(content: AcpToolCallContent[] | undefined, rawInput: unknown): string { + const text = toolContentText(content); + if (text) { + return text; + } + return stringify(rawInput); +} + +function toolOutputText(content: AcpToolCallContent[] | undefined, rawOutput: unknown): string { + const text = toolContentText(content); + if (text) { + return text; + } + return stringify(rawOutput); +} + +function toolContentText(content: AcpToolCallContent[] | undefined): string { + if (!content) { + return ""; + } + return content + .map((entry) => (entry.type === "content" ? contentText((entry as { content?: AcpContentBlock }).content) : "")) + .filter(Boolean) + .join("\n"); +} + +function displayBlockFromAcpToolContent(entry: AcpToolCallContent): DisplayBlock | null { + const record = entry as Record; + const path = typeof record.path === "string" ? record.path : undefined; + const description = typeof record.description === "string" ? record.description : undefined; + + if (entry.type === "diff" && path) { + return { + type: "diff", + path, + old_text: typeof record.oldText === "string" ? record.oldText : typeof record.old_text === "string" ? record.old_text : "", + new_text: typeof record.newText === "string" ? record.newText : typeof record.new_text === "string" ? record.new_text : "", + }; + } + if (entry.type === "command" && typeof record.command === "string") { + return { + type: "command", + language: typeof record.language === "string" ? record.language : "bash", + command: record.command, + ...(typeof record.cwd === "string" ? { cwd: record.cwd } : {}), + ...(description ? { description } : {}), + ...(typeof record.danger === "string" ? { danger: record.danger } : {}), + }; + } + if (entry.type === "file-op" && path && isFileOperation(record.operation)) { + const detail = typeof record.detail === "string" ? record.detail : description; + return { type: "file-op", operation: record.operation, path, ...(detail ? { detail } : {}) }; + } + if (entry.type === "file-content" && path && typeof record.content === "string") { + return { type: "file-content", path, content: record.content, ...(typeof record.language === "string" ? { language: record.language } : {}) }; + } + if (entry.type === "url-fetch" && typeof record.url === "string") { + return { type: "url-fetch", url: record.url, ...(typeof record.method === "string" ? { method: record.method } : {}) }; + } + if (entry.type === "search" && typeof record.query === "string") { + return { type: "search", query: record.query, ...(typeof record.scope === "string" ? { scope: record.scope } : {}) }; + } + if (entry.type === "invocation" && isInvocationKind(record.kind) && typeof record.name === "string") { + return { type: "invocation", kind: record.kind, name: record.name, ...(description ? { description } : {}) }; + } + const taskId = typeof record.task_id === "string" ? record.task_id : typeof record.taskId === "string" ? record.taskId : undefined; + if (entry.type === "background-task" && taskId) { + return { + type: "background-task", + task_id: taskId, + kind: typeof record.kind === "string" ? record.kind : "background", + status: typeof record.status === "string" ? record.status : "unknown", + ...(description ? { description } : {}), + }; + } + return null; +} + +function isFileOperation(value: unknown): value is "read" | "write" | "edit" | "glob" | "grep" { + return value === "read" || value === "write" || value === "edit" || value === "glob" || value === "grep"; +} + +function isInvocationKind(value: unknown): value is "agent" | "skill" { + return value === "agent" || value === "skill"; +} + +function toolContentToDisplay(content: AcpToolCallContent[] | undefined): DisplayBlock[] { + if (!content) { + return []; + } + const blocks: DisplayBlock[] = []; + for (const entry of content) { + const displayBlock = displayBlockFromAcpToolContent(entry); + if (displayBlock) { + blocks.push(displayBlock); + continue; + } + if (entry.type === "content") { + const inner = (entry as { content?: AcpContentBlock }).content; + if (inner?.type === "todo") { + const items = todoItemsFromAcpTodoContent(inner as unknown as AcpToolCallContent); + if (items.length > 0) { + blocks.push({ type: "todo", items }); + } + } else { + const text = contentText(inner); + if (text) { + const todoItems = parseTodoListText(text); + if (todoItems) { + blocks.push({ type: "todo", items: todoItems }); + } else { + blocks.push({ type: "brief", text }); + } + } + } + } else if (entry.type === "todo") { + const items = todoItemsFromAcpTodoContent(entry); + if (items.length > 0) { + blocks.push({ type: "todo", items }); + } + } + } + return blocks; +} + +function toolResultDisplay(content: AcpToolCallContent[] | undefined, rawOutput: unknown): DisplayBlock[] { + const blocks = toolContentToDisplay(content); + if (blocks.some((block) => block.type === "todo")) { + return blocks; + } + + const items = todoItemsFromUnknown(rawOutput); + if (items.length > 0) { + return [...blocks, { type: "todo", items }]; + } + + return blocks; +} + +type TodoStatus = TodoBlock["items"][number]["status"]; + +function normalizeTodoItemStatus(status: unknown): TodoStatus { + const normalized = typeof status === "string" ? status.trim().toLowerCase().replace(/[\s-]+/g, "_") : status; + if (normalized === "done" || normalized === "completed" || normalized === "complete" || normalized === "finished") { + return "done"; + } + if (normalized === "in_progress" || normalized === "active" || normalized === "running") { + return "in_progress"; + } + return "pending"; +} + +function todoItemTitle(value: unknown): string { + if (typeof value === "string") { + return value.trim(); + } + if (!value || typeof value !== "object") { + return ""; + } + const item = value as Record; + const title = item.title ?? item.content ?? item.text ?? item.name ?? item.task; + return typeof title === "string" ? title.trim() : ""; +} + +function todoItemsFromArray(value: unknown): TodoBlock["items"] { + if (!Array.isArray(value)) { + return []; + } + + return value + .map((entry) => { + const title = todoItemTitle(entry); + if (!title) { + return null; + } + const status = entry && typeof entry === "object" ? normalizeTodoItemStatus((entry as Record).status) : "pending"; + return { title, status }; + }) + .filter((entry): entry is TodoBlock["items"][number] => entry !== null); +} + +function parseTodoListText(text: string): TodoBlock["items"] | null { + const items: TodoBlock["items"] = []; + for (const line of text.split("\n")) { + const match = /^\s*(?:[-*]\s*)?\[([^\]]+)\]\s*(.+)$/.exec(line); + if (match) { + const status = match[1].trim(); + const title = match[2].trim(); + if (title) { + items.push({ title, status: normalizeTodoItemStatus(status) }); + } + } + } + return items.length > 0 ? items : null; +} + +function parseJsonValue(value: string): unknown { + try { + return JSON.parse(value); + } catch { + return null; + } +} + +function todoItemsFromUnknown(value: unknown): TodoBlock["items"] { + if (value === undefined || value === null) { + return []; + } + if (typeof value === "string") { + const parsedText = parseTodoListText(value); + if (parsedText) { + return parsedText; + } + const parsedJson = parseJsonValue(value); + return parsedJson === null ? [] : todoItemsFromUnknown(parsedJson); + } + if (Array.isArray(value)) { + const text = value + .map((entry) => { + if (typeof entry === "string") { + return entry; + } + if (entry && typeof entry === "object" && typeof (entry as Record).text === "string") { + return (entry as Record).text; + } + return ""; + }) + .filter(Boolean) + .join("\n"); + const textItems = parseTodoListText(text); + return textItems ?? todoItemsFromArray(value); + } + if (typeof value !== "object") { + return []; + } + + const record = value as Record; + for (const key of ["items", "entries", "todos", "todo", "list", "todo_list"] as const) { + const items = todoItemsFromUnknown(record[key]); + if (items.length > 0) { + return items; + } + } + + for (const key of ["text", "content", "output", "message", "raw"] as const) { + if (typeof record[key] === "string") { + const items = parseTodoListText(record[key]); + if (items) { + return items; + } + } + } + + const title = todoItemTitle(record); + return title ? [{ title, status: normalizeTodoItemStatus(record.status) }] : []; +} + +function todoItemsFromAcpTodoContent(entry: AcpToolCallContent): TodoBlock["items"] { + const record = entry as Record; + const candidates = [record.items, record.entries, record.todos, record.todo]; + for (const candidate of candidates) { + const items = todoItemsFromArray(candidate); + if (items.length > 0) { + return items; + } + } + return []; +} + +function normalizePlanEntries(entries: AcpPlanEntry[] | undefined): Array<{ content: string; status: "pending" | "in_progress" | "completed"; priority?: "low" | "medium" | "high" }> { + if (!entries) { + return []; + } + + return entries + .map((entry) => { + const content = typeof entry.content === "string" ? entry.content : ""; + const status: "pending" | "in_progress" | "completed" = entry.status === "in_progress" || entry.status === "completed" ? entry.status : "pending"; + const priority: "low" | "medium" | "high" | undefined = entry.priority === "low" || entry.priority === "medium" || entry.priority === "high" ? entry.priority : undefined; + return { content, status, priority }; + }) + .filter((entry) => entry.content.length > 0); +} + +function displayBlockSummary(block: DisplayBlock): string { + const record = block as Record; + switch (block.type) { + case "brief": + return typeof record.text === "string" ? record.text : ""; + case "diff": + return `Modify ${typeof record.path === "string" ? record.path : ""}`; + case "todo": + return Array.isArray(record.items) + ? record.items + .map((item) => (item && typeof item === "object" && typeof (item as { title?: unknown }).title === "string" ? (item as { title: string }).title : "")) + .filter(Boolean) + .join("\n") + : ""; + case "command": + return typeof record.description === "string" ? record.description : typeof record.command === "string" ? record.command : ""; + case "file-op": { + const detail = typeof record.detail === "string" ? record.detail : typeof record.description === "string" ? record.description : ""; + return `${typeof record.operation === "string" ? record.operation : "file"} ${typeof record.path === "string" ? record.path : ""}${detail ? `\n${detail}` : ""}`; + } + case "file-content": + return `View ${typeof record.path === "string" ? record.path : ""}`; + case "url-fetch": + return `${typeof record.method === "string" ? record.method : "GET"} ${typeof record.url === "string" ? record.url : ""}`; + case "search": + return `Search ${typeof record.query === "string" ? record.query : ""}${typeof record.scope === "string" ? ` in ${record.scope}` : ""}`; + case "invocation": + return `${typeof record.kind === "string" ? record.kind : "invocation"} ${typeof record.name === "string" ? record.name : ""}${typeof record.description === "string" ? `\n${record.description}` : ""}`; + case "background-task": { + const taskId = typeof record.task_id === "string" ? record.task_id : typeof record.taskId === "string" ? record.taskId : ""; + const kind = typeof record.kind === "string" ? record.kind : "background"; + const status = typeof record.status === "string" ? record.status : "unknown"; + const description = typeof record.description === "string" ? `: ${record.description}` : ""; + return `Background task ${taskId} (${kind}, ${status})${description}`; + } + default: + return ""; + } +} + +function permissionAction(request: AcpPermissionRequest): string { + const kinds = request.options?.map((o) => o.kind).filter(Boolean).join(", "); + return kinds || "request permission"; +} + +function stringify(value: unknown): string { + if (value === undefined || value === null) { + return ""; + } + if (typeof value === "string") { + return value; + } + try { + return JSON.stringify(value); + } catch { + return String(value); + } +} + +export function normalizeAcpMode(options: { mode?: AgentMode; yoloMode?: boolean }): AgentMode { + if (options.mode === "default" || options.mode === "plan" || options.mode === "auto" || options.mode === "yolo") { + return options.mode; + } + return options.yoloMode ? "yolo" : "default"; +} diff --git a/apps/vscode/agent-sdk/config.ts b/apps/vscode/agent-sdk/config.ts new file mode 100644 index 000000000..ace74352f --- /dev/null +++ b/apps/vscode/agent-sdk/config.ts @@ -0,0 +1,172 @@ +import * as fs from "node:fs"; +import * as toml from "toml"; +import { z } from "zod/v3"; +import { KimiPaths } from "./paths"; +import type { KimiConfig, ModelConfig } from "./schema"; + +// ============================================================================ +// Config Schema +// ============================================================================ + +const LLMProviderSchema = z.object({ + type: z.string(), + base_url: z.string().optional(), + api_key: z.string().optional(), + env: z.record(z.string()).optional(), + custom_headers: z.record(z.string()).optional(), +}).passthrough(); + +const LLMModelSchema = z.object({ + provider: z.string(), + model: z.string(), + max_context_size: z.number().int().positive(), + capabilities: z.array(z.string()).optional(), + display_name: z.string().optional(), +}).passthrough(); + +const LoopControlSchema = z.object({ + max_steps_per_turn: z.number().int().min(1).default(100), + max_retries_per_step: z.number().int().min(1).default(3), + max_ralph_iterations: z.number().int().min(-1).default(0), +}); + +const MoonshotSearchConfigSchema = z.object({ + base_url: z.string(), + api_key: z.string(), + custom_headers: z.record(z.string()).optional(), +}); + +const MoonshotFetchConfigSchema = z.object({ + base_url: z.string(), + api_key: z.string(), + custom_headers: z.record(z.string()).optional(), +}); + +const ServicesSchema = z.object({ + moonshot_search: MoonshotSearchConfigSchema.optional(), + moonshot_fetch: MoonshotFetchConfigSchema.optional(), +}); + +const MCPClientConfigSchema = z.object({ + tool_call_timeout_ms: z.number().int().positive().default(60000), +}); + +const MCPConfigSchema = z.object({ + client: MCPClientConfigSchema.default({}), +}); + +const ConfigSchema = z.object({ + default_model: z.string().default(""), + default_thinking: z.union([z.boolean(), z.enum(["on", "off"])]).default(false), + models: z.record(LLMModelSchema).default({}), + providers: z.record(LLMProviderSchema).default({}), + loop_control: LoopControlSchema.partial().default({}), + services: ServicesSchema.default({}), + mcp: MCPConfigSchema.partial().default({}), +}).passthrough(); + +type Config = z.infer; + +// Config Parsing +export function parseConfig(): KimiConfig { + if (!fs.existsSync(KimiPaths.config)) { + return { defaultModel: null, defaultThinking: false, models: [] }; + } + + try { + const raw = toml.parse(fs.readFileSync(KimiPaths.config, "utf-8")); + const config = ConfigSchema.parse(raw); + return toKimiConfig(config); + } catch (err) { + console.warn("[config] Failed to parse config.toml:", err); + return { defaultModel: null, defaultThinking: false, models: [] }; + } +} + +function toKimiConfig(config: Config): KimiConfig { + const models: ModelConfig[] = Object.entries(config.models).map(([id, model]) => ({ + id, + name: model.display_name || id, + capabilities: model.capabilities ?? [], + })); + + models.sort((a, b) => a.name.localeCompare(b.name)); + + return { + defaultModel: config.default_model || null, + defaultThinking: config.default_thinking === true || config.default_thinking === "on", + models, + }; +} + +// Config Saving +// This is deliberately simple and only handles the default_model setting. +// Otherwise the toml lib will change the format / default values. +export function saveDefaultModel(modelId: string, thinking?: boolean): void { + const configPath = KimiPaths.config; + + if (!fs.existsSync(configPath)) { + let content = `default_model = "${modelId}"\n`; + if (thinking !== undefined) { + content += `default_thinking = ${thinking ? "true" : "false"}\n`; + } + fs.writeFileSync(configPath, content, "utf-8"); + return; + } + + let content = fs.readFileSync(configPath, "utf-8"); + + // Update default_model + const modelRegex = /^default_model\s*=\s*"[^"]*"/m; + + if (modelRegex.test(content)) { + content = content.replace(modelRegex, `default_model = "${modelId}"`); + } else { + content = `default_model = "${modelId}"\n` + content; + } + + // Update default_thinking if provided. + // The kernel only accepts a BOOLEAN (`default_thinking: z.boolean()`) and the + // CLI writes `default_thinking = true`. Match any existing assignment — + // boolean OR a legacy quoted "on"/"off" — and replace it in place. Matching + // only the quoted form would miss the boolean line and append a SECOND + // `default_thinking`, producing invalid TOML (redefinition) that breaks the CLI. + if (thinking !== undefined) { + const thinkingValue = thinking ? "true" : "false"; + const thinkingRegex = /^default_thinking\s*=\s*.+$/m; + if (thinkingRegex.test(content)) { + content = content.replace(thinkingRegex, `default_thinking = ${thinkingValue}`); + } else { + // Insert after default_model + content = content.replace(/^(default_model\s*=\s*"[^"]*")/m, `$1\ndefault_thinking = ${thinkingValue}`); + } + } + + fs.writeFileSync(configPath, content, "utf-8"); +} + +// Model Utilities +export function getModelById(models: ModelConfig[], modelId: string): ModelConfig | undefined { + return models.find((m) => m.id === modelId); +} + +export type ThinkingMode = "none" | "switch" | "always"; + +export function getModelThinkingMode(model: ModelConfig): ThinkingMode { + if (model.capabilities.includes("always_thinking")) { + return "always"; + } + if (model.capabilities.includes("thinking")) { + return "switch"; + } + return "none"; +} + +export function isModelThinking(models: ModelConfig[], modelId: string): boolean { + const model = getModelById(models, modelId); + if (!model) { + return false; + } + const mode = getModelThinkingMode(model); + return mode === "always" || mode === "switch"; +} diff --git a/apps/vscode/agent-sdk/errors.ts b/apps/vscode/agent-sdk/errors.ts new file mode 100644 index 000000000..3d81c9f4d --- /dev/null +++ b/apps/vscode/agent-sdk/errors.ts @@ -0,0 +1,146 @@ +// Error Categories +export type ErrorCategory = "transport" | "protocol" | "session" | "cli"; + +// Error Code Constants +export const TransportErrorCodes = { + SPAWN_FAILED: "SPAWN_FAILED", + STDIN_NOT_WRITABLE: "STDIN_NOT_WRITABLE", + PROCESS_CRASHED: "PROCESS_CRASHED", + CLI_NOT_FOUND: "CLI_NOT_FOUND", + ALREADY_STARTED: "ALREADY_STARTED", + HANDSHAKE_TIMEOUT: "HANDSHAKE_TIMEOUT", +} as const; + +export const ProtocolErrorCodes = { + INVALID_JSON: "INVALID_JSON", + SCHEMA_MISMATCH: "SCHEMA_MISMATCH", + UNKNOWN_EVENT_TYPE: "UNKNOWN_EVENT_TYPE", + UNKNOWN_REQUEST_TYPE: "UNKNOWN_REQUEST_TYPE", + REQUEST_TIMEOUT: "REQUEST_TIMEOUT", + REQUEST_CANCELLED: "REQUEST_CANCELLED", +} as const; + +export const SessionErrorCodes = { + SESSION_CLOSED: "SESSION_CLOSED", + SESSION_BUSY: "SESSION_BUSY", + TURN_INTERRUPTED: "TURN_INTERRUPTED", + APPROVAL_FAILED: "APPROVAL_FAILED", +} as const; + +export const CliErrorCodes = { + AUTH_REQUIRED: "AUTH_REQUIRED", + INVALID_STATE: "INVALID_STATE", + LLM_NOT_SET: "LLM_NOT_SET", + LLM_NOT_SUPPORTED: "LLM_NOT_SUPPORTED", + CHAT_PROVIDER_ERROR: "CHAT_PROVIDER_ERROR", + CONFIG_ERROR: "CONFIG_ERROR", + INVALID_PARAMS: "INVALID_PARAMS", + UNKNOWN: "UNKNOWN", +} as const; + +export type TransportErrorCodeType = (typeof TransportErrorCodes)[keyof typeof TransportErrorCodes]; +export type ProtocolErrorCodeType = (typeof ProtocolErrorCodes)[keyof typeof ProtocolErrorCodes]; +export type SessionErrorCodeType = (typeof SessionErrorCodes)[keyof typeof SessionErrorCodes]; +export type CliErrorCodeType = (typeof CliErrorCodes)[keyof typeof CliErrorCodes]; + +// Base Error +export abstract class AgentSdkError extends Error { + abstract readonly code: string; + abstract readonly category: ErrorCategory; + + constructor( + message: string, + public readonly cause?: unknown, + public readonly context?: Record, + ) { + super(message); + this.name = this.constructor.name; + } +} + +// Transport Errors - Process and I/O level +export class TransportError extends AgentSdkError { + readonly category = "transport" as const; + + constructor( + public readonly code: TransportErrorCodeType, + message: string, + cause?: unknown, + ) { + super(message, cause); + } +} + +// Protocol Errors - JSON-RPC and schema level +export class ProtocolError extends AgentSdkError { + readonly category = "protocol" as const; + + constructor( + public readonly code: ProtocolErrorCodeType, + message: string, + context?: Record, + ) { + super(message, undefined, context); + } +} + +// Session Errors - Session state level +export class SessionError extends AgentSdkError { + readonly category = "session" as const; + + constructor( + public readonly code: SessionErrorCodeType, + message: string, + ) { + super(message); + } +} + +// CLI Errors - Business logic level (from CLI responses) +export class CliError extends AgentSdkError { + readonly category = "cli" as const; + + constructor( + public readonly code: CliErrorCodeType, + message: string, + public readonly numericCode?: number, + ) { + super(message); + } + + static fromRpcError(rpcCode: number, message: string): CliError { + const codeMap: Record = { + // ACP reuses JSON-RPC -32000 for `authRequired` (see kimi-code + // acp-adapter: RequestError.authRequired()). Under the wire protocol + // this code meant INVALID_STATE; on ACP it always signals auth. + [-32000]: CliErrorCodes.AUTH_REQUIRED, + [-32001]: CliErrorCodes.LLM_NOT_SET, + [-32002]: CliErrorCodes.LLM_NOT_SUPPORTED, + [-32003]: CliErrorCodes.CHAT_PROVIDER_ERROR, + // JSON-RPC standard INVALID_PARAMS; ACP returns this for unknown model aliases etc. + [-32602]: CliErrorCodes.INVALID_PARAMS, + // ACP may wrap config/model lookup failures as JSON-RPC Internal error. + [-32603]: CliErrorCodes.CONFIG_ERROR, + }; + return new CliError(codeMap[rpcCode] ?? CliErrorCodes.UNKNOWN, message, rpcCode); + } +} + +// Error Utilities +export function isAgentSdkError(err: unknown): err is AgentSdkError { + return err instanceof AgentSdkError; +} + +export function getErrorCode(err: unknown): string { + if (isAgentSdkError(err)) { + return err.code; + } + return "UNKNOWN"; +} + +export function getErrorCategory(err: unknown): ErrorCategory | "unknown" { + if (isAgentSdkError(err)) { + return err.category; + } + return "unknown"; +} diff --git a/apps/vscode/agent-sdk/history/context-extract.ts b/apps/vscode/agent-sdk/history/context-extract.ts new file mode 100644 index 000000000..a3b2ca8a1 --- /dev/null +++ b/apps/vscode/agent-sdk/history/context-extract.ts @@ -0,0 +1,394 @@ +import * as fs from "node:fs"; +import * as fsp from "node:fs/promises"; +import * as path from "node:path"; +import * as readline from "node:readline"; +import { KimiPaths } from "../paths"; +import { cleanUserInput } from "../utils"; +import { parseEventPayload, type ContentPart, type DisplayBlock, type StreamEvent, type ToolResult, type WireEvent } from "../schema"; + +// Constants +const MAX_FILE_SIZE = 20 * 1024 * 1024; // 20MB + +// Parse Session Events +export async function parseSessionEvents(workDir: string, sessionId: string): Promise { + const sessionDir = KimiPaths.sessionDir(workDir, sessionId); + const wireFile = findFirstExistingFile(path.join(sessionDir, "agents", "main", "wire.jsonl"), path.join(sessionDir, "wire.jsonl")); + const contextFile = path.join(sessionDir, "context.jsonl"); + + // Try wire.jsonl first (complete event stream) + if (wireFile) { + const stat = await fsp.stat(wireFile); + if (stat.size <= MAX_FILE_SIZE) { + return parseWireFile(wireFile); + } + } + + // Fallback to context.jsonl (compacted) + if (fs.existsSync(contextFile)) { + return parseContextFile(contextFile); + } + + return []; +} + +function findFirstExistingFile(...files: string[]): string | null { + return files.find((file) => fs.existsSync(file)) ?? null; +} + +// Wire File Parser +async function parseWireFile(filePath: string): Promise { + const events: StreamEvent[] = []; + + const stream = fs.createReadStream(filePath, { encoding: "utf-8" }); + const rl = readline.createInterface({ input: stream, crlfDelay: Infinity }); + + for await (const line of rl) { + if (!line.trim()) { + continue; + } + + try { + const record = JSON.parse(line); + events.push(...parseWireRecord(record)); + } catch { + // Skip invalid lines + } + } + + return events; +} + +function parseWireRecord(record: unknown): StreamEvent[] { + if (!record || typeof record !== "object") { + return []; + } + + const rec = record as Record; + const converted = convertRecordWireRecord(rec); + if (converted.length > 0) { + return converted; + } + + const message = rec.message as { type?: string; payload?: unknown } | undefined; + + if (!message?.type) { + return []; + } + + const result = parseEventPayload(message.type, message.payload); + if (!result.ok) { + return []; + } + + const event = cleanTurnBeginEvent(result.value); + return event ? [event] : []; +} + +function convertRecordWireRecord(record: Record): StreamEvent[] { + if (record.type === "context.append_message") { + return convertContextMessage(record.message, false); + } + if (record.type === "context.append_loop_event") { + return convertLoopEvent(record.event); + } + return []; +} + +function convertLoopEvent(raw: unknown): StreamEvent[] { + if (!raw || typeof raw !== "object") { + return []; + } + + const event = raw as Record; + if (event.type === "step.begin") { + const n = typeof event.step === "number" ? event.step : 1; + return [{ type: "StepBegin", payload: { n } }]; + } + if (event.type === "content.part") { + return [{ type: "ContentPart", payload: normalizeContentPart(event.part) }]; + } + if (event.type === "tool.call") { + const toolCallId = typeof event.toolCallId === "string" ? event.toolCallId : typeof event.uuid === "string" ? event.uuid : null; + const name = typeof event.name === "string" ? event.name : "tool"; + if (!toolCallId) { + return []; + } + return [ + { + type: "ToolCall", + payload: { + type: "function", + id: toolCallId, + function: { + name, + arguments: stringifyToolArguments(event.args), + }, + extras: { + description: event.description, + }, + }, + }, + ]; + } + if (event.type === "tool.result") { + const toolCallId = typeof event.toolCallId === "string" ? event.toolCallId : typeof event.parentUuid === "string" ? event.parentUuid : null; + if (!toolCallId) { + return []; + } + const result = normalizeToolResult(event.result); + return [ + { + type: "ToolResult", + payload: { + tool_call_id: toolCallId, + return_value: result, + }, + }, + ]; + } + + return []; +} + +// Context File Parser +async function parseContextFile(filePath: string): Promise { + const events: StreamEvent[] = []; + + const stream = fs.createReadStream(filePath, { encoding: "utf-8" }); + const rl = readline.createInterface({ input: stream, crlfDelay: Infinity }); + + for await (const line of rl) { + if (!line.trim()) { + continue; + } + + try { + const record = JSON.parse(line); + const converted = convertContextRecord(record); + events.push(...converted); + } catch { + // Skip invalid lines + } + } + + return events; +} + +function convertContextRecord(record: unknown): WireEvent[] { + if (!record || typeof record !== "object") { + return []; + } + + const rec = record as Record; + return convertContextMessage(rec, true); +} + +function convertContextMessage(rec: unknown, synthesizeStepBegin: boolean): WireEvent[] { + if (!rec || typeof rec !== "object") { + return []; + } + + const message = rec as Record; + const events: WireEvent[] = []; + const role = message.role; + + // Convert role-based context records to events + if (role === "user" && message.content) { + if (isInjectedMessage(message)) { + return events; + } + + const content = normalizeContent(message.content); + const userInput = cleanUserInput(content); + if (!userInput) { + return events; + } + + events.push({ + type: "TurnBegin", + payload: { user_input: userInput }, + }); + if (synthesizeStepBegin) { + events.push({ + type: "StepBegin", + payload: { n: 1 }, + }); + } + } + + if (role === "assistant" && message.content) { + for (const part of normalizeContent(message.content)) { + events.push({ + type: "ContentPart", + payload: part, + }); + } + } + + // Handle tool calls + const toolCalls = Array.isArray(message.toolCalls) ? message.toolCalls : message.tool_calls; + if (Array.isArray(toolCalls)) { + for (const call of toolCalls) { + if (call && typeof call === "object") { + const tc = call as Record; + const fn = tc.function as Record | undefined; + if (tc.id && fn?.name) { + events.push({ + type: "ToolCall", + payload: { + type: "function", + id: tc.id as string, + function: { + name: fn.name as string, + arguments: fn.arguments as string | undefined, + }, + }, + }); + } + } + } + } + + if (role === "tool") { + const toolCallId = typeof message.toolCallId === "string" ? message.toolCallId : typeof message.tool_call_id === "string" ? message.tool_call_id : null; + if (toolCallId) { + events.push({ + type: "ToolResult", + payload: { + tool_call_id: toolCallId, + return_value: { + is_error: false, + output: normalizeContent(message.content), + message: "", + display: [], + }, + }, + }); + } + } + + return events; +} + +function isInjectedMessage(message: Record): boolean { + const origin = message.origin; + if (!origin || typeof origin !== "object") { + return false; + } + return (origin as Record).kind !== "user"; +} + +function normalizeContent(raw: unknown): ContentPart[] { + if (typeof raw === "string") { + return [{ type: "text", text: raw }]; + } + if (!Array.isArray(raw)) { + return raw === undefined || raw === null ? [] : [{ type: "text", text: stringifyUnknown(raw) }]; + } + return raw.map(normalizeContentPart); +} + +function normalizeContentPart(raw: unknown): ContentPart { + if (!raw || typeof raw !== "object") { + return { type: "text", text: stringifyUnknown(raw) }; + } + + const part = raw as Record; + if (part.type === "text") { + return { type: "text", text: stringifyUnknown(part.text) }; + } + if (part.type === "think") { + const encrypted = typeof part.encrypted === "string" ? part.encrypted : part.encrypted === null ? null : undefined; + return { type: "think", think: stringifyUnknown(part.think), encrypted }; + } + if (part.type === "image" || part.type === "image_url") { + return mediaPart("image_url", part, "imageUrl", "image_url"); + } + if (part.type === "audio" || part.type === "audio_url") { + return mediaPart("audio_url", part, "audioUrl", "audio_url"); + } + if (part.type === "video" || part.type === "video_url") { + return mediaPart("video_url", part, "videoUrl", "video_url"); + } + + const result = parseEventPayload("ContentPart", raw); + return result.ok && result.value.type === "ContentPart" ? result.value.payload : { type: "text", text: `[unsupported content: ${stringifyUnknown(raw)}]` }; +} + +function mediaPart(type: "image_url" | "audio_url" | "video_url", part: Record, camelKey: string, snakeKey: string): ContentPart { + const directUrl = typeof part.url === "string" ? part.url : undefined; + const nested = typeof part[camelKey] === "object" && part[camelKey] !== null ? (part[camelKey] as Record) : typeof part[snakeKey] === "object" && part[snakeKey] !== null ? (part[snakeKey] as Record) : undefined; + const url = directUrl ?? (typeof nested?.url === "string" ? nested.url : ""); + const id = typeof part.id === "string" ? part.id : typeof nested?.id === "string" ? nested.id : undefined; + + if (type === "image_url") { + return { type, image_url: { url, id } }; + } + if (type === "audio_url") { + return { type, audio_url: { url, id } }; + } + return { type, video_url: { url, id } }; +} + +function stringifyToolArguments(args: unknown): string | null { + if (args === undefined || args === null) { + return null; + } + if (typeof args === "string") { + return args; + } + return stringifyUnknown(args); +} + +function normalizeToolResult(raw: unknown): ToolResult["return_value"] { + if (!raw || typeof raw !== "object") { + const output = stringifyUnknown(raw); + return { is_error: false, output, message: output, display: [] }; + } + + const result = raw as Record; + const output = "output" in result ? normalizeToolOutput(result.output) : normalizeToolOutput(raw); + return { + is_error: result.is_error === true || result.isError === true, + output, + message: typeof result.message === "string" ? result.message : typeof output === "string" ? output : "", + display: Array.isArray(result.display) ? (result.display as DisplayBlock[]) : [], + extras: typeof result.extras === "object" && result.extras !== null ? (result.extras as Record) : undefined, + }; +} + +function normalizeToolOutput(raw: unknown): string | ContentPart[] { + if (Array.isArray(raw)) { + return normalizeContent(raw); + } + if (typeof raw === "string") { + return raw; + } + return stringifyUnknown(raw); +} + +function stringifyUnknown(value: unknown): string { + if (typeof value === "string") { + return value; + } + if (value === undefined || value === null) { + return ""; + } + if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") { + return String(value); + } + try { + return JSON.stringify(value); + } catch { + return ""; + } +} + +function cleanTurnBeginEvent(event: StreamEvent): StreamEvent | null { + if (event.type !== "TurnBegin") { + return event; + } + + const userInput = cleanUserInput(event.payload.user_input); + return userInput ? { ...event, payload: { user_input: userInput } } : null; +} diff --git a/apps/vscode/agent-sdk/index.ts b/apps/vscode/agent-sdk/index.ts new file mode 100644 index 000000000..61dc46292 --- /dev/null +++ b/apps/vscode/agent-sdk/index.ts @@ -0,0 +1,125 @@ +/** + * Kimi Code Agent SDK - TypeScript SDK for Kimi Code Agent Client Protocol (ACP). + * + * @example Quick Start + * ```typescript + * import { createSession } from "@moonshot-ai/kimi-code-vscode-agent-sdk"; + * + * const session = createSession({ + * workDir: process.cwd(), + * model: "kimi-k2-0711-preview", + * }); + * + * const turn = session.prompt("Hello"); + * for await (const event of turn) { + * if (event.type === "ContentPart" && event.payload.type === "text") { + * console.log(event.payload.text); + * } + * if (event.type === "ApprovalRequest") { + * await turn.approve(event.payload.id, "approve"); + * } + * } + * + * await session.close(); + * ``` + * + * @module @moonshot-ai/kimi-code-vscode-agent-sdk + */ + +// Session +export { createSession, prompt } from "./session"; +export type { Session, Turn, SessionState } from "./session"; + +// Storage +export { listSessions, deleteSession } from "./storage"; + +// History +export { parseSessionEvents } from "./history/context-extract"; + +// Config +export { parseConfig, saveDefaultModel, getModelById, isModelThinking, getModelThinkingMode } from "./config"; +export type { ThinkingMode } from "./config"; + +// Paths +export { KimiPaths } from "./paths"; + +// Errors +export { + AgentSdkError, + TransportError, + ProtocolError, + SessionError, + CliError, + isAgentSdkError, + getErrorCode, + getErrorCategory, + TransportErrorCodes, + ProtocolErrorCodes, + SessionErrorCodes, + CliErrorCodes, +} from "./errors"; +export type { ErrorCategory, TransportErrorCodeType, ProtocolErrorCodeType, SessionErrorCodeType, CliErrorCodeType } from "./errors"; + +// Utils +export { cleanSystemTags, cleanUserInput, extractBrief, extractTextFromContentParts, formatContentOutput } from "./utils"; + +// ACP legacy compatibility event translation +export { AcpLegacyEventTranslator, normalizeAcpMode } from "./acp-legacy-events"; +export type { AcpContentBlock, AcpPermissionRequest, AcpPlanEntry, AcpSessionNotification, AcpSessionUpdate, AcpToolCallContent, AcpTranslateOptions } from "./acp-legacy-events"; + +// Types +export type { + ApprovalResponse, + ApprovalResult, + AgentMode, + ApprovalOption, + ContentPart, + TokenUsage, + DisplayBlock, + CommandBlock, + FileOpBlock, + FileContentBlock, + UrlFetchBlock, + SearchBlock, + InvocationBlock, + BackgroundTaskBlock, + ToolCall, + ToolCallPart, + ToolResult, + TurnBegin, + StepBegin, + StatusUpdate, + ApprovalRequestPayload, + PlanEntry, + Plan, + ConfigOption, + ConfigOptionUpdate, + AvailableCommand, + AvailableCommandsUpdate, + SubagentEvent, + StreamEvent, + LegacyWireEvent, + LegacyWireRequest, + LegacyStreamEvent, + RunResult, + ModelConfig, + MCPServerConfig, + KimiConfig, + SessionOptions, + SessionInfo, + ContextRecord, +} from "./schema"; + +// Schemas +export { + ContentPartSchema, + DisplayBlockSchema, + ToolCallSchema, + ToolResultSchema, + PlanSchema, + ConfigOptionUpdateSchema, + AvailableCommandsUpdateSchema, + RunResultSchema, + parseEventPayload, + parseRequestPayload, +} from "./schema"; diff --git a/apps/vscode/agent-sdk/package.json b/apps/vscode/agent-sdk/package.json new file mode 100644 index 000000000..06c713305 --- /dev/null +++ b/apps/vscode/agent-sdk/package.json @@ -0,0 +1,64 @@ +{ + "name": "@moonshot-ai/kimi-code-vscode-agent-sdk", + "private": true, + "version": "0.1.0", + "description": "SDK for interacting with Kimi Code CLI", + "license": "MIT", + "main": "./dist/index.js", + "module": "./dist/index.mjs", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.mjs", + "require": "./dist/index.js" + }, + "./schema": { + "types": "./dist/schema.d.ts", + "import": "./dist/schema.mjs", + "require": "./dist/schema.js" + }, + "./errors": { + "types": "./dist/errors.d.ts", + "import": "./dist/errors.mjs", + "require": "./dist/errors.js" + }, + "./utils": { + "types": "./dist/utils.d.ts", + "import": "./dist/utils.mjs", + "require": "./dist/utils.js" + }, + "./paths": { + "types": "./dist/paths.d.ts", + "import": "./dist/paths.mjs", + "require": "./dist/paths.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsup", + "dev": "tsup --watch", + "prepublishOnly": "pnpm run build", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage" + }, + "dependencies": { + "@moonshot-ai/acp-adapter": "workspace:*", + "toml": "^3.0.0", + "zod": "catalog:" + }, + "devDependencies": { + "@types/node": "^22.15.3", + "@vitest/coverage-v8": "4.1.4", + "tsup": "^8.0.0", + "typescript": "6.0.2", + "vitest": "4.1.4" + }, + "engines": { + "node": ">=24.15.0" + } +} diff --git a/apps/vscode/agent-sdk/paths.ts b/apps/vscode/agent-sdk/paths.ts new file mode 100644 index 000000000..6065e3d77 --- /dev/null +++ b/apps/vscode/agent-sdk/paths.ts @@ -0,0 +1,32 @@ +import * as path from "node:path"; +import * as os from "node:os"; +import * as crypto from "node:crypto"; + +const KIMI_HOME = process.env.KIMI_CODE_HOME || path.join(os.homedir(), ".kimi-code"); + +function hashPath(workDir: string): string { + return crypto.createHash("sha256").update(workDir, "utf-8").digest("hex").slice(0, 12); +} + +function slugPath(workDir: string): string { + const base = path.basename(workDir).replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, ""); + return base || "workspace"; +} + +export const KimiPaths = { + home: KIMI_HOME, + config: path.join(KIMI_HOME, "config.toml"), + mcpConfig: path.join(KIMI_HOME, "mcp.json"), + + sessionsDir(workDir: string): string { + return path.join(KIMI_HOME, "sessions", `wd_${slugPath(workDir)}_${hashPath(workDir)}`); + }, + + sessionDir(workDir: string, sessionId: string): string { + return path.join(KIMI_HOME, "sessions", `wd_${slugPath(workDir)}_${hashPath(workDir)}`, sessionId); + }, + + shadowGitDir(workDir: string, sessionId: string): string { + return path.join(KIMI_HOME, "sessions", `wd_${slugPath(workDir)}_${hashPath(workDir)}`, sessionId, "shadow", ".git"); + }, +}; diff --git a/apps/vscode/agent-sdk/protocol.ts b/apps/vscode/agent-sdk/protocol.ts new file mode 100644 index 000000000..a59352fb6 --- /dev/null +++ b/apps/vscode/agent-sdk/protocol.ts @@ -0,0 +1,687 @@ +import { spawn, type ChildProcess } from "node:child_process"; +import { createInterface, type Interface as ReadlineInterface } from "node:readline"; +import { type StreamEvent, type RunResult, type ContentPart, type ParseError, type AgentMode, type ApprovalResult } from "./schema"; +import { TransportError, CliError } from "./errors"; +import { AcpLegacyEventTranslator, normalizeAcpMode, type AcpContentBlock, type AcpPermissionRequest, type AcpSessionNotification } from "./acp-legacy-events"; + +const MAX_DEBUG_PAYLOAD_LENGTH = 500; +const HANDSHAKE_TIMEOUT_MS = 10000; + +function isDebugAcpEnabled(): boolean { + return process.env.KIMI_CODE_DEBUG_ACP === "1"; +} + +function debugAcp(...args: unknown[]): void { + if (isDebugAcpEnabled()) { + console.log(...args); + } +} + +function debugAcpPayload(prefix: string, payload: string): void { + if (isDebugAcpEnabled()) { + console.log(prefix, truncateForDebug(payload)); + } +} + +function debugAcpStderr(data: unknown): void { + if (isDebugAcpEnabled()) { + console.warn("[protocol-client stderr]", data instanceof Buffer ? data.toString() : String(data)); + } +} + +function truncateForDebug(value: unknown): string { + const text = typeof value === "string" ? value : JSON.stringify(value); + if (text.length <= MAX_DEBUG_PAYLOAD_LENGTH) { + return text; + } + return `${text.slice(0, MAX_DEBUG_PAYLOAD_LENGTH)}...(${text.length} chars)`; +} + +// Client Options +export interface ClientOptions { + sessionId?: string; + workDir: string; + model?: string; + thinking?: boolean; + mode?: AgentMode; + yoloMode?: boolean; + executablePath?: string; + environmentVariables?: Record; +} + +type AppliedConfig = Pick & { + thinking: boolean; + mode: AgentMode; +}; + +// Prompt Stream +export interface PromptStream { + events: AsyncIterable; + result: Promise; +} + +interface PendingRequest { + resolve: (v: unknown) => void; + reject: (e: Error) => void; +} + +// Event Channel Helper +// Creates a push-based async iterable used for PromptStream. `push` adds +// events to a queue (or resolves a waiting consumer); `finish` signals EOF. +export function createEventChannel(): { + iterable: AsyncIterable; + push: (value: T) => void; + finish: () => void; +} { + const queue: T[] = []; + const resolvers: Array<(result: IteratorResult) => void> = []; + let finished = false; + + return { + iterable: { + [Symbol.asyncIterator]: () => ({ + next: () => { + const queued = queue.shift(); + if (queued !== undefined) { + return Promise.resolve({ done: false as const, value: queued }); + } + if (finished) { + return Promise.resolve({ done: true as const, value: undefined }); + } + return new Promise((resolve) => resolvers.push(resolve)); + }, + }), + }, + push: (value: T) => { + if (finished) { + return; + } + const resolver = resolvers.shift(); + if (resolver) { + resolver({ done: false, value }); + } else { + queue.push(value); + } + }, + finish: () => { + if (finished) { + return; + } + finished = true; + for (const resolver of resolvers) { + resolver({ done: true, value: undefined }); + } + resolvers.length = 0; + }, + }; +} + +// Protocol Client +export class ProtocolClient { + private process: ChildProcess | null = null; + private readline: ReadlineInterface | null = null; + private requestId = 0; + private pendingRequests = new Map(); + + private pushEvent: ((event: StreamEvent) => void) | null = null; + private finishEvents: (() => void) | null = null; + private ready: Promise | null = null; + private acpSessionId: string | null = null; + private readonly translator = new AcpLegacyEventTranslator(); + // Events emitted before a prompt stream consumer is attached (e.g. during + // session handshake replay) are buffered here and replayed on the next stream. + private bufferedEvents: StreamEvent[] = []; + private appliedConfig: Partial | null = null; + + get isRunning(): boolean { + return this.process !== null && this.process.exitCode === null; + } + + get sessionId(): string | null { + return this.acpSessionId; + } + + start(options: ClientOptions): void { + if (this.process) { + throw new TransportError("ALREADY_STARTED", "Client already started"); + } + + const executable = options.executablePath ?? "kimi"; + const args = ["acp"]; + + debugAcp(`[protocol-client] Spawning ACP CLI: ${executable} ${args.join(" ")}`); + + try { + this.process = spawn(executable, args, { + cwd: options.workDir, + env: { ...process.env, ...options.environmentVariables }, + stdio: ["pipe", "pipe", "pipe"], + }); + } catch (err) { + throw new TransportError("SPAWN_FAILED", `Failed to spawn CLI: ${err}`, err); + } + + if (!this.process.stdout || !this.process.stdin) { + this.process.kill(); + this.process = null; + throw new TransportError("SPAWN_FAILED", "Process missing stdio"); + } + + this.readline = createInterface({ input: this.process.stdout }); + this.readline.on("line", (line) => this.handleLine(line)); + + this.process.stderr?.on("data", (data) => debugAcpStderr(data)); + this.process.on("error", (err) => this.handleProcessError(err)); + this.process.on("exit", (code) => this.handleProcessExit(code)); + + this.ready = this.initialize(options); + } + + async ensureReady(): Promise { + if (!this.ready) { + throw new TransportError("SPAWN_FAILED", "Client is not started"); + } + await this.ready; + if (!this.acpSessionId) { + throw new TransportError("HANDSHAKE_TIMEOUT", "ACP session was not initialized"); + } + return this.acpSessionId; + } + + consumeBufferedEvents(): StreamEvent[] { + const events = this.bufferedEvents; + this.bufferedEvents = []; + return events; + } + + async applyConfig(options: Pick): Promise { + await this.ensureReady(); + await this.applyConfigRaw(options); + } + + private normalizeAppliedConfig(options: Pick): AppliedConfig { + return { + model: options.model, + thinking: options.thinking ?? false, + mode: normalizeMode(options), + }; + } + + private markKnownConfigApplied(configOptions: unknown[] | undefined, _options: Pick): void { + const knownConfig: Partial = { ...(this.appliedConfig ?? {}) }; + + for (const option of configOptions ?? []) { + const id = getConfigOptionId(option); + const value = getConfigOptionValue(option); + + if (id === "model" && typeof value === "string") { + knownConfig.model = value; + } else if (id === "thinking") { + const thinking = parseBooleanConfigValue(value); + if (thinking !== null) { + knownConfig.thinking = thinking; + } + } else if (id === "mode") { + const mode = parseModeConfigValue(value); + if (mode) { + knownConfig.mode = mode; + } + } + } + + this.appliedConfig = Object.keys(knownConfig).length > 0 ? knownConfig : null; + } + + private async applyConfigRaw(options: Pick): Promise { + if (!this.acpSessionId) { + return; + } + const sessionId = this.acpSessionId; + const config = this.normalizeAppliedConfig(options); + const appliedConfig = this.appliedConfig; + + if (config.model && appliedConfig?.model !== config.model) { + await this.sendRequest( + "session/set_config_option", + { + sessionId, + configId: "model", + value: config.model, + }, + HANDSHAKE_TIMEOUT_MS, + ); + } + if (appliedConfig?.thinking !== config.thinking) { + await this.sendRequest( + "session/set_config_option", + { + sessionId, + configId: "thinking", + value: config.thinking ? "on" : "off", + }, + HANDSHAKE_TIMEOUT_MS, + ); + } + if (appliedConfig?.mode !== config.mode) { + await this.sendRequest( + "session/set_config_option", + { + sessionId, + configId: "mode", + value: config.mode, + }, + HANDSHAKE_TIMEOUT_MS, + ); + } + + this.appliedConfig = appliedConfig ? { ...appliedConfig, ...config } : config; + } + + async stop(): Promise { + if (!this.process) { + return; + } + + if (this.process.exitCode !== null || this.process.killed) { + this.cleanup(); + return; + } + + this.process.kill("SIGTERM"); + await new Promise((resolve) => { + const timeout = setTimeout(() => { + this.process?.kill("SIGKILL"); + resolve(); + }, 3000); + this.process!.once("exit", () => { + clearTimeout(timeout); + resolve(); + }); + }); + this.cleanup(); + } + + sendPrompt(content: string | ContentPart[]): PromptStream { + const { iterable, push, finish } = createEventChannel(); + + this.pushEvent = push; + this.finishEvents = () => { + finish(); + this.pushEvent = null; + this.finishEvents = null; + }; + + push({ type: "TurnBegin", payload: { user_input: content } }); + push({ type: "StepBegin", payload: { n: 1 } }); + + const result = this.ensureReady() + .then((sessionId) => + this.sendRequest("session/prompt", { + sessionId, + prompt: toAcpPrompt(content), + }), + ) + .then((res) => { + this.finishEvents?.(); + const stopReason = (res as { stopReason?: string } | undefined)?.stopReason; + return runResultFromStopReason(stopReason); + }) + .catch((err) => { + this.finishEvents?.(); + throw err; + }); + + return { events: iterable, result }; + } + + sendCancel(): Promise { + if (!this.acpSessionId) { + return Promise.resolve(); + } + this.writeLine({ jsonrpc: "2.0", method: "session/cancel", params: { sessionId: this.acpSessionId } }); + return Promise.resolve(); + } + + sendApproval(requestId: string | number, response: ApprovalResult): Promise { + const optionId = typeof response === "string" ? (response === "approve" ? "approve_once" : response === "approve_for_session" ? "approve_always" : "reject") : response.optionId; + this.writeLine({ + jsonrpc: "2.0", + id: requestId, + result: { outcome: { outcome: "selected", optionId } }, + }); + this.emitEvent({ type: "ApprovalRequestResolved", payload: { request_id: requestId, response } }); + return Promise.resolve(); + } + + private async initialize(options: ClientOptions): Promise { + await this.sendRequest( + "initialize", + { + protocolVersion: 1, + clientCapabilities: { + fs: { readTextFile: false, writeTextFile: false }, + }, + }, + HANDSHAKE_TIMEOUT_MS, + ); + + if (options.sessionId) { + const res = await this.sendRequest( + "session/load", + { + cwd: options.workDir, + sessionId: options.sessionId, + mcpServers: [], + }, + HANDSHAKE_TIMEOUT_MS, + ); + this.acpSessionId = options.sessionId; + const configOptions = extractConfigOptions(res); + this.markKnownConfigApplied(configOptions, options); + this.emitConfigOptionUpdate(configOptions); + } else { + const res = (await this.sendRequest( + "session/new", + { + cwd: options.workDir, + mcpServers: [], + }, + HANDSHAKE_TIMEOUT_MS, + )) as { sessionId?: string; configOptions?: unknown[] }; + if (!res?.sessionId) { + throw new TransportError("HANDSHAKE_TIMEOUT", "ACP session/new did not return sessionId"); + } + this.acpSessionId = res.sessionId; + const configOptions = extractConfigOptions(res); + this.markKnownConfigApplied(configOptions, options); + this.emitConfigOptionUpdate(configOptions); + await this.applyConfigRaw(options); + } + } + + // Private: RPC Communication + private sendRequest(method: string, params?: any, timeoutMs?: number): Promise { + const id = `${++this.requestId}_${Date.now()}`; + + return new Promise((resolve, reject) => { + let timeout: NodeJS.Timeout | undefined; + const timeoutHandler = timeoutMs + ? () => { + this.pendingRequests.delete(id); + reject(new TransportError("HANDSHAKE_TIMEOUT", `RPC ${method} timed out after ${timeoutMs}ms`)); + this.stop(); + } + : undefined; + + const wrappedResolve = (value: unknown) => { + if (timeout) clearTimeout(timeout); + resolve(value); + }; + const wrappedReject = (err: Error) => { + if (timeout) clearTimeout(timeout); + reject(err); + }; + + this.pendingRequests.set(id, { resolve: wrappedResolve, reject: wrappedReject }); + + if (timeoutMs && timeoutMs > 0) { + timeout = setTimeout(timeoutHandler!, timeoutMs); + } + + try { + this.writeLine({ jsonrpc: "2.0", id, method, ...(params && { params }) }); + } catch (err) { + if (timeout) clearTimeout(timeout); + this.pendingRequests.delete(id); + reject(err); + } + }); + } + + private writeLine(data: unknown): void { + const payload = JSON.stringify(data); + debugAcpPayload("[protocol-client] Sending:", payload); + + if (!this.process?.stdin?.writable) { + throw new TransportError("STDIN_NOT_WRITABLE", "Cannot write to CLI stdin"); + } + this.process.stdin.write(payload + "\n"); + } + + // Private: Line Handling + private handleLine(line: string): void { + debugAcpPayload("[protocol-client] Received:", line); + + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + this.emitParseError("INVALID_JSON", "Failed to parse JSON", line); + return; + } + + const msg = parsed as { id?: string | number; method?: string; params?: unknown; result?: unknown; error?: { code: number; message: string; data?: unknown } }; + + if (msg.id !== undefined && this.pendingRequests.has(String(msg.id))) { + const pending = this.pendingRequests.get(String(msg.id))!; + this.pendingRequests.delete(String(msg.id)); + + if (msg.error) { + const detail = formatRpcError(msg.error); + pending.reject(CliError.fromRpcError(msg.error.code, detail)); + } else { + pending.resolve(msg.result); + } + return; + } + + if (msg.method) { + this.handleNotificationOrRequest(msg.id, msg.method, msg.params); + } + } + + private handleNotificationOrRequest(id: string | number | undefined, method: string, params: unknown): void { + if (method === "session/update") { + this.handleSessionUpdate(params as AcpSessionNotification); + return; + } + + if (method === "kimi/conversation_reset") { + this.emitEvent({ type: "ConversationReset", payload: {} }); + return; + } + + if (method === "kimi/step_interrupted" || method === "kimi/compaction" || method === "kimi/subagent_event") { + for (const event of this.translator.extensionNotificationToEvents(method, params)) { + this.emitEvent(event); + } + return; + } + + // ACP permission request ids are JSON-RPC request ids and can be number 0, + // so we must check against undefined rather than a truthy test. + if (method === "session/request_permission" && id !== undefined) { + this.handlePermissionRequest(id, params as AcpPermissionRequest); + return; + } + } + + private handleSessionUpdate(notification: AcpSessionNotification): void { + for (const event of this.translator.sessionUpdateToEvents(notification, { + suppressUserEcho: this.pushEvent !== null, + onUnknownSessionUpdate: (update) => debugAcp("[protocol-client] Ignoring unknown ACP session/update", update.sessionUpdate), + })) { + this.emitEvent(event); + } + } + + private handlePermissionRequest(id: string | number, request: AcpPermissionRequest): void { + this.emitEvent(this.translator.permissionRequestToEvent(id, request)); + } + + private emitParseError(code: string, message: string, raw?: string): void { + const error: ParseError = { type: "error", code, message, raw: raw?.slice(0, 500) }; + this.emitEvent(error); + } + + private emitEvent(event: StreamEvent): void { + if (this.pushEvent) { + this.pushEvent(event); + } else { + this.bufferedEvents.push(event); + } + } + + private emitConfigOptionUpdate(configOptions: unknown[] | undefined): void { + const options = (configOptions ?? []).filter((option): option is Record => option !== null && typeof option === "object" && !Array.isArray(option)); + if (options.length === 0) { + return; + } + this.emitEvent({ type: "ConfigOptionUpdate", payload: { configOptions: options } }); + } + + // Private: Process Lifecycle + private handleProcessError(err: Error): void { + console.error("[protocol-client] Process error:", err.message); + + const error = new TransportError("PROCESS_CRASHED", `CLI process error: ${err.message}`, err); + for (const pending of this.pendingRequests.values()) { + pending.reject(error); + } + this.finishEvents?.(); + this.cleanup(); + } + + private handleProcessExit(code: number | null): void { + debugAcp("[protocol-client] Process exited with code:", code); + + if (this.pendingRequests.size > 0) { + const error = new TransportError("PROCESS_CRASHED", `CLI exited with code ${code ?? "unknown"}`); + for (const pending of this.pendingRequests.values()) { + pending.reject(error); + } + } + this.finishEvents?.(); + this.cleanup(); + } + + private cleanup(): void { + this.readline?.removeAllListeners(); + this.readline?.close(); + this.readline = null; + + this.process?.removeAllListeners(); + this.process?.stdout?.removeAllListeners(); + this.process?.stderr?.removeAllListeners(); + this.process = null; + + this.pushEvent = null; + this.finishEvents = null; + this.pendingRequests.clear(); + this.ready = null; + this.acpSessionId = null; + this.appliedConfig = null; + this.translator.reset(); + this.bufferedEvents = []; + } +} + +function extractConfigOptions(result: unknown): unknown[] | undefined { + if (!result || typeof result !== "object" || !("configOptions" in result)) { + return undefined; + } + const configOptions = result.configOptions; + return Array.isArray(configOptions) ? configOptions : undefined; +} + +function getConfigOptionId(option: unknown): string | null { + if (!option || typeof option !== "object") { + return null; + } + const record = option as Record; + const id = record.configId ?? record.optionId ?? record.id ?? record.name; + return typeof id === "string" ? id : null; +} + +function getConfigOptionValue(option: unknown): unknown { + if (!option || typeof option !== "object") { + return undefined; + } + const record = option as Record; + return record.value ?? record.currentValue ?? record.defaultValue; +} + +function parseBooleanConfigValue(value: unknown): boolean | null { + if (typeof value === "boolean") { + return value; + } + if (typeof value !== "string") { + return null; + } + if (value === "on" || value === "true" || value === "enabled") { + return true; + } + if (value === "off" || value === "false" || value === "disabled") { + return false; + } + return null; +} + +function parseModeConfigValue(value: unknown): AgentMode | null { + if (value === "default" || value === "plan" || value === "auto" || value === "yolo") { + return value; + } + return null; +} + +function toAcpPrompt(content: string | ContentPart[]): AcpContentBlock[] { + if (typeof content === "string") { + return [{ type: "text", text: content }]; + } + + const blocks: AcpContentBlock[] = []; + for (const part of content) { + if (part.type === "text") { + blocks.push({ type: "text", text: part.text }); + } else if (part.type === "image_url") { + const parsed = parseDataUrl(part.image_url.url); + if (parsed) { + blocks.push({ type: "image", mimeType: parsed.mimeType, data: parsed.data }); + } else { + blocks.push({ type: "text", text: `` }); + } + } else if (part.type === "audio_url") { + blocks.push({ type: "text", text: `