From 21acaf9cf5e9cf463d986e79ea4d59c573c19a8d Mon Sep 17 00:00:00 2001 From: lihao Date: Tue, 9 Jun 2026 16:14:22 +0800 Subject: [PATCH 1/6] feat: add VS Code plugin workspace --- .changeset/README.md | 5 + .changeset/config.json | 7 +- .../kimi-acp-extension-notifications.md | 5 + .changeset/shared-slash-command-registry.md | 6 + .github/workflows/ci.yml | 1 + .gitignore | 8 + .oxlintrc.json | 1 + AGENTS.md | 1 + apps/kimi-code/src/tui/commands/registry.ts | 284 +- apps/vscode/.vscodeignore | 30 + apps/vscode/LICENSE | 21 + apps/vscode/agent-display-model/package.json | 25 + .../agent-display-model/src/copy-content.ts | 96 + .../vscode/agent-display-model/src/effects.ts | 10 + apps/vscode/agent-display-model/src/events.ts | 65 + apps/vscode/agent-display-model/src/index.ts | 70 + apps/vscode/agent-display-model/src/model.ts | 271 + .../vscode/agent-display-model/src/reducer.ts | 454 ++ .../agent-display-model/src/tool-display.ts | 256 + .../test/copy-content.test.ts | 81 + .../test/display-fixtures.test.ts | 28 + .../test/fixtures/display-reducer-fixtures.ts | 560 ++ .../agent-display-model/test/reducer.test.ts | 376 + .../test/tool-display.test.ts | 68 + apps/vscode/agent-display-model/tsconfig.json | 4 + .../agent-display-model/vitest.config.ts | 8 + apps/vscode/agent-sdk/.npmignore | 26 + apps/vscode/agent-sdk/README.md | 567 ++ apps/vscode/agent-sdk/acp-legacy-events.ts | 857 +++ apps/vscode/agent-sdk/config.ts | 172 + apps/vscode/agent-sdk/errors.ts | 146 + .../agent-sdk/history/context-extract.ts | 180 + apps/vscode/agent-sdk/index.ts | 125 + apps/vscode/agent-sdk/package.json | 64 + apps/vscode/agent-sdk/paths.ts | 32 + apps/vscode/agent-sdk/protocol.ts | 687 ++ apps/vscode/agent-sdk/schema.ts | 834 +++ apps/vscode/agent-sdk/session.ts | 406 ++ apps/vscode/agent-sdk/storage.ts | 160 + .../tests/acp-legacy-events.golden.test.ts | 89 + .../agent-sdk/tests/acp-legacy-events.test.ts | 212 + apps/vscode/agent-sdk/tests/config.test.ts | 307 + apps/vscode/agent-sdk/tests/errors.test.ts | 232 + .../tests/fixtures/acp-legacy/index.ts | 51 + .../acp-legacy/kimi-compaction-completed.json | 32 + .../acp-legacy/kimi-compaction-started.json | 19 + .../acp-legacy/kimi-step-interrupted.json | 17 + .../kimi-subagent-child-tool-call.json | 41 + .../acp-legacy/kimi-subagent-started.json | 25 + ...session-request-permission-numeric-id.json | 32 + .../session-update-agent-message.json | 14 + .../session-update-agent-thought.json | 14 + .../session-update-available-commands.json | 19 + .../session-update-config-option.json | 19 + .../acp-legacy/session-update-plan.json | 25 + .../session-update-tool-call-lifecycle.json | 68 + .../acp-legacy/session-update-unknown.json | 13 + .../acp-legacy/session-update-usage.json | 38 + .../session-update-user-message.json | 18 + apps/vscode/agent-sdk/tests/protocol.test.ts | 1510 ++++ apps/vscode/agent-sdk/tests/schema.test.ts | 738 ++ apps/vscode/agent-sdk/tests/session.test.ts | 1348 ++++ apps/vscode/agent-sdk/tests/utils.test.ts | 171 + apps/vscode/agent-sdk/tsconfig.json | 29 + apps/vscode/agent-sdk/tsup.config.ts | 37 + apps/vscode/agent-sdk/utils.ts | 63 + apps/vscode/agent-sdk/vitest.config.ts | 15 + apps/vscode/dev.js | 38 + apps/vscode/esbuild.js | 66 + apps/vscode/eslint.config.mjs | 43 + apps/vscode/package.json | 235 + apps/vscode/resources/kimi-icon.svg | 33 + .../check-vscode-runtime-boundaries.mjs | 102 + apps/vscode/scripts/check-vsix-contents.mjs | 111 + apps/vscode/shared/bridge.ts | 61 + apps/vscode/shared/errors.ts | 55 + apps/vscode/shared/types.ts | 70 + apps/vscode/shared/utils.ts | 3 + apps/vscode/src/KimiWebviewProvider.ts | 138 + apps/vscode/src/bridge-handler.ts | 252 + apps/vscode/src/config/vscode-settings.ts | 56 + apps/vscode/src/extension.ts | 150 + apps/vscode/src/handlers/chat.handler.ts | 297 + apps/vscode/src/handlers/cli.handler.ts | 15 + apps/vscode/src/handlers/config.handler.ts | 61 + apps/vscode/src/handlers/file.handler.ts | 294 + apps/vscode/src/handlers/index.ts | 20 + apps/vscode/src/handlers/mcp.handler.ts | 9 + apps/vscode/src/handlers/session.handler.ts | 57 + apps/vscode/src/handlers/types.ts | 28 + apps/vscode/src/handlers/workspace.handler.ts | 21 + apps/vscode/src/managers/cli.manager.ts | 89 + apps/vscode/src/managers/file.manager.ts | 162 + apps/vscode/src/managers/git.manager.ts | 331 + apps/vscode/src/managers/index.ts | 4 + apps/vscode/src/managers/mcp.manager.ts | 7 + apps/vscode/src/repositories/index.ts | 0 apps/vscode/tsconfig.json | 21 + apps/vscode/webview-ui/components.json | 24 + apps/vscode/webview-ui/index.html | 12 + apps/vscode/webview-ui/package.json | 53 + .../webview-ui/public/kimi-banner-dark.svg | 6 + .../webview-ui/public/kimi-banner-light.svg | 6 + apps/vscode/webview-ui/public/kimi-logo.png | Bin 0 -> 19541 bytes apps/vscode/webview-ui/src/App.tsx | 92 + .../webview-ui/src/components/ActionMenu.tsx | 59 + .../src/components/ApprovalDialog.tsx | 177 + .../webview-ui/src/components/ChatArea.tsx | 63 + .../webview-ui/src/components/ChatMessage.tsx | 271 + .../webview-ui/src/components/ChatStatus.tsx | 38 + .../src/components/CompactionCard.tsx | 37 + .../src/components/ConfigErrorScreen.tsx | 93 + .../webview-ui/src/components/CopyButton.tsx | 47 + .../src/components/DisplayBlocks.tsx | 221 + .../src/components/ErrorBoundary.tsx | 59 + .../src/components/FileChangesBar.tsx | 156 + .../src/components/FilePickerMenu.tsx | 173 + .../webview-ui/src/components/Header.tsx | 78 + .../webview-ui/src/components/InlineError.tsx | 48 + .../webview-ui/src/components/KimiLogo.tsx | 11 + .../webview-ui/src/components/KimiMascot.tsx | 29 + .../src/components/MCPServersModal.tsx | 95 + .../webview-ui/src/components/Markdown.tsx | 229 + .../src/components/MediaPreviewModal.tsx | 51 + .../src/components/MediaThumbnail.tsx | 64 + .../webview-ui/src/components/PlanBlock.tsx | 36 + .../webview-ui/src/components/SessionList.tsx | 187 + .../src/components/SlashCommandMenu.tsx | 94 + .../src/components/ThinkingBlock.tsx | 43 + .../src/components/ThinkingButton.tsx | 45 + .../src/components/ToolRenderers.tsx | 436 ++ .../src/components/WelcomeScreen.tsx | 16 + .../components/hooks/useExtensionImageUrl.ts | 14 + .../vscode/webview-ui/src/components/index.ts | 21 + .../src/components/inputarea/InputArea.tsx | 682 ++ .../inputarea/hooks/useClickOutside.ts | 20 + .../inputarea/hooks/useFilePicker.tsx | 292 + .../inputarea/hooks/useMediaUpload.ts | 151 + .../inputarea/hooks/useSlashMenu.ts | 145 + .../src/components/inputarea/utils.ts | 28 + .../src/components/ui/accordion.tsx | 49 + .../src/components/ui/alert-dialog.tsx | 153 + .../webview-ui/src/components/ui/badge.tsx | 33 + .../webview-ui/src/components/ui/button.tsx | 54 + .../webview-ui/src/components/ui/card.tsx | 52 + .../webview-ui/src/components/ui/checkbox.tsx | 24 + .../webview-ui/src/components/ui/combobox.tsx | 216 + .../webview-ui/src/components/ui/command.tsx | 109 + .../src/components/ui/context-menu.tsx | 201 + .../webview-ui/src/components/ui/dialog.tsx | 110 + .../src/components/ui/dropdown-menu.tsx | 196 + .../webview-ui/src/components/ui/field.tsx | 153 + .../src/components/ui/input-group.tsx | 114 + .../webview-ui/src/components/ui/input.tsx | 19 + .../webview-ui/src/components/ui/label.tsx | 21 + .../webview-ui/src/components/ui/popover.tsx | 47 + .../src/components/ui/scroll-area.tsx | 40 + .../webview-ui/src/components/ui/select.tsx | 129 + .../src/components/ui/separator.tsx | 21 + .../webview-ui/src/components/ui/sonner.tsx | 23 + .../webview-ui/src/components/ui/switch.tsx | 38 + .../webview-ui/src/components/ui/tabs.tsx | 54 + .../webview-ui/src/components/ui/textarea.tsx | 18 + .../webview-ui/src/components/ui/tooltip.tsx | 41 + .../vscode/webview-ui/src/hooks/useAppInit.ts | 158 + .../webview-ui/src/hooks/useWelcomeHint.ts | 87 + apps/vscode/webview-ui/src/lib/content.ts | 62 + .../src/lib/display-block-adapter.ts | 123 + apps/vscode/webview-ui/src/lib/id.ts | 11 + apps/vscode/webview-ui/src/lib/media-utils.ts | 153 + .../webview-ui/src/lib/text-enrichment.ts | 144 + apps/vscode/webview-ui/src/lib/utils.ts | 6 + apps/vscode/webview-ui/src/main.tsx | 35 + apps/vscode/webview-ui/src/services/bridge.ts | 241 + .../webview-ui/src/services/commands.ts | 31 + apps/vscode/webview-ui/src/services/config.ts | 21 + apps/vscode/webview-ui/src/services/index.ts | 5 + .../src/services/recommended-mcp.ts | 46 + .../webview-ui/src/stores/chat.store.test.ts | 180 + .../webview-ui/src/stores/chat.store.ts | 582 ++ .../src/stores/display-effects.test.ts | 29 + .../webview-ui/src/stores/display-effects.ts | 21 + .../src/stores/display-event-adapter.test.ts | 445 ++ .../src/stores/display-event-adapter.ts | 232 + .../src/stores/event-handlers.test.ts | 132 + .../webview-ui/src/stores/event-handlers.ts | 565 ++ apps/vscode/webview-ui/src/stores/index.ts | 6 + .../webview-ui/src/stores/settings.store.ts | 322 + apps/vscode/webview-ui/src/styles/index.css | 187 + apps/vscode/webview-ui/src/vite-env.d.ts | 1 + apps/vscode/webview-ui/tsconfig.json | 23 + apps/vscode/webview-ui/vite.config.ts | 35 + docs/en/guides/ides.md | 21 +- docs/zh/guides/ides.md | 21 +- flake.nix | 8 + package.json | 3 + packages/acp-adapter/README.md | 4 + packages/acp-adapter/package.json | 9 + packages/acp-adapter/src/builtin-commands.ts | 42 +- packages/acp-adapter/src/events-map.ts | 49 + packages/acp-adapter/src/index.ts | 15 + packages/acp-adapter/src/protocol/index.ts | 48 + .../src/protocol/kimi-extensions.ts | 121 + packages/acp-adapter/src/session.ts | 339 + .../acp-adapter/src/slash-command-registry.ts | 345 + .../acp-adapter/test/protocol-surface.test.ts | 58 + .../acp-adapter/test/session-prompt.test.ts | 254 + .../acp-adapter/test/session-slash.test.ts | 80 +- .../test/slash-command-contract.test.ts | 27 + packages/acp-adapter/test/slash.test.ts | 11 +- packages/acp-adapter/tsdown.config.ts | 5 +- packages/node-sdk/src/rpc.ts | 8 + packages/node-sdk/src/session.ts | 5 + pnpm-lock.yaml | 6371 ++++++++++++++++- pnpm-workspace.yaml | 3 + vitest.config.ts | 15 +- 216 files changed, 32670 insertions(+), 414 deletions(-) create mode 100644 .changeset/kimi-acp-extension-notifications.md create mode 100644 .changeset/shared-slash-command-registry.md create mode 100644 apps/vscode/.vscodeignore create mode 100644 apps/vscode/LICENSE create mode 100644 apps/vscode/agent-display-model/package.json create mode 100644 apps/vscode/agent-display-model/src/copy-content.ts create mode 100644 apps/vscode/agent-display-model/src/effects.ts create mode 100644 apps/vscode/agent-display-model/src/events.ts create mode 100644 apps/vscode/agent-display-model/src/index.ts create mode 100644 apps/vscode/agent-display-model/src/model.ts create mode 100644 apps/vscode/agent-display-model/src/reducer.ts create mode 100644 apps/vscode/agent-display-model/src/tool-display.ts create mode 100644 apps/vscode/agent-display-model/test/copy-content.test.ts create mode 100644 apps/vscode/agent-display-model/test/display-fixtures.test.ts create mode 100644 apps/vscode/agent-display-model/test/fixtures/display-reducer-fixtures.ts create mode 100644 apps/vscode/agent-display-model/test/reducer.test.ts create mode 100644 apps/vscode/agent-display-model/test/tool-display.test.ts create mode 100644 apps/vscode/agent-display-model/tsconfig.json create mode 100644 apps/vscode/agent-display-model/vitest.config.ts create mode 100644 apps/vscode/agent-sdk/.npmignore create mode 100644 apps/vscode/agent-sdk/README.md create mode 100644 apps/vscode/agent-sdk/acp-legacy-events.ts create mode 100644 apps/vscode/agent-sdk/config.ts create mode 100644 apps/vscode/agent-sdk/errors.ts create mode 100644 apps/vscode/agent-sdk/history/context-extract.ts create mode 100644 apps/vscode/agent-sdk/index.ts create mode 100644 apps/vscode/agent-sdk/package.json create mode 100644 apps/vscode/agent-sdk/paths.ts create mode 100644 apps/vscode/agent-sdk/protocol.ts create mode 100644 apps/vscode/agent-sdk/schema.ts create mode 100644 apps/vscode/agent-sdk/session.ts create mode 100644 apps/vscode/agent-sdk/storage.ts create mode 100644 apps/vscode/agent-sdk/tests/acp-legacy-events.golden.test.ts create mode 100644 apps/vscode/agent-sdk/tests/acp-legacy-events.test.ts create mode 100644 apps/vscode/agent-sdk/tests/config.test.ts create mode 100644 apps/vscode/agent-sdk/tests/errors.test.ts create mode 100644 apps/vscode/agent-sdk/tests/fixtures/acp-legacy/index.ts create mode 100644 apps/vscode/agent-sdk/tests/fixtures/acp-legacy/kimi-compaction-completed.json create mode 100644 apps/vscode/agent-sdk/tests/fixtures/acp-legacy/kimi-compaction-started.json create mode 100644 apps/vscode/agent-sdk/tests/fixtures/acp-legacy/kimi-step-interrupted.json create mode 100644 apps/vscode/agent-sdk/tests/fixtures/acp-legacy/kimi-subagent-child-tool-call.json create mode 100644 apps/vscode/agent-sdk/tests/fixtures/acp-legacy/kimi-subagent-started.json create mode 100644 apps/vscode/agent-sdk/tests/fixtures/acp-legacy/session-request-permission-numeric-id.json create mode 100644 apps/vscode/agent-sdk/tests/fixtures/acp-legacy/session-update-agent-message.json create mode 100644 apps/vscode/agent-sdk/tests/fixtures/acp-legacy/session-update-agent-thought.json create mode 100644 apps/vscode/agent-sdk/tests/fixtures/acp-legacy/session-update-available-commands.json create mode 100644 apps/vscode/agent-sdk/tests/fixtures/acp-legacy/session-update-config-option.json create mode 100644 apps/vscode/agent-sdk/tests/fixtures/acp-legacy/session-update-plan.json create mode 100644 apps/vscode/agent-sdk/tests/fixtures/acp-legacy/session-update-tool-call-lifecycle.json create mode 100644 apps/vscode/agent-sdk/tests/fixtures/acp-legacy/session-update-unknown.json create mode 100644 apps/vscode/agent-sdk/tests/fixtures/acp-legacy/session-update-usage.json create mode 100644 apps/vscode/agent-sdk/tests/fixtures/acp-legacy/session-update-user-message.json create mode 100644 apps/vscode/agent-sdk/tests/protocol.test.ts create mode 100644 apps/vscode/agent-sdk/tests/schema.test.ts create mode 100644 apps/vscode/agent-sdk/tests/session.test.ts create mode 100644 apps/vscode/agent-sdk/tests/utils.test.ts create mode 100644 apps/vscode/agent-sdk/tsconfig.json create mode 100644 apps/vscode/agent-sdk/tsup.config.ts create mode 100644 apps/vscode/agent-sdk/utils.ts create mode 100644 apps/vscode/agent-sdk/vitest.config.ts create mode 100644 apps/vscode/dev.js create mode 100644 apps/vscode/esbuild.js create mode 100644 apps/vscode/eslint.config.mjs create mode 100644 apps/vscode/package.json create mode 100644 apps/vscode/resources/kimi-icon.svg create mode 100644 apps/vscode/scripts/check-vscode-runtime-boundaries.mjs create mode 100644 apps/vscode/scripts/check-vsix-contents.mjs create mode 100644 apps/vscode/shared/bridge.ts create mode 100644 apps/vscode/shared/errors.ts create mode 100644 apps/vscode/shared/types.ts create mode 100644 apps/vscode/shared/utils.ts create mode 100644 apps/vscode/src/KimiWebviewProvider.ts create mode 100644 apps/vscode/src/bridge-handler.ts create mode 100644 apps/vscode/src/config/vscode-settings.ts create mode 100644 apps/vscode/src/extension.ts create mode 100644 apps/vscode/src/handlers/chat.handler.ts create mode 100644 apps/vscode/src/handlers/cli.handler.ts create mode 100644 apps/vscode/src/handlers/config.handler.ts create mode 100644 apps/vscode/src/handlers/file.handler.ts create mode 100644 apps/vscode/src/handlers/index.ts create mode 100644 apps/vscode/src/handlers/mcp.handler.ts create mode 100644 apps/vscode/src/handlers/session.handler.ts create mode 100644 apps/vscode/src/handlers/types.ts create mode 100644 apps/vscode/src/handlers/workspace.handler.ts create mode 100644 apps/vscode/src/managers/cli.manager.ts create mode 100644 apps/vscode/src/managers/file.manager.ts create mode 100644 apps/vscode/src/managers/git.manager.ts create mode 100644 apps/vscode/src/managers/index.ts create mode 100644 apps/vscode/src/managers/mcp.manager.ts create mode 100644 apps/vscode/src/repositories/index.ts create mode 100644 apps/vscode/tsconfig.json create mode 100644 apps/vscode/webview-ui/components.json create mode 100644 apps/vscode/webview-ui/index.html create mode 100644 apps/vscode/webview-ui/package.json create mode 100644 apps/vscode/webview-ui/public/kimi-banner-dark.svg create mode 100644 apps/vscode/webview-ui/public/kimi-banner-light.svg create mode 100644 apps/vscode/webview-ui/public/kimi-logo.png create mode 100644 apps/vscode/webview-ui/src/App.tsx create mode 100644 apps/vscode/webview-ui/src/components/ActionMenu.tsx create mode 100644 apps/vscode/webview-ui/src/components/ApprovalDialog.tsx create mode 100644 apps/vscode/webview-ui/src/components/ChatArea.tsx create mode 100644 apps/vscode/webview-ui/src/components/ChatMessage.tsx create mode 100644 apps/vscode/webview-ui/src/components/ChatStatus.tsx create mode 100644 apps/vscode/webview-ui/src/components/CompactionCard.tsx create mode 100644 apps/vscode/webview-ui/src/components/ConfigErrorScreen.tsx create mode 100644 apps/vscode/webview-ui/src/components/CopyButton.tsx create mode 100644 apps/vscode/webview-ui/src/components/DisplayBlocks.tsx create mode 100644 apps/vscode/webview-ui/src/components/ErrorBoundary.tsx create mode 100644 apps/vscode/webview-ui/src/components/FileChangesBar.tsx create mode 100644 apps/vscode/webview-ui/src/components/FilePickerMenu.tsx create mode 100644 apps/vscode/webview-ui/src/components/Header.tsx create mode 100644 apps/vscode/webview-ui/src/components/InlineError.tsx create mode 100644 apps/vscode/webview-ui/src/components/KimiLogo.tsx create mode 100644 apps/vscode/webview-ui/src/components/KimiMascot.tsx create mode 100644 apps/vscode/webview-ui/src/components/MCPServersModal.tsx create mode 100644 apps/vscode/webview-ui/src/components/Markdown.tsx create mode 100644 apps/vscode/webview-ui/src/components/MediaPreviewModal.tsx create mode 100644 apps/vscode/webview-ui/src/components/MediaThumbnail.tsx create mode 100644 apps/vscode/webview-ui/src/components/PlanBlock.tsx create mode 100644 apps/vscode/webview-ui/src/components/SessionList.tsx create mode 100644 apps/vscode/webview-ui/src/components/SlashCommandMenu.tsx create mode 100644 apps/vscode/webview-ui/src/components/ThinkingBlock.tsx create mode 100644 apps/vscode/webview-ui/src/components/ThinkingButton.tsx create mode 100644 apps/vscode/webview-ui/src/components/ToolRenderers.tsx create mode 100644 apps/vscode/webview-ui/src/components/WelcomeScreen.tsx create mode 100644 apps/vscode/webview-ui/src/components/hooks/useExtensionImageUrl.ts create mode 100644 apps/vscode/webview-ui/src/components/index.ts create mode 100644 apps/vscode/webview-ui/src/components/inputarea/InputArea.tsx create mode 100644 apps/vscode/webview-ui/src/components/inputarea/hooks/useClickOutside.ts create mode 100644 apps/vscode/webview-ui/src/components/inputarea/hooks/useFilePicker.tsx create mode 100644 apps/vscode/webview-ui/src/components/inputarea/hooks/useMediaUpload.ts create mode 100644 apps/vscode/webview-ui/src/components/inputarea/hooks/useSlashMenu.ts create mode 100644 apps/vscode/webview-ui/src/components/inputarea/utils.ts create mode 100644 apps/vscode/webview-ui/src/components/ui/accordion.tsx create mode 100644 apps/vscode/webview-ui/src/components/ui/alert-dialog.tsx create mode 100644 apps/vscode/webview-ui/src/components/ui/badge.tsx create mode 100644 apps/vscode/webview-ui/src/components/ui/button.tsx create mode 100644 apps/vscode/webview-ui/src/components/ui/card.tsx create mode 100644 apps/vscode/webview-ui/src/components/ui/checkbox.tsx create mode 100644 apps/vscode/webview-ui/src/components/ui/combobox.tsx create mode 100644 apps/vscode/webview-ui/src/components/ui/command.tsx create mode 100644 apps/vscode/webview-ui/src/components/ui/context-menu.tsx create mode 100644 apps/vscode/webview-ui/src/components/ui/dialog.tsx create mode 100644 apps/vscode/webview-ui/src/components/ui/dropdown-menu.tsx create mode 100644 apps/vscode/webview-ui/src/components/ui/field.tsx create mode 100644 apps/vscode/webview-ui/src/components/ui/input-group.tsx create mode 100644 apps/vscode/webview-ui/src/components/ui/input.tsx create mode 100644 apps/vscode/webview-ui/src/components/ui/label.tsx create mode 100644 apps/vscode/webview-ui/src/components/ui/popover.tsx create mode 100644 apps/vscode/webview-ui/src/components/ui/scroll-area.tsx create mode 100644 apps/vscode/webview-ui/src/components/ui/select.tsx create mode 100644 apps/vscode/webview-ui/src/components/ui/separator.tsx create mode 100644 apps/vscode/webview-ui/src/components/ui/sonner.tsx create mode 100644 apps/vscode/webview-ui/src/components/ui/switch.tsx create mode 100644 apps/vscode/webview-ui/src/components/ui/tabs.tsx create mode 100644 apps/vscode/webview-ui/src/components/ui/textarea.tsx create mode 100644 apps/vscode/webview-ui/src/components/ui/tooltip.tsx create mode 100644 apps/vscode/webview-ui/src/hooks/useAppInit.ts create mode 100644 apps/vscode/webview-ui/src/hooks/useWelcomeHint.ts create mode 100644 apps/vscode/webview-ui/src/lib/content.ts create mode 100644 apps/vscode/webview-ui/src/lib/display-block-adapter.ts create mode 100644 apps/vscode/webview-ui/src/lib/id.ts create mode 100644 apps/vscode/webview-ui/src/lib/media-utils.ts create mode 100644 apps/vscode/webview-ui/src/lib/text-enrichment.ts create mode 100644 apps/vscode/webview-ui/src/lib/utils.ts create mode 100644 apps/vscode/webview-ui/src/main.tsx create mode 100644 apps/vscode/webview-ui/src/services/bridge.ts create mode 100644 apps/vscode/webview-ui/src/services/commands.ts create mode 100644 apps/vscode/webview-ui/src/services/config.ts create mode 100644 apps/vscode/webview-ui/src/services/index.ts create mode 100644 apps/vscode/webview-ui/src/services/recommended-mcp.ts create mode 100644 apps/vscode/webview-ui/src/stores/chat.store.test.ts create mode 100644 apps/vscode/webview-ui/src/stores/chat.store.ts create mode 100644 apps/vscode/webview-ui/src/stores/display-effects.test.ts create mode 100644 apps/vscode/webview-ui/src/stores/display-effects.ts create mode 100644 apps/vscode/webview-ui/src/stores/display-event-adapter.test.ts create mode 100644 apps/vscode/webview-ui/src/stores/display-event-adapter.ts create mode 100644 apps/vscode/webview-ui/src/stores/event-handlers.test.ts create mode 100644 apps/vscode/webview-ui/src/stores/event-handlers.ts create mode 100644 apps/vscode/webview-ui/src/stores/index.ts create mode 100644 apps/vscode/webview-ui/src/stores/settings.store.ts create mode 100644 apps/vscode/webview-ui/src/styles/index.css create mode 100644 apps/vscode/webview-ui/src/vite-env.d.ts create mode 100644 apps/vscode/webview-ui/tsconfig.json create mode 100644 apps/vscode/webview-ui/vite.config.ts create mode 100644 packages/acp-adapter/src/protocol/index.ts create mode 100644 packages/acp-adapter/src/protocol/kimi-extensions.ts create mode 100644 packages/acp-adapter/src/slash-command-registry.ts create mode 100644 packages/acp-adapter/test/protocol-surface.test.ts create mode 100644 packages/acp-adapter/test/slash-command-contract.test.ts diff --git a/.changeset/README.md b/.changeset/README.md index dd568888bd..1df5bb2725 100644 --- a/.changeset/README.md +++ b/.changeset/README.md @@ -30,6 +30,11 @@ All other workspace packages are private internal packages, are not published to - `@moonshot-ai/vis-server` - `@moonshot-ai/vis-web` - `kimi-migration-legacy` +- `@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 c430a5cee9..f629081567 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -11,7 +11,12 @@ "@moonshot-ai/vis", "@moonshot-ai/vis-server", "@moonshot-ai/vis-web", - "kimi-migration-legacy" + "kimi-migration-legacy", + "@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 0000000000..fcdc5f594b --- /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 0000000000..79494ba375 --- /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 ebbc4897f1..f2dfb36d96 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -80,6 +80,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 46dca949ef..d6ed63f02e 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,11 @@ docker-compose.yml docs/superpowers/ reports/ .superpowers/ +superpowers + +# VS Code extension build artifacts +apps/vscode/dist/ +apps/vscode/out/ +apps/vscode/.vscode-test/ +apps/vscode/.vscode-test-web/ +apps/vscode/*.vsix diff --git a/.oxlintrc.json b/.oxlintrc.json index 877553f49a..18e53e5f2a 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -148,6 +148,7 @@ "dist/", "coverage/", "node_modules/", + "apps/vscode/", "apps/*/scripts/", "docs/smoke-archive/", "*.generated.ts" diff --git a/AGENTS.md b/AGENTS.md index a9f77b457d..8d06b416c7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,6 +16,7 @@ This is a TypeScript monorepo built for agent-assisted development. Keep the roo - `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/kimi-web`: the browser web UI, a peer to the TUI. Vue 3 + Vite + vue-i18n; talks to the server over REST + WebSocket under `/api/v1`. It must not depend on `@moonshot-ai/agent-core` (wire types are re-implemented locally). See `apps/kimi-web/AGENTS.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, the in-process DI service layer (`src/services/`), 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 966e5ceb7a..e8bca262bf 100644 --- a/apps/kimi-code/src/tui/commands/registry.ts +++ b/apps/kimi-code/src/tui/commands/registry.ts @@ -3,6 +3,11 @@ import { homedir } from 'node:os'; import { basename, dirname, join, relative, resolve } from 'pathe'; 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'; @@ -132,155 +137,21 @@ function formatDirectoryCompletionValue(argumentPrefix: string, parentInput: str return `${join(parentInput, entryName)}/`; } -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, - argumentHint: '[on|off] | ', + 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: 'add-dir', - aliases: [], - description: 'Add or list an additional workspace directory', - priority: 60, - availability: 'idle-only', - argumentHint: '[list] | ', - completeArgs: addDirArgumentCompletions, - }, - { - 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, - argumentHint: '', - }, - { - name: 'goal', - aliases: [], - description: 'Start or manage an autonomous goal', - priority: 80, - argumentHint: '[status|pause|resume|cancel|replace|next] | ', + 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 + // completeArgs), so they don't need to be spelled out inline. completeArgs: goalArgumentCompletions, // status / pause / cancel are always available; creation, replacement, and // resume start (or restart) a turn and so are idle-only. @@ -292,112 +163,27 @@ 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, - argumentHint: '', - 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: 'web', - aliases: [], - description: 'Open the current session in the Web UI and exit the terminal', - priority: 40, - availability: 'always', - }, - { - name: 'exit', - aliases: ['quit', 'q'], - description: 'Exit the application', - priority: 20, - }, - { - name: 'version', - aliases: [], - description: 'Show version information', - priority: 20, - availability: 'always', + 'add-dir': { + completeArgs: addDirArgumentCompletions, }, -] as const satisfies readonly KimiSlashCommand[]; +}; + +function toKimiSlashCommand(command: SlashCommandDescriptor): KimiSlashCommand<SlashCommandName> { + 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<SlashCommandName>[] = 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 0000000000..965f2afd01 --- /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 0000000000..6102a1bc79 --- /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 0000000000..6fb7d1e3ae --- /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 0000000000..8811d87b96 --- /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<DisplayPart, { type: 'tool-call' }>): 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 0000000000..cf99187456 --- /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 0000000000..40c3059935 --- /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 0000000000..971caea9f0 --- /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 0000000000..033d68ef4b --- /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<string, unknown>; +} + +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 0000000000..8a71b94e9c --- /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<T extends DisplayPart>(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<DisplayBlock, { type: 'diff' }> => 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<DisplayPart, { type: 'plan' }> => 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<DisplayState['status']>): 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 0000000000..9a87eb26c8 --- /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<string, unknown> { + if (!argumentsText) { + return {}; + } + + try { + const parsed = JSON.parse(argumentsText) as unknown; + return parsed && typeof parsed === 'object' ? (parsed as Record<string, unknown>) : { 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, unknown>): string { + for (const key of GENERIC_SUMMARY_KEYS) { + const value = getSummaryString(args[key]); + if (value) { + return value; + } + } + return ''; +} + +export function getRichToolDisplayBlocks<T extends { type: string }>(blocks?: readonly T[]): T[] { + return (blocks ?? []).filter((block) => block.type !== 'brief'); +} + +export function findTodoDisplayBlock<T extends { type: string }>(blocks?: readonly T[]): Extract<T, { type: 'todo' }> | null { + const block = (blocks ?? []).find((candidate) => candidate.type === 'todo'); + return block ? (block as Extract<T, { type: 'todo' }>) : 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<string, unknown>; + 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<string, unknown>)['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<string, unknown>; + 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 0000000000..a9e596cfea --- /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 0000000000..792546184d --- /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<DisplayState>(state).toMatchObject(fixture.expectedState); + expect<DisplayEffect[]>(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 0000000000..91dfa62516 --- /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 0000000000..2f49fa7cab --- /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<typeof reduceDisplayEvent>[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 0000000000..ad873e48f3 --- /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 0000000000..1d7a4c2a9f --- /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 0000000000..1b8d2a3009 --- /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 0000000000..98873c51e9 --- /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 0000000000..a04d193396 --- /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<string, string>; // 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<string, string>; + + // Methods + prompt(content: string | ContentPart[]): Turn; + close(): Promise<void>; + [Symbol.asyncDispose](): Promise<void>; +} +``` + +#### `Turn` + +Represents an ongoing conversation turn. + +```typescript +interface Turn { + [Symbol.asyncIterator](): AsyncIterator<StreamEvent, RunResult, undefined>; + interrupt(): Promise<void>; + // 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<void>; + readonly result: Promise<RunResult>; +} +``` + +#### `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<string, unknown> | null; +} +``` + +#### `ToolResult` + +```typescript +interface ToolResult { + tool_call_id: string; + return_value: { + is_error: boolean; + output: string | ContentPart[]; + message: string; + display: DisplayBlock[]; + extras?: Record<string, unknown> | 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<string, unknown> }; // 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<SessionInfo[]>` + +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<boolean>` + +Deletes a session. Returns `true` if successful. + +#### `parseSessionEvents(workDir: string, sessionId: string): Promise<StreamEvent[]>` + +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<string, string>; + headers?: Record<string, string>; + 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<string, unknown>; +} + +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 0000000000..05f946c529 --- /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<AcpProtocolPermissionRequest["toolCall"]>; +type AcpPermissionOption = AcpProtocolPermissionRequest["options"][number]; + +export type AcpSessionNotification = Omit<AcpProtocolSessionNotification, "sessionId" | "update"> & { + 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<AcpProtocolPermissionRequest, "sessionId" | "toolCall" | "options"> { + sessionId?: string; + toolCall?: Partial<AcpPermissionToolCall> & { content?: AcpToolCallContent[] }; + options?: Array<Partial<AcpPermissionOption> & { 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<string, string>(); + private toolTitles = new Map<string, string>(); + private seenTools = new Set<string>(); + + 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<AcpSessionUpdate, { sessionUpdate: "tool_call" }>)] : []; + case "tool_call_update": + return typeof update.toolCallId === "string" ? this.toolCallUpdateToEvents(update as Extract<AcpSessionUpdate, { sessionUpdate: "tool_call_update" }>) : []; + 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<AcpSessionUpdate, { sessionUpdate: "usage_update" }>); + 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<AcpSessionUpdate, { sessionUpdate: "tool_call" }>): 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<AcpSessionUpdate, { sessionUpdate: "tool_call_update" }>): 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<string, unknown> => 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<string, unknown> => 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<AcpSessionUpdate, { sessionUpdate: "usage_update" }>): 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<string, unknown>) : 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<KimiCompactionNotification> | 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<KimiCompactionNotification> | 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<KimiSubagentNotification> | 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<string, unknown> | undefined { + return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : undefined; +} + +function tokenUsageFromMeta(value: unknown) { + if (!value || typeof value !== "object") { + return undefined; + } + + const record = value as Record<string, unknown>; + 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<string, unknown>; + 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<string, unknown>; + 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<string, unknown>; + 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<string, unknown>).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<string, unknown>).text === "string") { + return (entry as Record<string, unknown>).text; + } + return ""; + }) + .filter(Boolean) + .join("\n"); + const textItems = parseTodoListText(text); + return textItems ?? todoItemsFromArray(value); + } + if (typeof value !== "object") { + return []; + } + + const record = value as Record<string, unknown>; + 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<string, unknown>; + 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<string, unknown>; + 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 0000000000..ace74352f7 --- /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<typeof ConfigSchema>; + +// 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 0000000000..3d81c9f4d6 --- /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<string, unknown>, + ) { + 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<string, unknown>, + ) { + 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<number, CliErrorCodeType> = { + // 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 0000000000..3a75528b8a --- /dev/null +++ b/apps/vscode/agent-sdk/history/context-extract.ts @@ -0,0 +1,180 @@ +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 StreamEvent, 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<StreamEvent[]> { + const sessionDir = KimiPaths.sessionDir(workDir, sessionId); + const wireFile = path.join(sessionDir, "wire.jsonl"); + const contextFile = path.join(sessionDir, "context.jsonl"); + + // Try wire.jsonl first (complete event stream) + if (fs.existsSync(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 []; +} + +// Wire File Parser +async function parseWireFile(filePath: string): Promise<StreamEvent[]> { + 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 event = parseWireRecord(record); + if (event) { + events.push(event); + } + } catch { + // Skip invalid lines + } + } + + return events; +} + +function parseWireRecord(record: unknown): StreamEvent | null { + if (!record || typeof record !== "object") { + return null; + } + + const rec = record as Record<string, unknown>; + const message = rec.message as { type?: string; payload?: unknown } | undefined; + + if (!message?.type) { + return null; + } + + const result = parseEventPayload(message.type, message.payload); + if (!result.ok) { + return null; + } + + return cleanTurnBeginEvent(result.value); +} + +// Context File Parser +async function parseContextFile(filePath: string): Promise<StreamEvent[]> { + 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<string, unknown>; + const events: WireEvent[] = []; + + // Convert role-based context records to events + if (rec.role === "user" && rec.content) { + const userInput = + typeof rec.content === "string" || Array.isArray(rec.content) + ? cleanUserInput(rec.content as string | ContentPart[]) + : null; + if (!userInput) { + return events; + } + + events.push({ + type: "TurnBegin", + payload: { user_input: userInput }, + }); + } + + if (rec.role === "assistant" && rec.content) { + const content = rec.content; + if (typeof content === "string") { + events.push({ + type: "ContentPart", + payload: { type: "text", text: content }, + }); + } else if (Array.isArray(content)) { + for (const part of content) { + if (part && typeof part === "object" && "type" in part) { + const result = parseEventPayload("ContentPart", part); + if (result.ok) { + events.push(result.value); + } + } + } + } + } + + // Handle tool calls + if (rec.tool_calls && Array.isArray(rec.tool_calls)) { + for (const call of rec.tool_calls) { + if (call && typeof call === "object") { + const tc = call as Record<string, unknown>; + const fn = tc.function as Record<string, unknown> | 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, + }, + }, + }); + } + } + } + } + + return events; +} + +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 0000000000..61dc462929 --- /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 0000000000..06c713305b --- /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 0000000000..6065e3d77d --- /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 0000000000..a59352fb6d --- /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<string, string>; +} + +type AppliedConfig = Pick<ClientOptions, "model"> & { + thinking: boolean; + mode: AgentMode; +}; + +// Prompt Stream +export interface PromptStream { + events: AsyncIterable<StreamEvent>; + result: Promise<RunResult>; +} + +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<T>(): { + iterable: AsyncIterable<T>; + push: (value: T) => void; + finish: () => void; +} { + const queue: T[] = []; + const resolvers: Array<(result: IteratorResult<T>) => 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<string, PendingRequest>(); + + private pushEvent: ((event: StreamEvent) => void) | null = null; + private finishEvents: (() => void) | null = null; + private ready: Promise<void> | 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<AppliedConfig> | 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<string> { + 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<ClientOptions, "model" | "thinking" | "mode" | "yoloMode">): Promise<void> { + await this.ensureReady(); + await this.applyConfigRaw(options); + } + + private normalizeAppliedConfig(options: Pick<ClientOptions, "model" | "thinking" | "mode" | "yoloMode">): AppliedConfig { + return { + model: options.model, + thinking: options.thinking ?? false, + mode: normalizeMode(options), + }; + } + + private markKnownConfigApplied(configOptions: unknown[] | undefined, _options: Pick<ClientOptions, "model" | "thinking" | "mode" | "yoloMode">): void { + const knownConfig: Partial<AppliedConfig> = { ...(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<ClientOptions, "model" | "thinking" | "mode" | "yoloMode">): Promise<void> { + 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<void> { + if (!this.process) { + return; + } + + if (this.process.exitCode !== null || this.process.killed) { + this.cleanup(); + return; + } + + this.process.kill("SIGTERM"); + await new Promise<void>((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<StreamEvent>(); + + 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<void> { + 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<void> { + 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<void> { + 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<unknown> { + 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<string, unknown> => 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<string, unknown>; + 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<string, unknown>; + 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: `<image url="${part.image_url.url}" />` }); + } + } else if (part.type === "audio_url") { + blocks.push({ type: "text", text: `<audio url="${part.audio_url.url}" />` }); + } else if (part.type === "video_url") { + blocks.push({ type: "text", text: `<video url="${part.video_url.url}" />` }); + } + } + return blocks; +} + +function parseDataUrl(url: string): { mimeType: string; data: string } | null { + const match = /^data:([^;,]+);base64,(.*)$/s.exec(url); + if (!match) { + return null; + } + return { mimeType: match[1], data: match[2] }; +} + +function normalizeMode(options: Pick<ClientOptions, "mode" | "yoloMode">): AgentMode { + return normalizeAcpMode(options); +} + +function runResultFromStopReason(stopReason: string | undefined): RunResult { + if (stopReason === "cancelled") { + return { status: "cancelled" }; + } + if (stopReason === "max_turn_requests" || stopReason === "max_steps" || stopReason === "max_steps_reached") { + return { status: "max_steps_reached" }; + } + return { status: "finished" }; +} + +function formatRpcError(error: { message: string; data?: unknown }): string { + const details = typeof error.data === "object" && error.data && "details" in error.data ? String((error.data as { details?: unknown }).details) : ""; + return details ? `${error.message}: ${details}` : error.message; +} diff --git a/apps/vscode/agent-sdk/schema.ts b/apps/vscode/agent-sdk/schema.ts new file mode 100644 index 0000000000..46e157440e --- /dev/null +++ b/apps/vscode/agent-sdk/schema.ts @@ -0,0 +1,834 @@ +import { z } from "zod/v3"; + +// ============================================================================ +// Primitives +// ============================================================================ + +/** 审批响应类型 */ +export const ApprovalResponseSchema = z.enum(["approve", "approve_for_session", "reject"]); +/** + * 审批响应 + * - `approve`: 批准本次操作 + * - `approve_for_session`: 批准本会话中的同类操作 + * - `reject`: 拒绝操作 + */ +export type ApprovalResponse = z.infer<typeof ApprovalResponseSchema>; + +/** 审批响应参数:固定响应或动态 optionId */ +export type ApprovalResult = ApprovalResponse | { optionId: string }; + +/** ACP execution mode */ +export const AgentModeSchema = z.enum(["default", "plan", "auto", "yolo"]); +export type AgentMode = z.infer<typeof AgentModeSchema>; + +/** 消息内容片段 */ +export const ContentPartSchema = z.discriminatedUnion("type", [ + z.object({ + /** 文本类型 */ + type: z.literal("text"), + /** 文本内容 */ + text: z.string(), + }), + z.object({ + /** 思考类型,仅在思考模式下出现 */ + type: z.literal("think"), + /** 思考内容 */ + think: z.string(), + /** 加密的思考内容或签名 */ + encrypted: z.string().nullable().optional(), + }), + z.object({ + /** 图片类型 */ + type: z.literal("image_url"), + image_url: z.object({ + /** 图片 URL,通常是 data URI(如 data:image/png;base64,...) */ + url: z.string(), + /** 图片 ID,用于区分不同图片 */ + id: z.string().nullable().optional(), + }), + }), + z.object({ + /** 音频类型 */ + type: z.literal("audio_url"), + audio_url: z.object({ + /** 音频 URL,通常是 data URI(如 data:audio/aac;base64,...) */ + url: z.string(), + /** 音频 ID,用于区分不同音频 */ + id: z.string().nullable().optional(), + }), + }), + z.object({ + /** 视频类型 */ + type: z.literal("video_url"), + video_url: z.object({ + /** 视频 URL,通常是 data URI(如 data:video/mp4;base64,...) */ + url: z.string(), + /** 视频 ID,用于区分不同视频 */ + id: z.string().nullable().optional(), + }), + }), +]); +/** + * 消息内容片段 + * - `text`: 文本内容 + * - `think`: 思考内容(思考模式) + * - `image_url`: 图片 + * - `audio_url`: 音频 + * - `video_url`: 视频 + */ +export type ContentPart = z.infer<typeof ContentPartSchema>; + +/** Token 用量统计 */ +export const TokenUsageSchema = z.object({ + /** 输入 token 数(非缓存) */ + input_other: z.number(), + /** 输出 token 数 */ + output: z.number(), + /** 从缓存读取的输入 token 数 */ + input_cache_read: z.number(), + /** 写入缓存的输入 token 数 */ + input_cache_creation: z.number(), +}); +export type TokenUsage = z.infer<typeof TokenUsageSchema>; + +// ============================================================================ +// DisplayBlock +// ============================================================================ + +/** 简短文本显示块 */ +export const BriefBlockSchema = z.object({ + type: z.literal("brief"), + /** 简短的文本内容 */ + text: z.string(), +}); +export type BriefBlock = z.infer<typeof BriefBlockSchema>; + +/** 文件差异显示块 */ +export const DiffBlockSchema = z.object({ + type: z.literal("diff"), + /** 文件路径 */ + path: z.string(), + /** 原始内容 */ + old_text: z.string(), + /** 新内容 */ + new_text: z.string(), +}); +export type DiffBlock = z.infer<typeof DiffBlockSchema>; + +function normalizeTodoItemStatus(status: unknown): unknown { + if (status === undefined || status === null || status === "") { + return "pending"; + } + if (status === "completed" || status === "complete" || status === "finished") { + return "done"; + } + if (status === "active" || status === "running") { + return "in_progress"; + } + if (status === "todo" || status === "not_started") { + return "pending"; + } + return status; +} + +const TodoItemStatusSchema = z.preprocess(normalizeTodoItemStatus, z.enum(["pending", "in_progress", "done"])); + +function normalizeTodoItem(item: unknown): unknown { + if (typeof item === "string") { + return { title: item }; + } + if (!item || typeof item !== "object") { + return item; + } + + const record = item as Record<string, unknown>; + if (typeof record.title === "string") { + return record; + } + const title = record.content ?? record.text ?? record.name ?? record.task; + return typeof title === "string" ? { ...record, title } : record; +} + +const TodoItemSchema = z.preprocess( + normalizeTodoItem, + z.object({ + /** 待办事项标题 */ + title: z.string(), + /** 状态:pending | in_progress | done(兼容 completed/active 等状态) */ + status: TodoItemStatusSchema.optional().default("pending"), + }), +); + +/** 待办事项显示块 */ +export const TodoBlockSchema = z.object({ + type: z.literal("todo"), + /** 待办事项列表 */ + items: z.array(TodoItemSchema), +}); +export type TodoBlock = z.infer<typeof TodoBlockSchema>; + +/** 命令执行显示块 */ +export interface CommandBlock { + type: "command"; + language: string; + command: string; + cwd?: string; + description?: string; + danger?: string; +} + +export type FileOperation = "read" | "write" | "edit" | "glob" | "grep"; + +/** 文件操作显示块 */ +export interface FileOpBlock { + type: "file-op"; + operation: FileOperation; + path: string; + detail?: string; +} + +/** 文件内容显示块 */ +export interface FileContentBlock { + type: "file-content"; + path: string; + content: string; + language?: string; +} + +/** URL 请求显示块 */ +export interface UrlFetchBlock { + type: "url-fetch"; + url: string; + method?: string; +} + +/** 搜索显示块 */ +export interface SearchBlock { + type: "search"; + query: string; + scope?: string; +} + +export type InvocationKind = "agent" | "skill"; + +/** Agent / Skill 调用显示块 */ +export interface InvocationBlock { + type: "invocation"; + kind: InvocationKind; + name: string; + description?: string; +} + +/** 后台任务显示块 */ +export interface BackgroundTaskBlock { + type: "background-task"; + task_id: string; + /** Camel-case compatibility alias accepted from older/internal display payloads. */ + taskId?: string; + kind: string; + status: string; + description?: string; +} + +/** 未知类型显示块(fallback) */ +export interface UnknownBlock { + /** 类型标识 */ + type: string; + /** 原始数据 */ + data: Record<string, unknown>; +} + +/** + * 显示块联合类型 + * - `brief`: 简短文本 + * - `diff`: 文件差异 + * - `todo`: 待办事项 + * - `command` / `file-op` / `file-content` / `url-fetch` / `search` / `invocation` / `background-task`: approval 语义块 + * - 其他: UnknownBlock fallback + */ +export type DisplayBlock = + | BriefBlock + | DiffBlock + | TodoBlock + | CommandBlock + | FileOpBlock + | FileContentBlock + | UrlFetchBlock + | SearchBlock + | InvocationBlock + | BackgroundTaskBlock + | UnknownBlock; + +const FileOperationSchema = z.enum(["read", "write", "edit", "glob", "grep"]); +const InvocationKindSchema = z.enum(["agent", "skill"]); + +/** DisplayBlock 原始解析 schema */ +const RawDisplayBlockSchema = z + .object({ + type: z.string(), + text: z.string().optional(), + path: z.string().optional(), + old_text: z.string().optional(), + new_text: z.string().optional(), + items: z.array(TodoItemSchema).optional(), + language: z.string().optional(), + command: z.string().optional(), + cwd: z.string().optional(), + description: z.string().optional(), + danger: z.string().optional(), + operation: FileOperationSchema.optional(), + detail: z.string().optional(), + content: z.string().optional(), + url: z.string().optional(), + method: z.string().optional(), + query: z.string().optional(), + scope: z.string().optional(), + kind: z.string().optional(), + name: z.string().optional(), + task_id: z.string().optional(), + taskId: z.string().optional(), + status: z.string().optional(), + }) + .passthrough(); + +/** DisplayBlock schema,自动转换为强类型 */ +export const DisplayBlockSchema = RawDisplayBlockSchema.transform((raw): DisplayBlock => { + if (raw.type === "brief" && typeof raw.text === "string") { + return { type: "brief", text: raw.text }; + } + if (raw.type === "diff" && typeof raw.path === "string" && typeof raw.old_text === "string" && typeof raw.new_text === "string") { + return { type: "diff", path: raw.path, old_text: raw.old_text, new_text: raw.new_text }; + } + if (raw.type === "todo" && Array.isArray(raw.items)) { + return { type: "todo", items: raw.items }; + } + if (raw.type === "command" && typeof raw.command === "string") { + return { + type: "command", + language: raw.language ?? "bash", + command: raw.command, + ...(raw.cwd !== undefined ? { cwd: raw.cwd } : {}), + ...(raw.description !== undefined ? { description: raw.description } : {}), + ...(raw.danger !== undefined ? { danger: raw.danger } : {}), + }; + } + const fileOpDetail = raw.detail ?? raw.description; + if (raw.type === "file-op" && raw.operation !== undefined && typeof raw.path === "string") { + return { type: "file-op", operation: raw.operation, path: raw.path, ...(fileOpDetail !== undefined ? { detail: fileOpDetail } : {}) }; + } + if (raw.type === "file-content" && typeof raw.path === "string" && typeof raw.content === "string") { + return { type: "file-content", path: raw.path, content: raw.content, ...(raw.language !== undefined ? { language: raw.language } : {}) }; + } + if (raw.type === "url-fetch" && typeof raw.url === "string") { + return { type: "url-fetch", url: raw.url, ...(raw.method !== undefined ? { method: raw.method } : {}) }; + } + if (raw.type === "search" && typeof raw.query === "string") { + return { type: "search", query: raw.query, ...(raw.scope !== undefined ? { scope: raw.scope } : {}) }; + } + if (raw.type === "invocation" && typeof raw.name === "string" && InvocationKindSchema.safeParse(raw.kind).success) { + return { type: "invocation", kind: raw.kind as InvocationKind, name: raw.name, ...(raw.description !== undefined ? { description: raw.description } : {}) }; + } + const taskId = raw.task_id ?? raw.taskId; + if (raw.type === "background-task" && typeof taskId === "string") { + return { + type: "background-task", + task_id: taskId, + kind: raw.kind ?? "background", + status: raw.status ?? "unknown", + ...(raw.description !== undefined ? { description: raw.description } : {}), + }; + } + const { type, ...rest } = raw; + return { type, data: rest }; +}); + +// ============================================================================ +// Tool Types +// ============================================================================ + +/** 工具调用 */ +export const ToolCallSchema = z.object({ + /** 固定为 "function" */ + type: z.literal("function"), + /** 工具调用 ID,用于关联 ToolResult */ + id: z.string(), + function: z.object({ + /** 工具名称,如 "Shell"、"ReadFile"、"WriteFile" */ + name: z.string(), + /** JSON 格式的参数字符串,流式时可能不完整 */ + arguments: z.string().nullable().optional(), + }), + /** 额外信息 */ + extras: z.record(z.unknown()).nullable().optional(), +}); +export type ToolCall = z.infer<typeof ToolCallSchema>; + +/** 工具调用参数片段(流式) */ +export const ToolCallPartSchema = z.object({ + /** 对应的工具调用 ID */ + tool_call_id: z.string(), + /** 参数片段,追加到指定 ToolCall 的 arguments */ + arguments_part: z.string().nullable().optional(), +}); +export type ToolCallPart = z.infer<typeof ToolCallPartSchema>; + +/** 工具执行结果 */ +export const ToolResultSchema = z.object({ + /** 对应的工具调用 ID */ + tool_call_id: z.string(), + return_value: z.object({ + /** 是否为错误 */ + is_error: z.boolean(), + /** 返回给模型的输出内容,可以是纯文本或内容片段数组 */ + output: z.union([z.string(), z.array(ContentPartSchema)]), + /** 给模型的解释性消息 */ + message: z.string(), + /** 显示给用户的内容块 */ + display: z.array(DisplayBlockSchema), + /** 额外调试信息 */ + extras: z.record(z.unknown()).nullable().optional(), + }), +}); +export type ToolResult = z.infer<typeof ToolResultSchema>; + +// ============================================================================ +// Event Payloads +// ============================================================================ + +/** 轮次开始事件 */ +export const TurnBeginSchema = z.object({ + /** 用户输入,可以是纯文本或内容片段数组 */ + user_input: z.union([z.string(), z.array(ContentPartSchema)]), +}); +export type TurnBegin = z.infer<typeof TurnBeginSchema>; + +/** 步骤开始事件 */ +export const StepBeginSchema = z.object({ + /** 步骤编号,从 1 开始 */ + n: z.number(), +}); +export type StepBegin = z.infer<typeof StepBeginSchema>; + +/** 空 payload(用于 StepInterrupted, ConversationReset) */ +export const EmptyPayloadSchema = z.object({}); +/** 步骤被中断,无额外字段 */ +export type StepInterrupted = z.infer<typeof EmptyPayloadSchema>; +/** 上下文压缩开始 */ +export const CompactionBeginSchema = z.object({ + /** manual 或 auto 触发来源。 */ + trigger: z.union([z.literal("manual"), z.literal("auto")]).optional(), + /** 可选压缩指令。 */ + instruction: z.string().optional(), + /** 附加展示消息。 */ + message: z.string().optional(), +}); +export type CompactionBegin = z.infer<typeof CompactionBeginSchema>; +/** 上下文压缩结束 */ +export const CompactionEndSchema = z.object({ + /** 压缩结束状态。 */ + status: z.union([z.literal("completed"), z.literal("cancelled"), z.literal("blocked")]).optional(), + /** manual 或 auto 触发来源。 */ + trigger: z.union([z.literal("manual"), z.literal("auto")]).optional(), + /** 可选压缩指令。 */ + instruction: z.string().optional(), + /** 压缩摘要。 */ + summary: z.string().optional(), + /** 被压缩的上下文条数。 */ + compactedCount: z.number().optional(), + /** 压缩前 token 数。 */ + tokensBefore: z.number().optional(), + /** 压缩后 token 数。 */ + tokensAfter: z.number().optional(), + /** 附加展示消息。 */ + message: z.string().optional(), +}); +export type CompactionEnd = z.infer<typeof CompactionEndSchema>; +/** 会话上下文重置,无额外字段 */ +export type ConversationReset = z.infer<typeof EmptyPayloadSchema>; + +/** 状态更新事件 */ +export const StatusUpdateSchema = z.object({ + /** 上下文使用率,0-1 之间的浮点数;溢出时可能大于 1 */ + context_usage: z.number().nullable().optional(), + /** 当前上下文已使用 token 数 */ + context_tokens: z.number().nullable().optional(), + /** 当前模型上下文窗口 token 总数 */ + max_context_tokens: z.number().nullable().optional(), + /** 当前步骤的 token 用量统计 */ + token_usage: TokenUsageSchema.nullable().optional(), + /** 当前步骤的消息 ID */ + message_id: z.string().nullable().optional(), +}); +export type StatusUpdate = z.infer<typeof StatusUpdateSchema>; + +/** ACP 审批选项 */ +export const ApprovalOptionSchema = z.object({ + /** 选项 ID,用于响应时引用 */ + optionId: z.string(), + /** 显示名称 */ + name: z.string(), + /** 选项类型/分类 */ + kind: z.string().optional(), +}); +export type ApprovalOption = z.infer<typeof ApprovalOptionSchema>; + +/** 审批请求 payload */ +export const ApprovalRequestPayloadSchema = z.object({ + /** 请求 ID,用于响应时引用(ACP 使用 JSON-RPC id,可能是 number 且从 0 起) */ + id: z.union([z.string(), z.number()]), + /** 关联的工具调用 ID */ + tool_call_id: z.string(), + /** 发起者(工具名称),如 "Shell"、"WriteFile" */ + sender: z.string(), + /** 操作描述,如 "run shell command" */ + action: z.string(), + /** 详细说明,如 "Run command `rm -rf /`" */ + description: z.string(), + /** 显示给用户的内容块 */ + display: z.array(DisplayBlockSchema).optional(), + /** ACP 动态选项(plan-review 等场景) */ + options: z.array(ApprovalOptionSchema).optional(), +}); +export type ApprovalRequestPayload = z.infer<typeof ApprovalRequestPayloadSchema>; + +/** 审批请求已解决事件 */ +export const ApprovalRequestResolvedSchema = z.object({ + /** 已解决的审批请求 ID(ACP JSON-RPC id 可能是 number) */ + request_id: z.union([z.string(), z.number()]), + /** 审批结果:固定响应或动态 optionId */ + response: z.union([ApprovalResponseSchema, z.object({ optionId: z.string() })]), +}); +export type ApprovalRequestResolved = z.infer<typeof ApprovalRequestResolvedSchema>; + +/** ACP plan / TodoList entry */ +export const PlanEntrySchema = z.object({ + content: z.string(), + status: z.enum(["pending", "in_progress", "completed"]), + priority: z.enum(["low", "medium", "high"]).optional(), +}); +export type PlanEntry = z.infer<typeof PlanEntrySchema>; + +/** ACP plan update. Each update replaces the whole current plan. */ +export const PlanSchema = z.object({ + entries: z.array(PlanEntrySchema), +}); +export type Plan = z.infer<typeof PlanSchema>; + +/** ACP config option as reported by session/update config_option_update. */ +export const ConfigOptionSchema = z.object({}).passthrough(); +export type ConfigOption = z.infer<typeof ConfigOptionSchema>; + +/** ACP config option update. */ +export const ConfigOptionUpdateSchema = z.object({ + configOptions: z.array(ConfigOptionSchema), +}); +export type ConfigOptionUpdate = z.infer<typeof ConfigOptionUpdateSchema>; + +/** ACP available command entry */ +export const AvailableCommandSchema = z.object({ + name: z.string(), + description: z.string(), + group: z.string().optional(), +}); +export type AvailableCommand = z.infer<typeof AvailableCommandSchema>; + +/** ACP available commands update. */ +export const AvailableCommandsUpdateSchema = z.object({ + availableCommands: z.array(AvailableCommandSchema), +}); +export type AvailableCommandsUpdate = z.infer<typeof AvailableCommandsUpdateSchema>; + +// ============================================================================ +// Wire Events & Requests +// ============================================================================ + +/** + * Legacy wire event union consumed by the VS Code webview. + * Prefer ACP-native wire/display contracts for new cross-client semantics. + */ +export type WireEvent = + | { type: "TurnBegin"; payload: TurnBegin } + | { type: "StepBegin"; payload: StepBegin } + | { type: "StepInterrupted"; payload: StepInterrupted } + | { type: "CompactionBegin"; payload: CompactionBegin } + | { type: "CompactionEnd"; payload: CompactionEnd } + | { type: "ConversationReset"; payload: ConversationReset } + | { type: "StatusUpdate"; payload: StatusUpdate } + | { type: "ContentPart"; payload: ContentPart } + | { type: "ToolCall"; payload: ToolCall } + | { type: "ToolCallPart"; payload: ToolCallPart } + | { type: "ToolResult"; payload: ToolResult } + | { type: "SubagentEvent"; payload: SubagentEvent } + | { type: "ApprovalRequestResolved"; payload: ApprovalRequestResolved } + | { type: "Plan"; payload: Plan } + | { type: "ConfigOptionUpdate"; payload: ConfigOptionUpdate } + | { type: "AvailableCommandsUpdate"; payload: AvailableCommandsUpdate }; + +/** 子 Agent 事件 */ +export interface SubagentEvent { + /** 关联的 Task 工具调用 ID */ + task_tool_call_id: string; + /** 子 Agent 产生的事件,嵌套的 Wire 消息格式,可能多层嵌套 */ + event: WireEvent; +} + +/** + * Legacy wire request union consumed by the VS Code webview. + * New ACP request semantics should be mapped through the compatibility layer explicitly. + */ +export type WireRequest = { type: "ApprovalRequest"; payload: ApprovalRequestPayload }; + +/** 事件类型 -> schema 映射 */ +export const EventSchemas: Record<string, z.ZodSchema> = { + TurnBegin: TurnBeginSchema, + StepBegin: StepBeginSchema, + StepInterrupted: EmptyPayloadSchema, + CompactionBegin: CompactionBeginSchema, + CompactionEnd: CompactionEndSchema, + ConversationReset: EmptyPayloadSchema, + StatusUpdate: StatusUpdateSchema, + ContentPart: ContentPartSchema, + ToolCall: ToolCallSchema, + ToolCallPart: ToolCallPartSchema, + ToolResult: ToolResultSchema, + ApprovalRequestResolved: ApprovalRequestResolvedSchema, + Plan: PlanSchema, + ConfigOptionUpdate: ConfigOptionUpdateSchema, + AvailableCommandsUpdate: AvailableCommandsUpdateSchema, +}; + +/** 请求类型 -> schema 映射 */ +export const RequestSchemas: Record<string, z.ZodSchema> = { + ApprovalRequest: ApprovalRequestPayloadSchema, +}; + +/** 解析 Wire 事件(内部使用) */ +function parseWireEvent(raw: { type: string; payload?: unknown }): WireEvent | null { + const result = parseEventPayload(raw.type, raw.payload); + return result.ok ? result.value : null; +} + +/** SubagentEvent schema */ +export const SubagentEventSchema = z.lazy(() => + z.object({ + /** 关联的 Task 工具调用 ID */ + task_tool_call_id: z.string(), + /** 子 Agent 产生的事件 */ + event: z.object({ type: z.string(), payload: z.unknown() }).transform((raw): WireEvent => { + const result = parseWireEvent(raw); + if (!result) { + return { type: "StepInterrupted", payload: {} }; + } + return result; + }), + }), +); +EventSchemas.SubagentEvent = SubagentEventSchema; + +// ============================================================================ +// Stream Event +// ============================================================================ + +/** 协议解析错误 */ +export interface ParseError { + type: "error"; + /** 错误代码 */ + code: string; + /** 错误消息 */ + message: string; + /** 原始数据(截断至 500 字符) */ + raw?: string; +} + +/** + * Legacy stream event union returned by the VS Code SDK Turn iterator. + * Includes legacy WireEvent, WireRequest, and parse errors. + */ +export type StreamEvent = WireEvent | WireRequest | ParseError; +export type LegacyWireEvent = WireEvent; +export type LegacyWireRequest = WireRequest; +export type LegacyStreamEvent = StreamEvent; + +// ============================================================================ +// Run Result +// ============================================================================ + +/** 轮次运行结果 */ +export const RunResultSchema = z.object({ + /** + * 完成状态 + * - `finished`: 轮次正常完成 + * - `cancelled`: 轮次被 cancel 取消 + * - `max_steps_reached`: 达到最大步数限制 + */ + status: z.enum(["finished", "cancelled", "max_steps_reached"]), + /** 当 status 为 max_steps_reached 时,返回已执行的步数 */ + steps: z.number().optional(), +}); +export type RunResult = z.infer<typeof RunResultSchema>; + +// ============================================================================ +// RPC Messages +// ============================================================================ + +/** RPC 错误 */ +export const RpcErrorSchema = z.object({ + /** 错误代码 */ + code: z.number(), + /** 错误消息 */ + message: z.string(), + /** 额外数据 */ + data: z.unknown().optional(), +}); +export type RpcError = z.infer<typeof RpcErrorSchema>; + +/** RPC 消息(请求、通知或响应) */ +export const RpcMessageSchema = z.object({ + jsonrpc: z.string().optional(), + /** JSON-RPC id:ACP 的请求/响应 id 可能是 string 或 number(含 0) */ + id: z.union([z.string(), z.number()]).optional(), + method: z.string().optional(), + params: z.unknown().optional(), + result: z.unknown().optional(), + error: RpcErrorSchema.optional(), +}); +export type RpcMessage = z.infer<typeof RpcMessageSchema>; + +// ============================================================================ +// Config Types +// ============================================================================ + +/** 模型配置 */ +export interface ModelConfig { + /** 模型 ID,用于 API 调用 */ + id: string; + /** 模型显示名称 */ + name: string; + /** 模型能力列表,如 ["thinking", "image_in", "video_in"] */ + capabilities: string[]; +} + +/** Kimi 配置 */ +export interface KimiConfig { + /** 默认模型 ID */ + defaultModel: string | null; + /** 默认思考模式 */ + defaultThinking: boolean; + /** 可用模型列表 */ + models: ModelConfig[]; +} + +/** MCP 服务器配置 */ +export interface MCPServerConfig { + /** 服务器名称,用于标识 */ + name: string; + /** 传输方式 */ + transport: "http" | "stdio"; + /** HTTP 传输时的服务器 URL */ + url?: string; + /** stdio 传输时的启动命令 */ + command?: string; + /** stdio 传输时的命令参数 */ + args?: string[]; + /** 环境变量 */ + env?: Record<string, string>; + /** HTTP 请求头 */ + headers?: Record<string, string>; + /** 认证方式,目前仅支持 "oauth" */ + auth?: "oauth"; +} + +// ============================================================================ +// Session Types +// ============================================================================ + +/** 会话选项 */ +export interface SessionOptions { + /** 工作目录路径,必填 */ + workDir: string; + /** 会话 ID,不提供则自动生成 UUID */ + sessionId?: string; + /** 模型 ID */ + model?: string; + /** 是否启用思考模式,默认 false */ + thinking?: boolean; + /** ACP execution mode,默认 "default" */ + mode?: AgentMode; + /** 是否自动批准所有操作,默认 false */ + yoloMode?: boolean; + /** CLI 可执行文件路径,默认 "kimi" */ + executable?: string; + /** 传递给 CLI 的环境变量 */ + env?: Record<string, string>; +} + +/** 会话信息 */ +export interface SessionInfo { + /** 会话 ID */ + id: string; + /** 工作目录 */ + workDir: string; + /** 上下文文件路径 */ + contextFile: string; + /** 最后更新时间戳(毫秒) */ + updatedAt: number; + /** 第一条用户消息的摘要 */ + brief: string; +} + +// ============================================================================ +// Context Record (for history parsing) +// ============================================================================ + +/** 上下文记录(用于解析历史) */ +export const ContextRecordSchema = z.object({ + role: z.string().optional(), + content: z.unknown().optional(), + tool_calls: z + .array( + z.object({ + id: z.string().optional(), + function: z + .object({ + name: z.string().optional(), + arguments: z.union([z.string(), z.record(z.unknown())]).optional(), + }) + .optional(), + }), + ) + .optional(), + tool_call_id: z.string().optional(), +}); +export type ContextRecord = z.infer<typeof ContextRecordSchema>; + +// ============================================================================ +// Parse Helpers +// ============================================================================ + +type Result<T> = { ok: true; value: T } | { ok: false; error: string }; + +/** 解析事件 payload */ +export function parseEventPayload(type: string, payload: unknown): Result<WireEvent> { + const schema = EventSchemas[type]; + if (!schema) { + return { ok: false, error: `Unknown event type: ${type}` }; + } + const result = schema.safeParse(payload); + if (!result.success) { + return { ok: false, error: `Invalid payload for ${type}: ${result.error.message}` }; + } + return { ok: true, value: { type, payload: result.data } as WireEvent }; +} + +/** 解析请求 payload */ +export function parseRequestPayload(type: string, payload: unknown): Result<WireRequest> { + const schema = RequestSchemas[type]; + if (!schema) { + return { ok: false, error: `Unknown request type: ${type}` }; + } + const result = schema.safeParse(payload); + if (!result.success) { + return { ok: false, error: `Invalid payload for ${type}: ${result.error.message}` }; + } + return { ok: true, value: { type, payload: result.data } as WireRequest }; +} diff --git a/apps/vscode/agent-sdk/session.ts b/apps/vscode/agent-sdk/session.ts new file mode 100644 index 0000000000..8118110210 --- /dev/null +++ b/apps/vscode/agent-sdk/session.ts @@ -0,0 +1,406 @@ +import * as crypto from "node:crypto"; +import { ProtocolClient } from "./protocol"; +import { SessionError } from "./errors"; +import type { SessionOptions, ContentPart, StreamEvent, RunResult, ApprovalResult, AgentMode } from "./schema"; + +export type SessionState = "idle" | "active" | "closed"; + +/** 当前生效的配置快照 */ +interface ActiveConfig { + sessionId: string | undefined; + model: string | undefined; + thinking: boolean; + mode: AgentMode; + executable: string; + env: string; // JSON stringified for comparison +} + +/** Turn 接口,代表一次对话轮次 */ +export interface Turn { + /** 异步迭代事件流,迭代完成后返回 RunResult */ + [Symbol.asyncIterator](): AsyncIterator<StreamEvent, RunResult, undefined>; + /** 中断当前轮次,清空消息队列 */ + interrupt(): Promise<void>; + /** 响应审批请求(ACP 的 JSON-RPC id 可能是 number 且从 0 起,需原样回写) */ + approve(requestId: string | number, response: ApprovalResult): Promise<void>; + /** 轮次完成后的结果 Promise */ + readonly result: Promise<RunResult>; +} + +/** Session 接口,代表一个与 Kimi Code 的持久连接 */ +export interface Session { + /** 会话 ID */ + readonly sessionId: string; + /** 工作目录 */ + readonly workDir: string; + /** 当前状态:idle | active | closed */ + readonly state: SessionState; + /** 模型 ID,可在轮次间修改 */ + model: string | undefined; + /** 是否启用思考模式,可在轮次间修改 */ + thinking: boolean; + /** ACP execution mode,可在轮次间修改 */ + mode: AgentMode; + /** 是否自动批准操作,可在轮次间修改 */ + yoloMode: boolean; + /** CLI 可执行文件路径,可在轮次间修改 */ + executable: string; + /** 环境变量,可在轮次间修改 */ + env: Record<string, string>; + /** 发送消息,返回 Turn 对象 */ + prompt(content: string | ContentPart[]): Turn; + /** 取消当前回复但保留已排队消息,下一排队消息立即发送 */ + steer(): Promise<void>; + /** 确保 ACP 会话已创建 / 加载,并同步真实 sessionId */ + ensureStarted(): Promise<void>; + /** 立即把当前 model/thinking/mode 通过 set_config_option 热更新到运行中的会话(可在轮次进行中调用);进程未运行时为 no-op */ + applyConfigNow(): Promise<void>; + /** 取出启动 / 加载会话时 ACP replay 产生的事件 */ + consumeBufferedEvents(): StreamEvent[]; + /** 关闭会话,释放资源 */ + close(): Promise<void>; + /** 支持 using 语法自动关闭 */ + [Symbol.asyncDispose](): Promise<void>; +} + +class TurnImpl implements Turn { + readonly result: Promise<RunResult>; + private resolveResult!: (result: RunResult) => void; + private rejectResult!: (error: Error) => void; + private interrupted = false; + + constructor( + private getClient: () => Promise<ProtocolClient>, + private getCurrentClient: () => ProtocolClient | null, + private getNextPending: () => (string | ContentPart[]) | undefined, + private clearPending: () => void, + private onComplete: () => void, + ) { + const promise = new Promise<RunResult>((resolve, reject) => { + this.resolveResult = resolve; + this.rejectResult = reject; + }); + this.result = promise; + promise.catch(() => {}); + } + + async *[Symbol.asyncIterator](): AsyncIterator<StreamEvent, RunResult, undefined> { + try { + let result: RunResult | undefined; + let content: string | ContentPart[] | undefined; + while (!this.interrupted && (content = this.getNextPending()) !== undefined) { + result = yield* this.processOne(content); + } + this.onComplete(); + this.resolveResult(result!); + return result!; + } catch (err) { + this.onComplete(); + this.rejectResult(err as Error); + throw err; + } + } + + private async *processOne(content: string | ContentPart[]): AsyncGenerator<StreamEvent, RunResult, undefined> { + const client = await this.getClient(); + const stream = client.sendPrompt(content); + for await (const event of stream.events) { + yield event; + } + return await stream.result; + } + + async interrupt(): Promise<void> { + this.interrupted = true; + this.clearPending(); + const client = this.getCurrentClient(); + if (client?.isRunning) { + return client.sendCancel(); + } + } + + async approve(requestId: string | number, response: ApprovalResult): Promise<void> { + const client = this.getCurrentClient(); + if (!client?.isRunning) { + throw new SessionError("SESSION_CLOSED", "Cannot approve: no active client"); + } + return client.sendApproval(requestId, response); + } +} + +class SessionImpl implements Session { + private _sessionId: string | undefined; + private readonly _initialSessionId: string | undefined; + private readonly _workDir: string; + private _model: string | undefined; + private _thinking: boolean; + private _mode: AgentMode; + private _executable: string; + private _env: Record<string, string>; + private _state: SessionState = "idle"; + + private client: ProtocolClient | null = null; + private activeConfig: ActiveConfig | null = null; + private getClientWithConfigCheckPromise: Promise<ProtocolClient> | null = null; + private currentTurn: TurnImpl | null = null; + private pendingMessages: (string | ContentPart[])[] = []; + + constructor(options: SessionOptions) { + // Keep the user-supplied sessionId so we can choose session/load vs + // session/new when starting the ACP handshake. A placeholder random UUID + // is generated only for local identity before the handshake completes. + this._initialSessionId = options.sessionId; + this._sessionId = options.sessionId ?? crypto.randomUUID(); + this._workDir = options.workDir; + this._model = options.model; + this._thinking = options.thinking ?? false; + this._mode = normalizeMode(options.mode, options.yoloMode); + this._executable = options.executable ?? "kimi"; + this._env = options.env ?? {}; + } + + get sessionId(): string { + return this._sessionId ?? ""; + } + get workDir(): string { + return this._workDir; + } + get state(): SessionState { + return this._state; + } + get model(): string | undefined { + return this._model; + } + set model(v: string | undefined) { + this._model = v; + } + get thinking(): boolean { + return this._thinking; + } + set thinking(v: boolean) { + this._thinking = v; + } + get mode(): AgentMode { + return this._mode; + } + set mode(v: AgentMode) { + this._mode = normalizeMode(v); + } + get yoloMode(): boolean { + return this._mode === "yolo"; + } + set yoloMode(v: boolean) { + this._mode = v ? "yolo" : "default"; + } + get executable(): string { + return this._executable; + } + set executable(v: string) { + this._executable = v; + } + get env(): Record<string, string> { + return this._env; + } + set env(v: Record<string, string>) { + this._env = v; + } + + prompt(content: string | ContentPart[]): Turn { + if (this._state === "closed") { + throw new SessionError("SESSION_CLOSED", "Session is closed"); + } + + this.pendingMessages.push(content); + + if (this._state === "active" && this.currentTurn) { + return this.currentTurn; + } + + this._state = "active"; + this.currentTurn = new TurnImpl( + () => this.getClientWithConfigCheck(), + () => this.client, + () => this.pendingMessages.shift(), + () => { + this.pendingMessages = []; + }, + () => { + if (this._state === "active") { + this._state = "idle"; + } + this.currentTurn = null; + }, + ); + + return this.currentTurn; + } + + async steer(): Promise<void> { + if (this._state !== "active" || !this.currentTurn || !this.client?.isRunning || this.pendingMessages.length === 0) { + return; + } + + await this.client.sendCancel(); + } + + async close(): Promise<void> { + if (this._state === "closed") { + return; + } + this._state = "closed"; + this.currentTurn = null; + this.pendingMessages = []; + this.getClientWithConfigCheckPromise = null; + + if (this.client) { + try { + await this.client.stop(); + } catch (err) { + console.warn("[session] Error during close:", err); + } + this.client = null; + this.activeConfig = null; + } + } + + [Symbol.asyncDispose](): Promise<void> { + return this.close(); + } + + async ensureStarted(): Promise<void> { + const client = await this.getClientWithConfigCheck(); + if (typeof client.ensureReady === "function") { + await client.ensureReady(); + } + const sessionId = "sessionId" in client ? client.sessionId : null; + if (sessionId) { + this._sessionId = sessionId; + } + } + + consumeBufferedEvents(): StreamEvent[] { + return this.client?.consumeBufferedEvents() ?? []; + } + + async applyConfigNow(): Promise<void> { + if (!this.client?.isRunning || typeof this.client.applyConfig !== "function") { + return; + } + await this.client.applyConfig({ + model: this._model, + thinking: this._thinking, + mode: this._mode, + }); + this.activeConfig = this.snapshotConfig(); + } + + private async getClientWithConfigCheck(): Promise<ProtocolClient> { + if (!this.getClientWithConfigCheckPromise) { + this.getClientWithConfigCheckPromise = this.doGetClientWithConfigCheck().finally(() => { + this.getClientWithConfigCheckPromise = null; + }); + } + return this.getClientWithConfigCheckPromise; + } + + private async doGetClientWithConfigCheck(): Promise<ProtocolClient> { + const currentConfig = this.snapshotConfig(); + + if (this.client?.isRunning && this.activeConfig && !this.needsRestart(currentConfig)) { + if (this.configChanged(currentConfig)) { + if (typeof this.client.applyConfig === "function") { + await this.client.applyConfig({ + model: this._model, + thinking: this._thinking, + mode: this._mode, + }); + } + this.activeConfig = currentConfig; + } + return this.client; + } + + // Config changed or no client, restart + if (this.client) { + await this.client.stop(); + this.client = null; + } + + this.client = new ProtocolClient(); + this.client.start({ + sessionId: this._initialSessionId ? this._sessionId : undefined, + workDir: this._workDir, + model: this._model, + thinking: this._thinking, + mode: this._mode, + executablePath: this._executable, + environmentVariables: this._env, + }); + this.activeConfig = currentConfig; + if (typeof this.client.ensureReady === "function") { + await this.client.ensureReady(); + } + const clientSessionId = "sessionId" in this.client ? this.client.sessionId : null; + if (clientSessionId) { + this._sessionId = clientSessionId; + this.activeConfig = this.snapshotConfig(); + } + + return this.client; + } + + private snapshotConfig(): ActiveConfig { + return { + sessionId: this._sessionId, + model: this._model, + thinking: this._thinking, + mode: this._mode, + executable: this._executable, + env: JSON.stringify(this._env), + }; + } + + private configChanged(current: ActiveConfig): boolean { + const active = this.activeConfig!; + return ( + current.sessionId !== active.sessionId || + current.model !== active.model || + current.thinking !== active.thinking || + current.mode !== active.mode || + current.executable !== active.executable || + current.env !== active.env + ); + } + + private needsRestart(current: ActiveConfig): boolean { + const active = this.activeConfig!; + return current.sessionId !== active.sessionId || current.executable !== active.executable || current.env !== active.env; + } +} + +/** Start New Session */ +export function createSession(options: SessionOptions): Session { + return new SessionImpl(options); +} + +function normalizeMode(mode?: AgentMode, yoloMode?: boolean): AgentMode { + if (mode === "default" || mode === "plan" || mode === "auto" || mode === "yolo") { + return mode; + } + return yoloMode ? "yolo" : "default"; +} + +/** One-time run: create session, send message, collect all events, and automatically close session after returning result */ +export async function prompt(content: string | ContentPart[], options: Omit<SessionOptions, "sessionId">): Promise<{ result: RunResult; events: StreamEvent[] }> { + const session = createSession(options); + try { + const turn = session.prompt(content); + const events: StreamEvent[] = []; + for await (const event of turn) { + events.push(event); + } + return { result: await turn.result, events }; + } finally { + await session.close(); + } +} diff --git a/apps/vscode/agent-sdk/storage.ts b/apps/vscode/agent-sdk/storage.ts new file mode 100644 index 0000000000..ad080c4caa --- /dev/null +++ b/apps/vscode/agent-sdk/storage.ts @@ -0,0 +1,160 @@ +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 type { SessionInfo, ContentPart } from "./schema"; +import { cleanSystemTags } from "./utils"; + +// Constants +const SESSION_ID_REGEX = /^(?:session_)?[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +// List Sessions (Async) +export async function listSessions(workDir: string): Promise<SessionInfo[]> { + const sessionsDir = KimiPaths.sessionsDir(workDir); + + try { + await fsp.access(sessionsDir); + } catch { + return []; + } + + let entries: fs.Dirent[]; + try { + entries = await fsp.readdir(sessionsDir, { withFileTypes: true }); + } catch (err) { + console.warn("[storage] Failed to read sessions:", err); + return []; + } + + const sessions: SessionInfo[] = []; + + for (const entry of entries) { + if (!entry.isDirectory() || !SESSION_ID_REGEX.test(entry.name)) { + continue; + } + + const sessionId = entry.name; + const sessionDir = path.join(sessionsDir, sessionId); + const wireFile = path.join(sessionDir, "wire.jsonl"); + const contextFile = path.join(sessionDir, "context.jsonl"); + const stateFile = path.join(sessionDir, "state.json"); + + const targetFile = fs.existsSync(wireFile) ? wireFile : fs.existsSync(contextFile) ? contextFile : stateFile; + if (!fs.existsSync(targetFile)) { + continue; + } + + try { + const stat = await fsp.stat(targetFile); + const brief = await getFirstUserMessage(sessionDir); + + sessions.push({ + id: sessionId, + workDir, + contextFile: targetFile, + updatedAt: stat.mtimeMs, + brief, + }); + } catch (err) { + console.warn(`[storage] Failed to stat ${sessionId}:`, err); + } + } + + return sessions.sort((a, b) => b.updatedAt - a.updatedAt); +} + +// Delete Session +export async function deleteSession(workDir: string, sessionId: string): Promise<boolean> { + const sessionDir = path.join(KimiPaths.sessionsDir(workDir), sessionId); + + try { + await fsp.access(sessionDir); + } catch { + return false; + } + + try { + await fsp.rm(sessionDir, { recursive: true, force: true }); + return true; + } catch (err) { + console.warn(`[storage] Failed to delete ${sessionId}:`, err); + return false; + } +} + +// Get First User Message (Stream-based, early exit) +async function getFirstUserMessage(sessionDir: string): Promise<string> { + const wireFile = path.join(sessionDir, "wire.jsonl"); + const contextFile = path.join(sessionDir, "context.jsonl"); + const stateFile = path.join(sessionDir, "state.json"); + + if (fs.existsSync(stateFile)) { + try { + const state = JSON.parse(fs.readFileSync(stateFile, "utf-8")) as { title?: string; lastPrompt?: string }; + return (state.title || state.lastPrompt || "").trim(); + } catch { + return ""; + } + } + + // Try wire.jsonl first, fallback to context.jsonl + const targetFile = fs.existsSync(wireFile) ? wireFile : fs.existsSync(contextFile) ? contextFile : null; + if (!targetFile) { + return ""; + } + + try { + const stream = fs.createReadStream(targetFile, { 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); + if (record.message?.type !== "TurnBegin") { + continue; + } + + const userInput = record.message.payload?.user_input; + const text = extractUserText(userInput); + if (text) { + rl.close(); + stream.destroy(); + return text; + } + } catch { + continue; + } + } + } catch (err) { + console.warn("[storage] Failed to read wire file:", err); + } + + return ""; +} + +// Text Extraction Helpers +function extractUserText(userInput: unknown): string { + if (typeof userInput === "string") { + return cleanSystemTags(stripFileTags(userInput)); + } + + if (Array.isArray(userInput)) { + const textParts = (userInput as ContentPart[]).filter((p): p is ContentPart & { type: "text" } => p.type === "text").map((p) => p.text); + return cleanSystemTags(stripFileTags(textParts.join("\n"))); + } + + return ""; +} + +function stripFileTags(text: string): string { + return text + .replace(/<uploaded_files>[\s\S]*?<\/uploaded_files>\s*/g, "") + .replace(/<document[^>]*>[\s\S]*?<\/document>\s*/g, "") + .replace(/<image[^>]*>[\s\S]*?<\/image>\s*/g, "") + .trim(); +} diff --git a/apps/vscode/agent-sdk/tests/acp-legacy-events.golden.test.ts b/apps/vscode/agent-sdk/tests/acp-legacy-events.golden.test.ts new file mode 100644 index 0000000000..cdfb6e6481 --- /dev/null +++ b/apps/vscode/agent-sdk/tests/acp-legacy-events.golden.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from "vitest"; +import { + AcpLegacyEventTranslator, + type AcpPermissionRequest, + type AcpSessionNotification, + type AcpSessionUpdate, + type AcpTranslateOptions, +} from "../acp-legacy-events"; +import type { StreamEvent } from "../schema"; +import { acpLegacyFixtures } from "./fixtures/acp-legacy"; + +type SessionUpdateFixture = { + name: string; + method: "session/update"; + params: AcpSessionNotification; + options?: AcpTranslateOptions; + expectedEvents: StreamEvent[]; + suppressedOptions?: AcpTranslateOptions; + expectedSuppressedEvents?: StreamEvent[]; + expectedUnknownSessionUpdate?: string; +}; + +type SessionUpdateSequenceFixture = { + name: string; + method: "session/update_sequence"; + params: { + sessionId?: string; + updates: AcpSessionNotification[]; + }; + expectedEvents: StreamEvent[]; +}; + +type ExtensionNotificationFixture = { + name: string; + method: "kimi/step_interrupted" | "kimi/compaction" | "kimi/subagent_event"; + params: unknown; + expectedEvents: StreamEvent[]; +}; + +type PermissionRequestFixture = { + name: string; + method: "session/request_permission"; + id: string | number; + params: AcpPermissionRequest; + expectedEvent: StreamEvent; +}; + +type Fixture = SessionUpdateFixture | SessionUpdateSequenceFixture | ExtensionNotificationFixture | PermissionRequestFixture; + +describe("AcpLegacyEventTranslator golden fixtures", () => { + for (const fixture of acpLegacyFixtures as Fixture[]) { + it(fixture.name, () => { + if (fixture.method === "session/update") { + const translator = new AcpLegacyEventTranslator(); + const unknownSessionUpdates: string[] = []; + const events = translator.sessionUpdateToEvents(fixture.params, { + ...fixture.options, + onUnknownSessionUpdate: (update: AcpSessionUpdate) => { + unknownSessionUpdates.push(update.sessionUpdate); + }, + }); + + expect(events).toEqual(fixture.expectedEvents); + expect(unknownSessionUpdates).toEqual(fixture.expectedUnknownSessionUpdate ? [fixture.expectedUnknownSessionUpdate] : []); + + if (fixture.suppressedOptions) { + expect(translator.sessionUpdateToEvents(fixture.params, fixture.suppressedOptions)).toEqual(fixture.expectedSuppressedEvents ?? []); + } + return; + } + + if (fixture.method === "session/update_sequence") { + const translator = new AcpLegacyEventTranslator(); + const events = fixture.params.updates.flatMap((notification) => translator.sessionUpdateToEvents(notification)); + expect(events).toEqual(fixture.expectedEvents); + return; + } + + if (fixture.method === "kimi/step_interrupted" || fixture.method === "kimi/compaction" || fixture.method === "kimi/subagent_event") { + const translator = new AcpLegacyEventTranslator(); + expect(translator.extensionNotificationToEvents(fixture.method, fixture.params)).toEqual(fixture.expectedEvents); + return; + } + + const translator = new AcpLegacyEventTranslator(); + expect(translator.permissionRequestToEvent(fixture.id, fixture.params)).toEqual(fixture.expectedEvent); + }); + } +}); diff --git a/apps/vscode/agent-sdk/tests/acp-legacy-events.test.ts b/apps/vscode/agent-sdk/tests/acp-legacy-events.test.ts new file mode 100644 index 0000000000..6f8a58d397 --- /dev/null +++ b/apps/vscode/agent-sdk/tests/acp-legacy-events.test.ts @@ -0,0 +1,212 @@ +import { describe, expect, it } from "vitest"; +import { AcpLegacyEventTranslator, normalizeAcpMode } from "../acp-legacy-events"; + +describe("AcpLegacyEventTranslator", () => { + it("translates replayed user messages unless live user echo is suppressed", () => { + const translator = new AcpLegacyEventTranslator(); + + expect( + translator.sessionUpdateToEvents({ + update: { sessionUpdate: "user_message_chunk", content: { type: "text", text: "<system>hidden</system>Hello" } }, + }), + ).toEqual([{ type: "TurnBegin", payload: { user_input: "Hello" } }, { type: "StepBegin", payload: { n: 1 } }]); + + expect( + translator.sessionUpdateToEvents( + { + update: { sessionUpdate: "user_message_chunk", content: { type: "text", text: "Hello" } }, + }, + { suppressUserEcho: true }, + ), + ).toEqual([]); + }); + + it("translates message, thinking, plan, config, and slash command updates", () => { + const translator = new AcpLegacyEventTranslator(); + + expect(translator.sessionUpdateToEvents({ update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text: "Hi" } } })).toEqual([ + { type: "ContentPart", payload: { type: "text", text: "Hi" } }, + ]); + expect(translator.sessionUpdateToEvents({ update: { sessionUpdate: "agent_thought_chunk", content: { type: "reasoning", reasoning: "Think" } } })).toEqual([ + { type: "ContentPart", payload: { type: "think", think: "Think" } }, + ]); + expect( + translator.sessionUpdateToEvents({ + update: { + sessionUpdate: "plan", + entries: [ + { content: "One", status: "active", priority: "urgent" }, + { content: "Two", status: "in_progress", priority: "high" }, + ], + }, + }), + ).toEqual([{ type: "Plan", payload: { entries: [{ content: "One", status: "pending" }, { content: "Two", status: "in_progress", priority: "high" }] } }]); + expect( + translator.sessionUpdateToEvents({ + update: { sessionUpdate: "config_option_update", configOptions: [{ id: "model" }, null, "bad"] }, + }), + ).toEqual([{ type: "ConfigOptionUpdate", payload: { configOptions: [{ id: "model" }] } }]); + expect( + translator.sessionUpdateToEvents({ + update: { sessionUpdate: "available_commands_update", availableCommands: [{ name: "/clear", description: "Clear" }, { description: "bad" }] }, + }), + ).toEqual([{ type: "AvailableCommandsUpdate", payload: { availableCommands: [{ name: "clear", description: "Clear" }] } }]); + expect( + translator.sessionUpdateToEvents({ + update: { + sessionUpdate: "usage_update", + used: 10, + size: 100, + _meta: { + contextUsage: 0.12, + currentTurn: { + input_other: 1, + output: 2, + input_cache_read: 3, + input_cache_creation: 4, + }, + }, + }, + }), + ).toEqual([ + { + type: "StatusUpdate", + payload: { + context_usage: 0.12, + context_tokens: 10, + max_context_tokens: 100, + token_usage: { + input_other: 1, + output: 2, + input_cache_read: 3, + input_cache_creation: 4, + }, + message_id: null, + }, + }, + ]); + }); + + it("keeps tool call delta state and emits rich tool results", () => { + const translator = new AcpLegacyEventTranslator(); + + expect( + translator.sessionUpdateToEvents({ + update: { sessionUpdate: "tool_call", toolCallId: "tool-1", title: "Shell", kind: "execute", status: "pending", rawInput: "pnpm test" }, + }), + ).toEqual([ + { + type: "ToolCall", + payload: { + type: "function", + id: "tool-1", + function: { name: "Shell", arguments: "pnpm test" }, + extras: { kind: "execute", status: "pending" }, + }, + }, + ]); + + expect( + translator.sessionUpdateToEvents({ + update: { sessionUpdate: "tool_call_update", toolCallId: "tool-1", title: "Shell", status: "running", rawInput: "pnpm test --runInBand" }, + }), + ).toEqual([{ type: "ToolCallPart", payload: { tool_call_id: "tool-1", arguments_part: " --runInBand" } }]); + + expect( + translator.sessionUpdateToEvents({ + update: { + sessionUpdate: "tool_call_update", + toolCallId: "tool-1", + title: "Shell", + status: "completed", + content: [ + { type: "content", content: { type: "text", text: "done" } }, + { type: "diff", path: "src/index.ts", oldText: "a", newText: "b" }, + ], + }, + }), + ).toEqual([ + { + type: "ToolResult", + payload: { + tool_call_id: "tool-1", + return_value: { + is_error: false, + output: "done", + message: "Shell", + display: [ + { type: "brief", text: "done" }, + { type: "diff", path: "src/index.ts", old_text: "a", new_text: "b" }, + ], + extras: { status: "completed" }, + }, + }, + }, + ]); + }); + + it("preserves numeric permission request ids and dynamic approval options", () => { + const translator = new AcpLegacyEventTranslator(); + + expect( + translator.permissionRequestToEvent(0, { + toolCall: { + toolCallId: "perm-1", + title: "Edit", + content: [{ type: "diff", path: "README.md", oldText: "old", newText: "new" }], + }, + options: [ + { optionId: "approve_once", name: "Approve once", kind: "allow" }, + { optionId: "reject", name: "Reject", kind: "deny" }, + ], + }), + ).toEqual({ + type: "ApprovalRequest", + payload: { + id: 0, + tool_call_id: "perm-1", + sender: "Edit", + action: "allow, deny", + description: "Modify README.md", + display: [{ type: "diff", path: "README.md", old_text: "old", new_text: "new" }], + options: [ + { optionId: "approve_once", name: "Approve once", kind: "allow" }, + { optionId: "reject", name: "Reject", kind: "deny" }, + ], + }, + }); + }); + + it("keeps rich ACP approval display blocks in legacy display", () => { + const translator = new AcpLegacyEventTranslator(); + + expect( + translator.permissionRequestToEvent("approval-rich", { + toolCall: { + toolCallId: "tool-rich", + title: "Shell", + content: [ + { type: "command", command: "pnpm test", cwd: "/repo", description: "Run tests" }, + { type: "file-op", operation: "read", path: "README.md", detail: "Inspect docs" }, + ], + }, + options: [{ optionId: "approve", name: "Approve", kind: "allow" }], + }), + ).toMatchObject({ + type: "ApprovalRequest", + payload: { + description: "Run tests\nread README.md\nInspect docs", + display: [ + { type: "command", language: "bash", command: "pnpm test", cwd: "/repo", description: "Run tests" }, + { type: "file-op", operation: "read", path: "README.md", detail: "Inspect docs" }, + ], + }, + }); + }); + + it("normalizes ACP mode options", () => { + expect(normalizeAcpMode({ mode: "plan" })).toBe("plan"); + expect(normalizeAcpMode({ mode: undefined, yoloMode: true })).toBe("yolo"); + expect(normalizeAcpMode({ mode: undefined, yoloMode: false })).toBe("default"); + }); +}); diff --git a/apps/vscode/agent-sdk/tests/config.test.ts b/apps/vscode/agent-sdk/tests/config.test.ts new file mode 100644 index 0000000000..7e10fd7909 --- /dev/null +++ b/apps/vscode/agent-sdk/tests/config.test.ts @@ -0,0 +1,307 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { getModelById, getModelThinkingMode, isModelThinking } from "../config"; +import type { ModelConfig } from "../schema"; + +const fsMock = vi.hoisted(() => ({ + existsSync: vi.fn(), + readFileSync: vi.fn(), + writeFileSync: vi.fn(), +})); + +vi.mock("node:fs", () => fsMock); + +function resetFsMock(): void { + fsMock.existsSync.mockReset(); + fsMock.readFileSync.mockReset(); + fsMock.writeFileSync.mockReset(); +} + +// ============================================================================ +// Test Data +// ============================================================================ +const testModels: ModelConfig[] = [ + { id: "kimi-k2", name: "Kimi K2", capabilities: [] }, + { id: "kimi-k2-thinking", name: "Kimi K2 Thinking", capabilities: ["thinking"] }, + { id: "kimi-k2-always", name: "Kimi K2 Always Think", capabilities: ["always_thinking"] }, + { id: "o1-preview", name: "o1-preview", capabilities: ["thinking"] }, + { id: "deepthink-r1", name: "DeepThink R1", capabilities: [] }, + { id: "gpt-4", name: "GPT-4", capabilities: ["image_in"] }, +]; + +// ============================================================================ +// getModelById Tests +// ============================================================================ +describe("getModelById", () => { + it("finds model by id", () => { + const model = getModelById(testModels, "kimi-k2"); + expect(model).toBeDefined(); + expect(model?.name).toBe("Kimi K2"); + }); + + it("returns undefined for unknown id", () => { + const model = getModelById(testModels, "nonexistent"); + expect(model).toBeUndefined(); + }); + + it("returns undefined for empty array", () => { + const model = getModelById([], "kimi-k2"); + expect(model).toBeUndefined(); + }); + + it("handles case-sensitive matching", () => { + const model = getModelById(testModels, "KIMI-K2"); + expect(model).toBeUndefined(); + }); +}); + +// ============================================================================ +// getModelThinkingMode Tests +// ============================================================================ +describe("getModelThinkingMode", () => { + it("returns 'none' for model without thinking capability", () => { + const model: ModelConfig = { id: "basic", name: "Basic Model", capabilities: [] }; + expect(getModelThinkingMode(model)).toBe("none"); + }); + + it("returns 'none' for model with only image_in capability", () => { + const model: ModelConfig = { id: "vision", name: "Vision Model", capabilities: ["image_in"] }; + expect(getModelThinkingMode(model)).toBe("none"); + }); + + it("returns 'switch' for model with thinking capability", () => { + const model: ModelConfig = { id: "smart", name: "Smart Model", capabilities: ["thinking"] }; + expect(getModelThinkingMode(model)).toBe("switch"); + }); + + it("returns 'always' for model with always_thinking capability", () => { + const model: ModelConfig = { id: "always", name: "Always On", capabilities: ["always_thinking"] }; + expect(getModelThinkingMode(model)).toBe("always"); + }); + + it("does not infer thinking from model name", () => { + const models: ModelConfig[] = [ + { id: "1", name: "DeepThink R1", capabilities: [] }, + { id: "2", name: "THINKING MODEL", capabilities: [] }, + { id: "3", name: "some-think-model", capabilities: [] }, + ]; + for (const model of models) { + expect(getModelThinkingMode(model)).toBe("none"); + } + }); + + it("uses configured capabilities even when model name contains think", () => { + const model: ModelConfig = { id: "test", name: "Think Model", capabilities: ["thinking"] }; + expect(getModelThinkingMode(model)).toBe("switch"); + }); + + it("handles multiple capabilities", () => { + const model: ModelConfig = { + id: "multi", + name: "Multi Model", + capabilities: ["image_in", "thinking", "video_in"], + }; + expect(getModelThinkingMode(model)).toBe("switch"); + }); +}); + +// ============================================================================ +// isModelThinking Tests +// ============================================================================ +describe("isModelThinking", () => { + it("returns true for model with thinking capability", () => { + expect(isModelThinking(testModels, "kimi-k2-thinking")).toBe(true); + }); + + it("returns true for model with always_thinking capability", () => { + expect(isModelThinking(testModels, "kimi-k2-always")).toBe(true); + }); + + it("returns false for model with think in name but no thinking capability", () => { + expect(isModelThinking(testModels, "deepthink-r1")).toBe(false); + }); + + it("returns false for model without thinking", () => { + expect(isModelThinking(testModels, "kimi-k2")).toBe(false); + expect(isModelThinking(testModels, "gpt-4")).toBe(false); + }); + + it("returns false for unknown model", () => { + expect(isModelThinking(testModels, "nonexistent")).toBe(false); + }); + + it("returns false for empty models array", () => { + expect(isModelThinking([], "any")).toBe(false); + }); +}); + +// ============================================================================ +// parseConfig Tests +// ============================================================================ +describe("parseConfig", () => { + beforeEach(() => { + resetFsMock(); + }); + + it("returns default config when file does not exist", async () => { + const fs = await import("node:fs"); + vi.mocked(fs.existsSync).mockReturnValue(false); + + vi.resetModules(); + const { parseConfig } = await import("../config.js"); + const config = parseConfig(); + + expect(config).toEqual({ + defaultModel: null, + defaultThinking: false, + models: [], + }); + }); + + it("parses valid TOML config", async () => { + const fs = await import("node:fs"); + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue(` +default_model = "kimi-k2" +default_thinking = "on" + +[models.kimi-k2] +provider = "kimi" +model = "kimi-k2" +max_context_size = 128000 +capabilities = ["thinking", "image_in"] + +[providers.kimi] +type = "kimi" +base_url = "https://api.example.com/v1" +api_key = "YOUR_API_KEY" +`); + + vi.resetModules(); + const { parseConfig } = await import("../config.js"); + const config = parseConfig(); + + expect(config.defaultModel).toBe("kimi-k2"); + expect(config.defaultThinking).toBe(true); + expect(config.models).toHaveLength(1); + expect(config.models[0].id).toBe("kimi-k2"); + expect(config.models[0].capabilities).toContain("thinking"); + }); + + it("returns default config on parse error", async () => { + const fs = await import("node:fs"); + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue("invalid { toml ["); + + vi.resetModules(); + const { parseConfig } = await import("../config.js"); + const config = parseConfig(); + + expect(config).toEqual({ + defaultModel: null, + defaultThinking: false, + models: [], + }); + }); +}); + +// ============================================================================ +// saveDefaultModel Tests +// ============================================================================ +describe("saveDefaultModel", () => { + beforeEach(() => { + resetFsMock(); + }); + + it("creates new file when config does not exist", async () => { + const fs = await import("node:fs"); + vi.mocked(fs.existsSync).mockReturnValue(false); + + vi.resetModules(); + const { saveDefaultModel } = await import("../config.js"); + saveDefaultModel("kimi-k2"); + + expect(fs.writeFileSync).toHaveBeenCalledWith(expect.any(String), 'default_model = "kimi-k2"\n', "utf-8"); + }); + + it("creates new file with thinking setting", async () => { + const fs = await import("node:fs"); + vi.mocked(fs.existsSync).mockReturnValue(false); + + vi.resetModules(); + const { saveDefaultModel } = await import("../config.js"); + saveDefaultModel("kimi-k2", true); + + expect(fs.writeFileSync).toHaveBeenCalledWith(expect.any(String), 'default_model = "kimi-k2"\ndefault_thinking = true\n', "utf-8"); + }); + + it("updates existing default_model", async () => { + const fs = await import("node:fs"); + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue('default_model = "old-model"\n\n[providers]\n'); + + vi.resetModules(); + const { saveDefaultModel } = await import("../config.js"); + saveDefaultModel("new-model"); + + expect(fs.writeFileSync).toHaveBeenCalledWith(expect.any(String), expect.stringContaining('default_model = "new-model"'), "utf-8"); + }); + + it("adds default_model to existing config without it", async () => { + const fs = await import("node:fs"); + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue("[providers]\n"); + + vi.resetModules(); + const { saveDefaultModel } = await import("../config.js"); + saveDefaultModel("kimi-k2"); + + const written = vi.mocked(fs.writeFileSync).mock.calls[0][1] as string; + expect(written).toContain('default_model = "kimi-k2"'); + expect(written).toContain("[providers]"); + }); + + it("migrates a legacy quoted default_thinking to a boolean (in place)", async () => { + const fs = await import("node:fs"); + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue('default_model = "kimi-k2"\ndefault_thinking = "off"\n'); + + vi.resetModules(); + const { saveDefaultModel } = await import("../config.js"); + saveDefaultModel("kimi-k2", true); + + const written = vi.mocked(fs.writeFileSync).mock.calls[0][1] as string; + expect(written).toContain("default_thinking = true"); + expect(written).not.toContain('default_thinking = "'); + // exactly one default_thinking line — no duplicate/redefinition + expect(written.match(/default_thinking/g)).toHaveLength(1); + }); + + it("replaces an existing boolean default_thinking without duplicating it", async () => { + const fs = await import("node:fs"); + vi.mocked(fs.existsSync).mockReturnValue(true); + // The CLI writes the boolean form; saveDefaultModel must update it in place, + // not append a second key (which would make the TOML invalid). + vi.mocked(fs.readFileSync).mockReturnValue("default_thinking = true\ndefault_model = \"kimi-k2\"\n"); + + vi.resetModules(); + const { saveDefaultModel } = await import("../config.js"); + saveDefaultModel("kimi-k2", false); + + const written = vi.mocked(fs.writeFileSync).mock.calls[0][1] as string; + expect(written).toContain("default_thinking = false"); + expect(written.match(/default_thinking/g)).toHaveLength(1); + }); + + it("adds default_thinking after default_model", async () => { + const fs = await import("node:fs"); + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue('default_model = "kimi-k2"\n\n[providers]\n'); + + vi.resetModules(); + const { saveDefaultModel } = await import("../config.js"); + saveDefaultModel("kimi-k2", false); + + const written = vi.mocked(fs.writeFileSync).mock.calls[0][1] as string; + expect(written).toContain("default_thinking = false"); + }); +}); diff --git a/apps/vscode/agent-sdk/tests/errors.test.ts b/apps/vscode/agent-sdk/tests/errors.test.ts new file mode 100644 index 0000000000..7e80e76810 --- /dev/null +++ b/apps/vscode/agent-sdk/tests/errors.test.ts @@ -0,0 +1,232 @@ +import { describe, it, expect } from "vitest"; +import { + AgentSdkError, + TransportError, + ProtocolError, + SessionError, + CliError, + isAgentSdkError, + getErrorCode, + getErrorCategory, + TransportErrorCodes, + ProtocolErrorCodes, + SessionErrorCodes, + CliErrorCodes, +} from "../errors"; + +// ============================================================================ +// TransportError Tests +// ============================================================================ +describe("TransportError", () => { + it("creates error with code and message", () => { + const err = new TransportError("SPAWN_FAILED", "Failed to spawn CLI"); + expect(err.code).toBe("SPAWN_FAILED"); + expect(err.message).toBe("Failed to spawn CLI"); + expect(err.category).toBe("transport"); + expect(err.name).toBe("TransportError"); + }); + + it("includes cause when provided", () => { + const cause = new Error("ENOENT"); + const err = new TransportError("CLI_NOT_FOUND", "CLI not found", cause); + expect(err.cause).toBe(cause); + }); + + it("is instance of AgentSdkError", () => { + const err = new TransportError("PROCESS_CRASHED", "Process crashed"); + expect(err).toBeInstanceOf(AgentSdkError); + expect(err).toBeInstanceOf(Error); + }); +}); + +// ============================================================================ +// ProtocolError Tests +// ============================================================================ +describe("ProtocolError", () => { + it("creates error with code and message", () => { + const err = new ProtocolError("INVALID_JSON", "Failed to parse JSON"); + expect(err.code).toBe("INVALID_JSON"); + expect(err.message).toBe("Failed to parse JSON"); + expect(err.category).toBe("protocol"); + }); + + it("includes context when provided", () => { + const err = new ProtocolError("SCHEMA_MISMATCH", "Invalid schema", { field: "user_input" }); + expect(err.context).toEqual({ field: "user_input" }); + }); +}); + +// ============================================================================ +// SessionError Tests +// ============================================================================ +describe("SessionError", () => { + it("creates error with code and message", () => { + const err = new SessionError("SESSION_CLOSED", "Session is closed"); + expect(err.code).toBe("SESSION_CLOSED"); + expect(err.message).toBe("Session is closed"); + expect(err.category).toBe("session"); + }); + + it("handles all session error codes", () => { + const codes = Object.values(SessionErrorCodes); + for (const code of codes) { + const err = new SessionError(code, `Error: ${code}`); + expect(err.code).toBe(code); + } + }); +}); + +// ============================================================================ +// CliError Tests +// ============================================================================ +describe("CliError", () => { + it("creates error with code and message", () => { + const err = new CliError("LLM_NOT_SET", "LLM is not configured"); + expect(err.code).toBe("LLM_NOT_SET"); + expect(err.message).toBe("LLM is not configured"); + expect(err.category).toBe("cli"); + }); + + it("includes numeric code when provided", () => { + const err = new CliError("CHAT_PROVIDER_ERROR", "Provider error", -32003); + expect(err.numericCode).toBe(-32003); + }); + + describe("fromRpcError", () => { + it("maps -32000 to AUTH_REQUIRED (ACP authRequired)", () => { + const err = CliError.fromRpcError(-32000, "Auth required"); + expect(err.code).toBe("AUTH_REQUIRED"); + expect(err.numericCode).toBe(-32000); + }); + + it("maps -32001 to LLM_NOT_SET", () => { + const err = CliError.fromRpcError(-32001, "LLM not set"); + expect(err.code).toBe("LLM_NOT_SET"); + }); + + it("maps -32002 to LLM_NOT_SUPPORTED", () => { + const err = CliError.fromRpcError(-32002, "LLM not supported"); + expect(err.code).toBe("LLM_NOT_SUPPORTED"); + }); + + it("maps -32003 to CHAT_PROVIDER_ERROR", () => { + const err = CliError.fromRpcError(-32003, "Provider error"); + expect(err.code).toBe("CHAT_PROVIDER_ERROR"); + }); + + it("maps -32603 to CONFIG_ERROR", () => { + const err = CliError.fromRpcError(-32603, "Internal error"); + expect(err.code).toBe("CONFIG_ERROR"); + }); + + it("maps unknown code to UNKNOWN", () => { + const err = CliError.fromRpcError(-99999, "Unknown error"); + expect(err.code).toBe("UNKNOWN"); + expect(err.numericCode).toBe(-99999); + }); + }); +}); + +// ============================================================================ +// Utility Functions Tests +// ============================================================================ +describe("isAgentSdkError", () => { + it("returns true for TransportError", () => { + expect(isAgentSdkError(new TransportError("SPAWN_FAILED", ""))).toBe(true); + }); + + it("returns true for ProtocolError", () => { + expect(isAgentSdkError(new ProtocolError("INVALID_JSON", ""))).toBe(true); + }); + + it("returns true for SessionError", () => { + expect(isAgentSdkError(new SessionError("SESSION_CLOSED", ""))).toBe(true); + }); + + it("returns true for CliError", () => { + expect(isAgentSdkError(new CliError("UNKNOWN", ""))).toBe(true); + }); + + it("returns false for regular Error", () => { + expect(isAgentSdkError(new Error("regular error"))).toBe(false); + }); + + it("returns false for non-error values", () => { + expect(isAgentSdkError(null)).toBe(false); + expect(isAgentSdkError(undefined)).toBe(false); + expect(isAgentSdkError("string")).toBe(false); + expect(isAgentSdkError(123)).toBe(false); + expect(isAgentSdkError({})).toBe(false); + }); +}); + +describe("getErrorCode", () => { + it("returns code for AgentSdkError", () => { + expect(getErrorCode(new TransportError("SPAWN_FAILED", ""))).toBe("SPAWN_FAILED"); + expect(getErrorCode(new CliError("LLM_NOT_SET", ""))).toBe("LLM_NOT_SET"); + }); + + it("returns UNKNOWN for regular Error", () => { + expect(getErrorCode(new Error("oops"))).toBe("UNKNOWN"); + }); + + it("returns UNKNOWN for non-error", () => { + expect(getErrorCode(null)).toBe("UNKNOWN"); + expect(getErrorCode("string")).toBe("UNKNOWN"); + }); +}); + +describe("getErrorCategory", () => { + it("returns category for AgentSdkError", () => { + expect(getErrorCategory(new TransportError("SPAWN_FAILED", ""))).toBe("transport"); + expect(getErrorCategory(new ProtocolError("INVALID_JSON", ""))).toBe("protocol"); + expect(getErrorCategory(new SessionError("SESSION_CLOSED", ""))).toBe("session"); + expect(getErrorCategory(new CliError("UNKNOWN", ""))).toBe("cli"); + }); + + it("returns unknown for regular Error", () => { + expect(getErrorCategory(new Error("oops"))).toBe("unknown"); + }); + + it("returns unknown for non-error", () => { + expect(getErrorCategory(null)).toBe("unknown"); + }); +}); + +// ============================================================================ +// Error Codes Constants Tests +// ============================================================================ +describe("Error code constants", () => { + it("TransportErrorCodes has expected values", () => { + expect(TransportErrorCodes.SPAWN_FAILED).toBe("SPAWN_FAILED"); + expect(TransportErrorCodes.STDIN_NOT_WRITABLE).toBe("STDIN_NOT_WRITABLE"); + expect(TransportErrorCodes.PROCESS_CRASHED).toBe("PROCESS_CRASHED"); + expect(TransportErrorCodes.CLI_NOT_FOUND).toBe("CLI_NOT_FOUND"); + expect(TransportErrorCodes.ALREADY_STARTED).toBe("ALREADY_STARTED"); + expect(TransportErrorCodes.HANDSHAKE_TIMEOUT).toBe("HANDSHAKE_TIMEOUT"); + }); + + it("ProtocolErrorCodes has expected values", () => { + expect(ProtocolErrorCodes.INVALID_JSON).toBe("INVALID_JSON"); + expect(ProtocolErrorCodes.SCHEMA_MISMATCH).toBe("SCHEMA_MISMATCH"); + expect(ProtocolErrorCodes.UNKNOWN_EVENT_TYPE).toBe("UNKNOWN_EVENT_TYPE"); + expect(ProtocolErrorCodes.UNKNOWN_REQUEST_TYPE).toBe("UNKNOWN_REQUEST_TYPE"); + expect(ProtocolErrorCodes.REQUEST_TIMEOUT).toBe("REQUEST_TIMEOUT"); + expect(ProtocolErrorCodes.REQUEST_CANCELLED).toBe("REQUEST_CANCELLED"); + }); + + it("SessionErrorCodes has expected values", () => { + expect(SessionErrorCodes.SESSION_CLOSED).toBe("SESSION_CLOSED"); + expect(SessionErrorCodes.SESSION_BUSY).toBe("SESSION_BUSY"); + expect(SessionErrorCodes.TURN_INTERRUPTED).toBe("TURN_INTERRUPTED"); + expect(SessionErrorCodes.APPROVAL_FAILED).toBe("APPROVAL_FAILED"); + }); + + it("CliErrorCodes has expected values", () => { + expect(CliErrorCodes.INVALID_STATE).toBe("INVALID_STATE"); + expect(CliErrorCodes.LLM_NOT_SET).toBe("LLM_NOT_SET"); + expect(CliErrorCodes.LLM_NOT_SUPPORTED).toBe("LLM_NOT_SUPPORTED"); + expect(CliErrorCodes.CHAT_PROVIDER_ERROR).toBe("CHAT_PROVIDER_ERROR"); + expect(CliErrorCodes.UNKNOWN).toBe("UNKNOWN"); + }); +}); diff --git a/apps/vscode/agent-sdk/tests/fixtures/acp-legacy/index.ts b/apps/vscode/agent-sdk/tests/fixtures/acp-legacy/index.ts new file mode 100644 index 0000000000..bac068e49a --- /dev/null +++ b/apps/vscode/agent-sdk/tests/fixtures/acp-legacy/index.ts @@ -0,0 +1,51 @@ +import agentMessage from "./session-update-agent-message.json"; +import agentThought from "./session-update-agent-thought.json"; +import availableCommands from "./session-update-available-commands.json"; +import compactionCompleted from "./kimi-compaction-completed.json"; +import compactionStarted from "./kimi-compaction-started.json"; +import configOption from "./session-update-config-option.json"; +import permissionNumericId from "./session-request-permission-numeric-id.json"; +import plan from "./session-update-plan.json"; +import stepInterrupted from "./kimi-step-interrupted.json"; +import subagentChildToolCall from "./kimi-subagent-child-tool-call.json"; +import subagentStarted from "./kimi-subagent-started.json"; +import toolCallLifecycle from "./session-update-tool-call-lifecycle.json"; +import unknownSessionUpdate from "./session-update-unknown.json"; +import usageUpdate from "./session-update-usage.json"; +import userMessage from "./session-update-user-message.json"; + +export const acpLegacyFixtures = [ + userMessage, + agentMessage, + agentThought, + plan, + configOption, + availableCommands, + usageUpdate, + toolCallLifecycle, + unknownSessionUpdate, + stepInterrupted, + compactionStarted, + compactionCompleted, + subagentStarted, + subagentChildToolCall, + permissionNumericId, +] as const; + +export { + agentMessage, + agentThought, + availableCommands, + compactionCompleted, + compactionStarted, + configOption, + permissionNumericId, + plan, + stepInterrupted, + subagentChildToolCall, + subagentStarted, + toolCallLifecycle, + unknownSessionUpdate, + usageUpdate, + userMessage, +}; diff --git a/apps/vscode/agent-sdk/tests/fixtures/acp-legacy/kimi-compaction-completed.json b/apps/vscode/agent-sdk/tests/fixtures/acp-legacy/kimi-compaction-completed.json new file mode 100644 index 0000000000..f8ece8f4ec --- /dev/null +++ b/apps/vscode/agent-sdk/tests/fixtures/acp-legacy/kimi-compaction-completed.json @@ -0,0 +1,32 @@ +{ + "name": "kimi/compaction completed -> CompactionEnd", + "method": "kimi/compaction", + "params": { + "sessionId": "sess-1", + "phase": "completed", + "trigger": "manual", + "instruction": "Keep important facts", + "message": "Compaction complete", + "result": { + "summary": "Compacted context", + "compactedCount": 12, + "tokensBefore": 12000, + "tokensAfter": 3000 + } + }, + "expectedEvents": [ + { + "type": "CompactionEnd", + "payload": { + "status": "completed", + "trigger": "manual", + "instruction": "Keep important facts", + "summary": "Compacted context", + "compactedCount": 12, + "tokensBefore": 12000, + "tokensAfter": 3000, + "message": "Compaction complete" + } + } + ] +} diff --git a/apps/vscode/agent-sdk/tests/fixtures/acp-legacy/kimi-compaction-started.json b/apps/vscode/agent-sdk/tests/fixtures/acp-legacy/kimi-compaction-started.json new file mode 100644 index 0000000000..f2a2503fe7 --- /dev/null +++ b/apps/vscode/agent-sdk/tests/fixtures/acp-legacy/kimi-compaction-started.json @@ -0,0 +1,19 @@ +{ + "name": "kimi/compaction started -> CompactionBegin", + "method": "kimi/compaction", + "params": { + "sessionId": "sess-1", + "phase": "started", + "trigger": "manual", + "instruction": "Keep important facts" + }, + "expectedEvents": [ + { + "type": "CompactionBegin", + "payload": { + "trigger": "manual", + "instruction": "Keep important facts" + } + } + ] +} diff --git a/apps/vscode/agent-sdk/tests/fixtures/acp-legacy/kimi-step-interrupted.json b/apps/vscode/agent-sdk/tests/fixtures/acp-legacy/kimi-step-interrupted.json new file mode 100644 index 0000000000..abda445c8b --- /dev/null +++ b/apps/vscode/agent-sdk/tests/fixtures/acp-legacy/kimi-step-interrupted.json @@ -0,0 +1,17 @@ +{ + "name": "kimi/step_interrupted -> StepInterrupted", + "method": "kimi/step_interrupted", + "params": { + "sessionId": "sess-1", + "turnId": 3, + "step": 2, + "reason": "aborted", + "message": "Interrupted by user" + }, + "expectedEvents": [ + { + "type": "StepInterrupted", + "payload": {} + } + ] +} diff --git a/apps/vscode/agent-sdk/tests/fixtures/acp-legacy/kimi-subagent-child-tool-call.json b/apps/vscode/agent-sdk/tests/fixtures/acp-legacy/kimi-subagent-child-tool-call.json new file mode 100644 index 0000000000..d5e2836f4f --- /dev/null +++ b/apps/vscode/agent-sdk/tests/fixtures/acp-legacy/kimi-subagent-child-tool-call.json @@ -0,0 +1,41 @@ +{ + "name": "kimi/subagent_event child tool call -> nested ToolCall", + "method": "kimi/subagent_event", + "params": { + "sessionId": "sess-1", + "parentToolCallId": "1:task-1", + "subagentId": "sub-1", + "phase": "child_event", + "event": { + "type": "ToolCall", + "payload": { + "type": "function", + "id": "99:child-tool-1", + "function": { + "name": "Read", + "arguments": "{\"path\":\"README.md\"}" + } + } + } + }, + "expectedEvents": [ + { + "type": "SubagentEvent", + "payload": { + "task_tool_call_id": "1:task-1", + "event": { + "type": "ToolCall", + "payload": { + "type": "function", + "id": "99:child-tool-1", + "function": { + "name": "Read", + "arguments": "{\"path\":\"README.md\"}" + }, + "extras": null + } + } + } + } + ] +} diff --git a/apps/vscode/agent-sdk/tests/fixtures/acp-legacy/kimi-subagent-started.json b/apps/vscode/agent-sdk/tests/fixtures/acp-legacy/kimi-subagent-started.json new file mode 100644 index 0000000000..5043222d91 --- /dev/null +++ b/apps/vscode/agent-sdk/tests/fixtures/acp-legacy/kimi-subagent-started.json @@ -0,0 +1,25 @@ +{ + "name": "kimi/subagent_event started -> nested StepBegin", + "method": "kimi/subagent_event", + "params": { + "sessionId": "sess-1", + "parentToolCallId": "1:task-1", + "subagentId": "sub-1", + "subagentName": "explore", + "phase": "started" + }, + "expectedEvents": [ + { + "type": "SubagentEvent", + "payload": { + "task_tool_call_id": "1:task-1", + "event": { + "type": "StepBegin", + "payload": { + "n": 1 + } + } + } + } + ] +} diff --git a/apps/vscode/agent-sdk/tests/fixtures/acp-legacy/session-request-permission-numeric-id.json b/apps/vscode/agent-sdk/tests/fixtures/acp-legacy/session-request-permission-numeric-id.json new file mode 100644 index 0000000000..d531ac3a19 --- /dev/null +++ b/apps/vscode/agent-sdk/tests/fixtures/acp-legacy/session-request-permission-numeric-id.json @@ -0,0 +1,32 @@ +{ + "name": "permission request preserves numeric id zero and dynamic options", + "method": "session/request_permission", + "id": 0, + "params": { + "sessionId": "sess-permission", + "toolCall": { + "toolCallId": "perm-1", + "title": "Edit", + "content": [{ "type": "diff", "path": "README.md", "oldText": "old", "newText": "new" }] + }, + "options": [ + { "optionId": "approve_once", "name": "Approve once", "kind": "allow" }, + { "optionId": "reject", "name": "Reject", "kind": "deny" } + ] + }, + "expectedEvent": { + "type": "ApprovalRequest", + "payload": { + "id": 0, + "tool_call_id": "perm-1", + "sender": "Edit", + "action": "allow, deny", + "description": "Modify README.md", + "display": [{ "type": "diff", "path": "README.md", "old_text": "old", "new_text": "new" }], + "options": [ + { "optionId": "approve_once", "name": "Approve once", "kind": "allow" }, + { "optionId": "reject", "name": "Reject", "kind": "deny" } + ] + } + } +} diff --git a/apps/vscode/agent-sdk/tests/fixtures/acp-legacy/session-update-agent-message.json b/apps/vscode/agent-sdk/tests/fixtures/acp-legacy/session-update-agent-message.json new file mode 100644 index 0000000000..1c495cb374 --- /dev/null +++ b/apps/vscode/agent-sdk/tests/fixtures/acp-legacy/session-update-agent-message.json @@ -0,0 +1,14 @@ +{ + "name": "agent message chunk becomes text ContentPart", + "method": "session/update", + "params": { + "sessionId": "sess-agent-message", + "update": { + "sessionUpdate": "agent_message_chunk", + "content": { "type": "text", "text": "Hi" } + } + }, + "expectedEvents": [ + { "type": "ContentPart", "payload": { "type": "text", "text": "Hi" } } + ] +} diff --git a/apps/vscode/agent-sdk/tests/fixtures/acp-legacy/session-update-agent-thought.json b/apps/vscode/agent-sdk/tests/fixtures/acp-legacy/session-update-agent-thought.json new file mode 100644 index 0000000000..8b9064286e --- /dev/null +++ b/apps/vscode/agent-sdk/tests/fixtures/acp-legacy/session-update-agent-thought.json @@ -0,0 +1,14 @@ +{ + "name": "agent thought chunk falls back to reasoning field", + "method": "session/update", + "params": { + "sessionId": "sess-agent-thought", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": { "type": "reasoning", "reasoning": "Think" } + } + }, + "expectedEvents": [ + { "type": "ContentPart", "payload": { "type": "think", "think": "Think" } } + ] +} diff --git a/apps/vscode/agent-sdk/tests/fixtures/acp-legacy/session-update-available-commands.json b/apps/vscode/agent-sdk/tests/fixtures/acp-legacy/session-update-available-commands.json new file mode 100644 index 0000000000..032930ad5f --- /dev/null +++ b/apps/vscode/agent-sdk/tests/fixtures/acp-legacy/session-update-available-commands.json @@ -0,0 +1,19 @@ +{ + "name": "available commands update filters invalid command entries", + "method": "session/update", + "params": { + "sessionId": "sess-commands", + "update": { + "sessionUpdate": "available_commands_update", + "availableCommands": [{ "name": "/clear", "description": "Clear" }, { "description": "bad" }] + } + }, + "expectedEvents": [ + { + "type": "AvailableCommandsUpdate", + "payload": { + "availableCommands": [{ "name": "clear", "description": "Clear" }] + } + } + ] +} diff --git a/apps/vscode/agent-sdk/tests/fixtures/acp-legacy/session-update-config-option.json b/apps/vscode/agent-sdk/tests/fixtures/acp-legacy/session-update-config-option.json new file mode 100644 index 0000000000..d14a0f3d28 --- /dev/null +++ b/apps/vscode/agent-sdk/tests/fixtures/acp-legacy/session-update-config-option.json @@ -0,0 +1,19 @@ +{ + "name": "config option update filters non-object options", + "method": "session/update", + "params": { + "sessionId": "sess-config", + "update": { + "sessionUpdate": "config_option_update", + "configOptions": [{ "id": "model" }, null, "bad"] + } + }, + "expectedEvents": [ + { + "type": "ConfigOptionUpdate", + "payload": { + "configOptions": [{ "id": "model" }] + } + } + ] +} diff --git a/apps/vscode/agent-sdk/tests/fixtures/acp-legacy/session-update-plan.json b/apps/vscode/agent-sdk/tests/fixtures/acp-legacy/session-update-plan.json new file mode 100644 index 0000000000..6cbe03c2a0 --- /dev/null +++ b/apps/vscode/agent-sdk/tests/fixtures/acp-legacy/session-update-plan.json @@ -0,0 +1,25 @@ +{ + "name": "plan update normalizes status and priority", + "method": "session/update", + "params": { + "sessionId": "sess-plan", + "update": { + "sessionUpdate": "plan", + "entries": [ + { "content": "One", "status": "active", "priority": "urgent" }, + { "content": "Two", "status": "in_progress", "priority": "high" } + ] + } + }, + "expectedEvents": [ + { + "type": "Plan", + "payload": { + "entries": [ + { "content": "One", "status": "pending" }, + { "content": "Two", "status": "in_progress", "priority": "high" } + ] + } + } + ] +} diff --git a/apps/vscode/agent-sdk/tests/fixtures/acp-legacy/session-update-tool-call-lifecycle.json b/apps/vscode/agent-sdk/tests/fixtures/acp-legacy/session-update-tool-call-lifecycle.json new file mode 100644 index 0000000000..5a092e57a4 --- /dev/null +++ b/apps/vscode/agent-sdk/tests/fixtures/acp-legacy/session-update-tool-call-lifecycle.json @@ -0,0 +1,68 @@ +{ + "name": "tool call lifecycle preserves cumulative args and rich result display", + "method": "session/update_sequence", + "params": { + "sessionId": "sess-tool-lifecycle", + "updates": [ + { + "update": { + "sessionUpdate": "tool_call", + "toolCallId": "tool-1", + "title": "Shell", + "kind": "execute", + "status": "pending", + "rawInput": "pnpm test" + } + }, + { + "update": { + "sessionUpdate": "tool_call_update", + "toolCallId": "tool-1", + "title": "Shell", + "status": "running", + "rawInput": "pnpm test --runInBand" + } + }, + { + "update": { + "sessionUpdate": "tool_call_update", + "toolCallId": "tool-1", + "title": "Shell", + "status": "completed", + "content": [ + { "type": "content", "content": { "type": "text", "text": "done" } }, + { "type": "diff", "path": "src/index.ts", "oldText": "a", "newText": "b" } + ] + } + } + ] + }, + "expectedEvents": [ + { + "type": "ToolCall", + "payload": { + "type": "function", + "id": "tool-1", + "function": { "name": "Shell", "arguments": "pnpm test" }, + "extras": { "kind": "execute", "status": "pending" } + } + }, + { "type": "ToolCallPart", "payload": { "tool_call_id": "tool-1", "arguments_part": " --runInBand" } }, + { + "type": "ToolResult", + "payload": { + "tool_call_id": "tool-1", + "return_value": { + "is_error": false, + "output": "done", + "message": "Shell", + "display": [ + { "type": "brief", "text": "done" }, + { "type": "diff", "path": "src/index.ts", "old_text": "a", "new_text": "b" } + ], + "extras": { "status": "completed" } + } + } + } + ] +} diff --git a/apps/vscode/agent-sdk/tests/fixtures/acp-legacy/session-update-unknown.json b/apps/vscode/agent-sdk/tests/fixtures/acp-legacy/session-update-unknown.json new file mode 100644 index 0000000000..2b4eefd285 --- /dev/null +++ b/apps/vscode/agent-sdk/tests/fixtures/acp-legacy/session-update-unknown.json @@ -0,0 +1,13 @@ +{ + "name": "unknown future session update is ignored for compatibility", + "method": "session/update", + "params": { + "sessionId": "sess-unknown", + "update": { + "sessionUpdate": "future_update", + "content": { "type": "text", "text": "ignored" } + } + }, + "expectedEvents": [], + "expectedUnknownSessionUpdate": "future_update" +} diff --git a/apps/vscode/agent-sdk/tests/fixtures/acp-legacy/session-update-usage.json b/apps/vscode/agent-sdk/tests/fixtures/acp-legacy/session-update-usage.json new file mode 100644 index 0000000000..f9a8ca085c --- /dev/null +++ b/apps/vscode/agent-sdk/tests/fixtures/acp-legacy/session-update-usage.json @@ -0,0 +1,38 @@ +{ + "name": "usage update maps context and current-turn token usage", + "method": "session/update", + "params": { + "sessionId": "sess-usage", + "update": { + "sessionUpdate": "usage_update", + "used": 12500, + "size": 200000, + "_meta": { + "contextUsage": 0.0625, + "currentTurn": { + "inputOther": 1000, + "output": 500, + "inputCacheRead": 100, + "inputCacheCreation": 50 + } + } + } + }, + "expectedEvents": [ + { + "type": "StatusUpdate", + "payload": { + "context_usage": 0.0625, + "context_tokens": 12500, + "max_context_tokens": 200000, + "token_usage": { + "input_other": 1000, + "output": 500, + "input_cache_read": 100, + "input_cache_creation": 50 + }, + "message_id": null + } + } + ] +} diff --git a/apps/vscode/agent-sdk/tests/fixtures/acp-legacy/session-update-user-message.json b/apps/vscode/agent-sdk/tests/fixtures/acp-legacy/session-update-user-message.json new file mode 100644 index 0000000000..512e6528a6 --- /dev/null +++ b/apps/vscode/agent-sdk/tests/fixtures/acp-legacy/session-update-user-message.json @@ -0,0 +1,18 @@ +{ + "name": "user message chunk cleans system tags and supports live echo suppression", + "method": "session/update", + "params": { + "sessionId": "sess-user-message", + "update": { + "sessionUpdate": "user_message_chunk", + "content": { "type": "text", "text": "<system>hidden</system>Hello" } + } + }, + "options": { "suppressUserEcho": false }, + "expectedEvents": [ + { "type": "TurnBegin", "payload": { "user_input": "Hello" } }, + { "type": "StepBegin", "payload": { "n": 1 } } + ], + "suppressedOptions": { "suppressUserEcho": true }, + "expectedSuppressedEvents": [] +} diff --git a/apps/vscode/agent-sdk/tests/protocol.test.ts b/apps/vscode/agent-sdk/tests/protocol.test.ts new file mode 100644 index 0000000000..a82005ebd2 --- /dev/null +++ b/apps/vscode/agent-sdk/tests/protocol.test.ts @@ -0,0 +1,1510 @@ +import { describe, it, expect, vi, beforeEach, afterEach, beforeAll } from "vitest"; +import { EventEmitter } from "node:events"; +import { Readable } from "node:stream"; +import { createEventChannel } from "../protocol"; +import { TransportError } from "../errors"; + +// ============================================================================ +// createEventChannel Tests +// ============================================================================ +describe("createEventChannel", () => { + it("pushes and consumes values in order", async () => { + const { iterable, push, finish } = createEventChannel<number>(); + + push(1); + push(2); + push(3); + finish(); + + const results: number[] = []; + for await (const value of iterable) { + results.push(value); + } + + expect(results).toEqual([1, 2, 3]); + }); + + it("handles async consumption with delayed push", async () => { + const { iterable, push, finish } = createEventChannel<string>(); + + const consumer = (async () => { + const results: string[] = []; + for await (const value of iterable) { + results.push(value); + } + return results; + })(); + + await new Promise((r) => setTimeout(r, 10)); + push("a"); + push("b"); + finish(); + + const results = await consumer; + expect(results).toEqual(["a", "b"]); + }); + + it("ignores pushes after finish", async () => { + const { iterable, push, finish } = createEventChannel<number>(); + + push(1); + finish(); + push(2); + push(3); + + const results: number[] = []; + for await (const value of iterable) { + results.push(value); + } + + expect(results).toEqual([1]); + }); + + it("handles empty channel", async () => { + const { iterable, finish } = createEventChannel<number>(); + finish(); + + const results: number[] = []; + for await (const value of iterable) { + results.push(value); + } + + expect(results).toEqual([]); + }); + + it("multiple finish calls are safe", async () => { + const { iterable, push, finish } = createEventChannel<number>(); + + push(1); + finish(); + finish(); + finish(); + + const results: number[] = []; + for await (const value of iterable) { + results.push(value); + } + + expect(results).toEqual([1]); + }); + + it("resolves waiting consumers on finish", async () => { + const { iterable, finish } = createEventChannel<number>(); + + const consumer = (async () => { + const results: number[] = []; + for await (const value of iterable) { + results.push(value); + } + return results; + })(); + + await new Promise((r) => setTimeout(r, 10)); + finish(); + + const results = await consumer; + expect(results).toEqual([]); + }); +}); + +// ============================================================================ +// ProtocolClient Tests +// ============================================================================ +const mockSpawn = vi.fn(); +vi.mock("node:child_process", () => ({ + spawn: (...args: unknown[]) => mockSpawn(...args), +})); + +let ProtocolClient: (typeof import("../protocol"))["ProtocolClient"]; + +beforeAll(async () => { + const module = await import("../protocol.js"); + ProtocolClient = module.ProtocolClient; +}); + +describe("ProtocolClient", () => { + beforeEach(() => { + mockSpawn.mockReset(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + // Create a mock process with proper Readable streams + function createMockProcess() { + const stdin = { + writable: true, + write: vi.fn(), + }; + + // Use real Readable streams that support readline + const stdout = new Readable({ read() {} }); + const stderr = new Readable({ read() {} }); + + const proc = new EventEmitter() as EventEmitter & { + stdin: typeof stdin; + stdout: Readable; + stderr: Readable; + exitCode: number | null; + killed: boolean; + kill: ReturnType<typeof vi.fn>; + }; + proc.stdin = stdin; + proc.stdout = stdout; + proc.stderr = stderr; + proc.exitCode = null; + proc.killed = false; + proc.kill = vi.fn(); + return proc; + } + + // Helper to push a line to stdout + function pushLine(proc: ReturnType<typeof createMockProcess>, line: string) { + proc.stdout.push(line + "\n"); + } + + function tick() { + return new Promise((resolve) => setTimeout(resolve, 0)); + } + + function parseWrite(proc: ReturnType<typeof createMockProcess>, index: number) { + return JSON.parse(proc.stdin.write.mock.calls[index][0]); + } + + async function waitForWrite(proc: ReturnType<typeof createMockProcess>, method: string, fromIndex = 0) { + for (let attempt = 0; attempt < 50; attempt++) { + for (let i = fromIndex; i < proc.stdin.write.mock.calls.length; i++) { + const message = parseWrite(proc, i); + if (message.method === method) { + return { index: i, message }; + } + } + await tick(); + } + throw new Error(`Timed out waiting for ${method}`); + } + + async function respondToWrite(proc: ReturnType<typeof createMockProcess>, method: string, result: unknown, fromIndex = 0) { + const { index, message } = await waitForWrite(proc, method, fromIndex); + pushLine(proc, JSON.stringify({ jsonrpc: "2.0", id: message.id, result })); + await tick(); + return index + 1; + } + + async function completeLoadHandshake(proc: ReturnType<typeof createMockProcess>, result: unknown = {}) { + let cursor = await respondToWrite(proc, "initialize", {}, 0); + return respondToWrite(proc, "session/load", result, cursor); + } + + async function completeNewHandshake(proc: ReturnType<typeof createMockProcess>, result: unknown = { sessionId: "new-session" }, configRequestCount = 2) { + let cursor = await respondToWrite(proc, "initialize", {}, 0); + cursor = await respondToWrite(proc, "session/new", result, cursor); + for (let i = 0; i < configRequestCount; i++) { + cursor = await respondToWrite(proc, "session/set_config_option", {}, cursor); + } + return cursor; + } + + describe("isRunning", () => { + it("returns false when not started", () => { + const client = new ProtocolClient(); + expect(client.isRunning).toBe(false); + }); + }); + + describe("start", () => { + it("throws ALREADY_STARTED when called twice", () => { + const proc = createMockProcess(); + mockSpawn.mockReturnValue(proc); + + const client = new ProtocolClient(); + client.start({ sessionId: "test", workDir: "/tmp" }); + + expect(() => { + client.start({ sessionId: "test", workDir: "/tmp" }); + }).toThrow(TransportError); + }); + + it("builds correct ACP args with all options", () => { + const proc = createMockProcess(); + mockSpawn.mockReturnValue(proc); + + const client = new ProtocolClient(); + client.start({ + sessionId: "sess-123", + workDir: "/project", + model: "kimi-k2", + thinking: true, + yoloMode: true, + executablePath: "/usr/local/bin/kimi", + environmentVariables: { MY_VAR: "value" }, + }); + + expect(mockSpawn).toHaveBeenCalledWith( + "/usr/local/bin/kimi", + ["acp"], + expect.objectContaining({ + cwd: "/project", + env: expect.objectContaining({ MY_VAR: "value" }), + stdio: ["pipe", "pipe", "pipe"], + }), + ); + }); + + it("does not pass legacy thinking flags when thinking is false", () => { + const proc = createMockProcess(); + mockSpawn.mockReturnValue(proc); + + const client = new ProtocolClient(); + client.start({ + sessionId: "test", + workDir: "/tmp", + thinking: false, + }); + + expect(mockSpawn).toHaveBeenCalledWith("kimi", ["acp"], expect.anything()); + }); + + it("sets handshake timeout on config option requests during session/new", async () => { + const timeoutSpy = vi.spyOn(global, "setTimeout"); + const proc = createMockProcess(); + mockSpawn.mockReturnValue(proc); + + const client = new ProtocolClient(); + client.start({ workDir: "/tmp", model: "kimi-k2", thinking: true, mode: "yolo" }); + await completeNewHandshake(proc, { sessionId: "new-session" }, 3); + + const handshakeTimeouts = timeoutSpy.mock.calls.filter((call) => call[1] === 10000); + expect(handshakeTimeouts.length).toBeGreaterThanOrEqual(5); + + const stopPromise = client.stop(); + proc.exitCode = 0; + proc.emit("exit", 0); + await stopPromise; + }); + + it("does not overwrite loaded session config with caller defaults", async () => { + const proc = createMockProcess(); + mockSpawn.mockReturnValue(proc); + + const client = new ProtocolClient(); + client.start({ + sessionId: "existing-session", + workDir: "/tmp", + model: "caller-default-model", + thinking: false, + }); + + await completeLoadHandshake(proc, { + configOptions: [ + { + id: "model", + currentValue: "loaded-model", + options: [{ value: "loaded-model", name: "Loaded Model" }], + }, + { + id: "thinking", + currentValue: "on", + options: [ + { value: "off", name: "Thinking Off" }, + { value: "on", name: "Thinking On" }, + ], + }, + ], + }); + await client.ensureReady(); + + const setConfigRequests = proc.stdin.write.mock.calls.map((_, index) => parseWrite(proc, index)).filter((message) => message.method === "session/set_config_option"); + expect(setConfigRequests).toEqual([]); + expect(client.consumeBufferedEvents()).toContainEqual({ + type: "ConfigOptionUpdate", + payload: { + configOptions: [ + { + id: "model", + currentValue: "loaded-model", + options: [{ value: "loaded-model", name: "Loaded Model" }], + }, + { + id: "thinking", + currentValue: "on", + options: [ + { value: "off", name: "Thinking Off" }, + { value: "on", name: "Thinking On" }, + ], + }, + ], + }, + }); + + const stopPromise = client.stop(); + proc.exitCode = 0; + proc.emit("exit", 0); + await stopPromise; + }); + + it("skips config option requests already known from session/new", async () => { + const proc = createMockProcess(); + mockSpawn.mockReturnValue(proc); + + const client = new ProtocolClient(); + client.start({ workDir: "/tmp", model: "kimi-k2", thinking: false }); + + let cursor = await respondToWrite(proc, "initialize", {}, 0); + cursor = await respondToWrite( + proc, + "session/new", + { + sessionId: "new-session", + configOptions: [ + { configId: "thinking", currentValue: "off" }, + { id: "mode", value: "default" }, + ], + }, + cursor, + ); + cursor = await respondToWrite(proc, "session/set_config_option", {}, cursor); + + await client.ensureReady(); + + const setConfigRequests = proc.stdin.write.mock.calls.map((_, index) => parseWrite(proc, index)).filter((message) => message.method === "session/set_config_option"); + expect(setConfigRequests).toEqual([ + expect.objectContaining({ + method: "session/set_config_option", + params: expect.objectContaining({ configId: "model", value: "kimi-k2" }), + }), + ]); + + const stopPromise = client.stop(); + proc.exitCode = 0; + proc.emit("exit", 0); + await stopPromise; + }); + + it("throws SPAWN_FAILED when spawn fails", () => { + mockSpawn.mockImplementation(() => { + throw new Error("spawn ENOENT"); + }); + + const client = new ProtocolClient(); + expect(() => { + client.start({ sessionId: "test", workDir: "/tmp" }); + }).toThrow(TransportError); + }); + + it("throws SPAWN_FAILED when stdio missing", () => { + const proc = createMockProcess(); + proc.stdout = null as unknown as Readable; + mockSpawn.mockReturnValue(proc); + + const client = new ProtocolClient(); + expect(() => { + client.start({ sessionId: "test", workDir: "/tmp" }); + }).toThrow(TransportError); + }); + }); + + describe("stop", () => { + it("does nothing when not started", async () => { + const client = new ProtocolClient(); + await client.stop(); + }); + + it("kills process on stop", async () => { + const proc = createMockProcess(); + mockSpawn.mockReturnValue(proc); + + const client = new ProtocolClient(); + client.start({ sessionId: "test", workDir: "/tmp" }); + await completeLoadHandshake(proc); + + const stopPromise = client.stop(); + + proc.exitCode = 0; + proc.emit("exit", 0); + + await stopPromise; + expect(proc.kill).toHaveBeenCalledWith("SIGTERM"); + }); + }); + + describe("sendPrompt", () => { + it("writes prompt request to stdin", async () => { + const proc = createMockProcess(); + mockSpawn.mockReturnValue(proc); + + const client = new ProtocolClient(); + client.start({ sessionId: "test", workDir: "/tmp" }); + const cursor = await completeLoadHandshake(proc); + + const stream = client.sendPrompt("Hello"); + const { message } = await waitForWrite(proc, "session/prompt", cursor); + + expect(message.params).toMatchObject({ + sessionId: "test", + prompt: [{ type: "text", text: "Hello" }], + }); + + pushLine(proc, JSON.stringify({ jsonrpc: "2.0", id: message.id, result: {} })); + for await (const _ of stream.events) { + // drain + } + await expect(stream.result).resolves.toEqual({ status: "finished" }); + }); + + it("handles ContentPart array input", async () => { + const proc = createMockProcess(); + mockSpawn.mockReturnValue(proc); + + const client = new ProtocolClient(); + client.start({ sessionId: "test", workDir: "/tmp" }); + const cursor = await completeLoadHandshake(proc); + + const stream = client.sendPrompt([{ type: "text", text: "Hello" }]); + const { message } = await waitForWrite(proc, "session/prompt", cursor); + + expect(message.params.prompt).toEqual([{ type: "text", text: "Hello" }]); + pushLine(proc, JSON.stringify({ jsonrpc: "2.0", id: message.id, result: {} })); + for await (const _ of stream.events) { + // drain + } + await expect(stream.result).resolves.toEqual({ status: "finished" }); + }); + }); + + describe("sendCancel", () => { + it("writes cancel notification to stdin", async () => { + const proc = createMockProcess(); + mockSpawn.mockReturnValue(proc); + + const client = new ProtocolClient(); + client.start({ sessionId: "test", workDir: "/tmp" }); + const cursor = await completeLoadHandshake(proc); + + client.sendCancel(); + const { message } = await waitForWrite(proc, "session/cancel", cursor); + + expect(message.id).toBeUndefined(); + expect(message.params).toEqual({ sessionId: "test" }); + }); + }); + + describe("sendApproval", () => { + it("writes approval response to stdin", () => { + const proc = createMockProcess(); + mockSpawn.mockReturnValue(proc); + + const client = new ProtocolClient(); + client.start({ sessionId: "test", workDir: "/tmp" }); + + client.sendApproval("req-123", "approve"); + + expect(proc.stdin.write).toHaveBeenCalledWith(expect.stringContaining('"id":"req-123"')); + expect(proc.stdin.write).toHaveBeenCalledWith(expect.stringContaining('"optionId":"approve_once"')); + }); + }); + + describe("protocol debug logging", () => { + it("does not log ACP traffic by default and stringifies outgoing messages once", async () => { + const oldDebug = process.env.KIMI_CODE_DEBUG_ACP; + delete process.env.KIMI_CODE_DEBUG_ACP; + + try { + const proc = createMockProcess(); + mockSpawn.mockReturnValue(proc); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined); + + const client = new ProtocolClient(); + client.start({ sessionId: "test", workDir: "/tmp" }); + + const stringifySpy = vi.spyOn(JSON, "stringify"); + client.sendApproval(0, "approve"); + + const stringifyCalls = stringifySpy.mock.calls.length; + expect(stringifyCalls).toBe(1); + expect(proc.stdin.write).toHaveBeenLastCalledWith('{"jsonrpc":"2.0","id":0,"result":{"outcome":{"outcome":"selected","optionId":"approve_once"}}}\n'); + + pushLine( + proc, + JSON.stringify({ + jsonrpc: "2.0", + method: "session/update", + params: { sessionId: "test", update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text: "secret chunk" } } }, + }), + ); + proc.stderr.push("secret stderr\n"); + await tick(); + + expect(logSpy).not.toHaveBeenCalled(); + expect(warnSpy).not.toHaveBeenCalled(); + } finally { + if (oldDebug === undefined) { + delete process.env.KIMI_CODE_DEBUG_ACP; + } else { + process.env.KIMI_CODE_DEBUG_ACP = oldDebug; + } + } + }); + }); + + describe("message handling", () => { + it("emits events from session/update notifications", async () => { + const proc = createMockProcess(); + mockSpawn.mockReturnValue(proc); + + const client = new ProtocolClient(); + client.start({ sessionId: "test", workDir: "/tmp" }); + const cursor = await completeLoadHandshake(proc); + + const stream = client.sendPrompt("Hello"); + const promptRequest = await waitForWrite(proc, "session/prompt", cursor); + + // Push event + pushLine( + proc, + JSON.stringify({ + jsonrpc: "2.0", + method: "session/update", + params: { sessionId: "test", update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text: "Hi" } } }, + }), + ); + + // Push response to finish + pushLine( + proc, + JSON.stringify({ + jsonrpc: "2.0", + id: promptRequest.message.id, + result: {}, + }), + ); + + // Signal end of stream + proc.stdout.push(null); + + const events = []; + for await (const event of stream.events) { + events.push(event); + } + + expect(events).toHaveLength(3); + expect(events[2]).toMatchObject({ + type: "ContentPart", + payload: { type: "text", text: "Hi" }, + }); + }); + + it("emits thought chunks from Kimi Code even when local thinking is disabled", async () => { + const proc = createMockProcess(); + mockSpawn.mockReturnValue(proc); + + const client = new ProtocolClient(); + client.start({ sessionId: "test", workDir: "/tmp", thinking: false }); + const cursor = await completeLoadHandshake(proc); + + const stream = client.sendPrompt("Hello"); + const promptRequest = await waitForWrite(proc, "session/prompt", cursor); + + pushLine( + proc, + JSON.stringify({ + jsonrpc: "2.0", + method: "session/update", + params: { sessionId: "test", update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text: "First" } } }, + }), + ); + pushLine( + proc, + JSON.stringify({ + jsonrpc: "2.0", + method: "session/update", + params: { sessionId: "test", update: { sessionUpdate: "agent_thought_chunk", content: { type: "text", text: "hidden thought" } } }, + }), + ); + pushLine( + proc, + JSON.stringify({ + jsonrpc: "2.0", + method: "session/update", + params: { sessionId: "test", update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text: " second" } } }, + }), + ); + + pushLine( + proc, + JSON.stringify({ + jsonrpc: "2.0", + id: promptRequest.message.id, + result: {}, + }), + ); + + proc.stdout.push(null); + + const events = []; + for await (const event of stream.events) { + events.push(event); + } + + expect(events).toEqual([ + { type: "TurnBegin", payload: { user_input: "Hello" } }, + { type: "StepBegin", payload: { n: 1 } }, + { type: "ContentPart", payload: { type: "text", text: "First" } }, + { type: "ContentPart", payload: { type: "think", think: "hidden thought" } }, + { type: "ContentPart", payload: { type: "text", text: " second" } }, + ]); + }); + + it("emits thought chunks when thinking is enabled", async () => { + const proc = createMockProcess(); + mockSpawn.mockReturnValue(proc); + + const client = new ProtocolClient(); + client.start({ sessionId: "test", workDir: "/tmp", thinking: true }); + const cursor = await completeLoadHandshake(proc); + + const stream = client.sendPrompt("Hello"); + const promptRequest = await waitForWrite(proc, "session/prompt", cursor); + + pushLine( + proc, + JSON.stringify({ + jsonrpc: "2.0", + method: "session/update", + params: { sessionId: "test", update: { sessionUpdate: "agent_thought_chunk", content: { type: "text", text: "visible thought" } } }, + }), + ); + + pushLine( + proc, + JSON.stringify({ + jsonrpc: "2.0", + id: promptRequest.message.id, + result: {}, + }), + ); + + proc.stdout.push(null); + + const events = []; + for await (const event of stream.events) { + events.push(event); + } + + expect(events).toEqual([ + { type: "TurnBegin", payload: { user_input: "Hello" } }, + { type: "StepBegin", payload: { n: 1 } }, + { type: "ContentPart", payload: { type: "think", think: "visible thought" } }, + ]); + }); + + it("emits thought chunks when thought content uses a reasoning fallback", async () => { + const proc = createMockProcess(); + mockSpawn.mockReturnValue(proc); + + const client = new ProtocolClient(); + client.start({ sessionId: "test", workDir: "/tmp", thinking: true }); + const cursor = await completeLoadHandshake(proc); + + const stream = client.sendPrompt("Hello"); + const promptRequest = await waitForWrite(proc, "session/prompt", cursor); + + pushLine( + proc, + JSON.stringify({ + jsonrpc: "2.0", + method: "session/update", + params: { sessionId: "test", update: { sessionUpdate: "agent_thought_chunk", content: { type: "reasoning", reasoning: "visible thought" } } }, + }), + ); + + pushLine( + proc, + JSON.stringify({ + jsonrpc: "2.0", + id: promptRequest.message.id, + result: {}, + }), + ); + + proc.stdout.push(null); + + const events = []; + for await (const event of stream.events) { + events.push(event); + } + + expect(events).toEqual([ + { type: "TurnBegin", payload: { user_input: "Hello" } }, + { type: "StepBegin", payload: { n: 1 } }, + { type: "ContentPart", payload: { type: "think", think: "visible thought" } }, + ]); + }); + + it("ignores whitespace-only thought chunks", async () => { + const proc = createMockProcess(); + mockSpawn.mockReturnValue(proc); + + const client = new ProtocolClient(); + client.start({ sessionId: "test", workDir: "/tmp", thinking: true }); + const cursor = await completeLoadHandshake(proc); + + const stream = client.sendPrompt("Hello"); + const promptRequest = await waitForWrite(proc, "session/prompt", cursor); + + pushLine( + proc, + JSON.stringify({ + jsonrpc: "2.0", + method: "session/update", + params: { sessionId: "test", update: { sessionUpdate: "agent_thought_chunk", content: { type: "text", text: "\n " } } }, + }), + ); + + pushLine( + proc, + JSON.stringify({ + jsonrpc: "2.0", + id: promptRequest.message.id, + result: {}, + }), + ); + + proc.stdout.push(null); + + const events = []; + for await (const event of stream.events) { + events.push(event); + } + + expect(events).toEqual([ + { type: "TurnBegin", payload: { user_input: "Hello" } }, + { type: "StepBegin", payload: { n: 1 } }, + ]); + }); + + it("drops replayed user_message_chunk when it only contains a system reminder", async () => { + const proc = createMockProcess(); + mockSpawn.mockReturnValue(proc); + + const client = new ProtocolClient(); + client.start({ sessionId: "test", workDir: "/tmp" }); + await completeLoadHandshake(proc); + + pushLine( + proc, + JSON.stringify({ + jsonrpc: "2.0", + method: "session/update", + params: { + sessionId: "test", + update: { + sessionUpdate: "user_message_chunk", + content: { + type: "text", + text: "<system-reminder>\nCurrent todo list:\n[pending] Hidden\n</system-reminder>", + }, + }, + }, + }), + ); + await tick(); + + expect(client.consumeBufferedEvents()).toEqual([]); + }); + + it("removes system reminder blocks from replayed user_message_chunk text", async () => { + const proc = createMockProcess(); + mockSpawn.mockReturnValue(proc); + + const client = new ProtocolClient(); + client.start({ sessionId: "test", workDir: "/tmp" }); + await completeLoadHandshake(proc); + + pushLine( + proc, + JSON.stringify({ + jsonrpc: "2.0", + method: "session/update", + params: { + sessionId: "test", + update: { + sessionUpdate: "user_message_chunk", + content: { + type: "text", + text: "Real prompt\n<system-reminder>\nCurrent todo list:\n[pending] Hidden\n</system-reminder>", + }, + }, + }, + }), + ); + await tick(); + + expect(client.consumeBufferedEvents()).toEqual([ + { type: "TurnBegin", payload: { user_input: "Real prompt" } }, + { type: "StepBegin", payload: { n: 1 } }, + ]); + }); + + it("emits todo display blocks from tool_call_update content", async () => { + const proc = createMockProcess(); + mockSpawn.mockReturnValue(proc); + + const client = new ProtocolClient(); + client.start({ sessionId: "test", workDir: "/tmp" }); + const cursor = await completeLoadHandshake(proc); + + const stream = client.sendPrompt("Set todos"); + const promptRequest = await waitForWrite(proc, "session/prompt", cursor); + + pushLine( + proc, + JSON.stringify({ + jsonrpc: "2.0", + method: "session/update", + params: { + sessionId: "test", + update: { + sessionUpdate: "tool_call_update", + toolCallId: "tc-todo", + title: "SetTodoList", + status: "completed", + content: [ + { + type: "todo", + items: [ + { title: "Inspect API", status: "completed" }, + { content: "Update UI", status: "active" }, + { text: "Run checks" }, + ], + }, + ], + }, + }, + }), + ); + + pushLine( + proc, + JSON.stringify({ + jsonrpc: "2.0", + id: promptRequest.message.id, + result: {}, + }), + ); + + proc.stdout.push(null); + + const events = []; + for await (const event of stream.events) { + events.push(event); + } + + expect(events[events.length - 1]).toMatchObject({ + type: "ToolResult", + payload: { + return_value: { + display: [ + { + type: "todo", + items: [ + { title: "Inspect API", status: "done" }, + { title: "Update UI", status: "in_progress" }, + { title: "Run checks", status: "pending" }, + ], + }, + ], + }, + }, + }); + }); + + it("emits todo display blocks from nested content block", async () => { + const proc = createMockProcess(); + mockSpawn.mockReturnValue(proc); + + const client = new ProtocolClient(); + client.start({ sessionId: "test", workDir: "/tmp" }); + const cursor = await completeLoadHandshake(proc); + + const stream = client.sendPrompt("Set todos"); + const promptRequest = await waitForWrite(proc, "session/prompt", cursor); + + pushLine( + proc, + JSON.stringify({ + jsonrpc: "2.0", + method: "session/update", + params: { + sessionId: "test", + update: { + sessionUpdate: "tool_call_update", + toolCallId: "tc-todo-nested", + title: "SetTodoList", + status: "completed", + content: [ + { type: "content", content: { type: "text", text: "Updating todos" } }, + { type: "content", content: { type: "todo", items: [{ title: "First" }, { title: "Second", status: "completed" }] } }, + ], + }, + }, + }), + ); + + pushLine( + proc, + JSON.stringify({ + jsonrpc: "2.0", + id: promptRequest.message.id, + result: {}, + }), + ); + + proc.stdout.push(null); + + const events = []; + for await (const event of stream.events) { + events.push(event); + } + + const toolResult = events.find((e) => e.type === "ToolResult"); + expect(toolResult).toBeDefined(); + expect(toolResult?.payload.return_value.display).toEqual([ + { type: "brief", text: "Updating todos" }, + { + type: "todo", + items: [ + { title: "First", status: "pending" }, + { title: "Second", status: "done" }, + ], + }, + ]); + }); + + it("emits todo display blocks from plain text todo list", async () => { + const proc = createMockProcess(); + mockSpawn.mockReturnValue(proc); + + const client = new ProtocolClient(); + client.start({ sessionId: "test", workDir: "/tmp" }); + const cursor = await completeLoadHandshake(proc); + + const stream = client.sendPrompt("Set todos"); + const promptRequest = await waitForWrite(proc, "session/prompt", cursor); + + pushLine( + proc, + JSON.stringify({ + jsonrpc: "2.0", + method: "session/update", + params: { + sessionId: "test", + update: { + sessionUpdate: "tool_call_update", + toolCallId: "tc-todo-text", + title: "TodoList", + status: "completed", + content: [ + { + type: "content", + content: { + type: "text", + text: "Todo list updated.\nCurrent todo list:\n [pending] Inspect code\n [in_progress] Update UI\n [done] Run checks\n", + }, + }, + ], + }, + }, + }), + ); + + pushLine( + proc, + JSON.stringify({ + jsonrpc: "2.0", + id: promptRequest.message.id, + result: {}, + }), + ); + + proc.stdout.push(null); + + const events = []; + for await (const event of stream.events) { + events.push(event); + } + + const toolResult = events.find((e) => e.type === "ToolResult"); + expect(toolResult).toBeDefined(); + expect(toolResult?.payload.return_value.display).toEqual([ + { + type: "todo", + items: [ + { title: "Inspect code", status: "pending" }, + { title: "Update UI", status: "in_progress" }, + { title: "Run checks", status: "done" }, + ], + }, + ]); + }); + + it("emits todo display blocks from raw output todo list", async () => { + const proc = createMockProcess(); + mockSpawn.mockReturnValue(proc); + + const client = new ProtocolClient(); + client.start({ sessionId: "test", workDir: "/tmp" }); + const cursor = await completeLoadHandshake(proc); + + const stream = client.sendPrompt("Set todos"); + const promptRequest = await waitForWrite(proc, "session/prompt", cursor); + + pushLine( + proc, + JSON.stringify({ + jsonrpc: "2.0", + method: "session/update", + params: { + sessionId: "test", + update: { + sessionUpdate: "tool_call_update", + toolCallId: "tc-todo-raw-output", + title: "TodoList", + status: "completed", + rawOutput: "Todo list updated.\nCurrent todo list:\n [pending] Inspect code\n [pending] Update UI\n [pending] Run checks\n", + }, + }, + }), + ); + + pushLine( + proc, + JSON.stringify({ + jsonrpc: "2.0", + id: promptRequest.message.id, + result: {}, + }), + ); + + proc.stdout.push(null); + + const events = []; + for await (const event of stream.events) { + events.push(event); + } + + const toolResult = events.find((e) => e.type === "ToolResult"); + expect(toolResult).toBeDefined(); + expect(toolResult?.payload.return_value.display).toEqual([ + { + type: "todo", + items: [ + { title: "Inspect code", status: "pending" }, + { title: "Update UI", status: "pending" }, + { title: "Run checks", status: "pending" }, + ], + }, + ]); + }); + + it("emits tool argument parts with the matching tool call id", async () => { + const proc = createMockProcess(); + mockSpawn.mockReturnValue(proc); + + const client = new ProtocolClient(); + client.start({ sessionId: "test", workDir: "/tmp" }); + const cursor = await completeLoadHandshake(proc); + + const stream = client.sendPrompt("Run tool"); + const promptRequest = await waitForWrite(proc, "session/prompt", cursor); + + pushLine( + proc, + JSON.stringify({ + jsonrpc: "2.0", + method: "session/update", + params: { + sessionId: "test", + update: { + sessionUpdate: "tool_call", + toolCallId: "tc-args", + title: "Shell", + rawInput: '{"cmd":"ec', + }, + }, + }), + ); + + pushLine( + proc, + JSON.stringify({ + jsonrpc: "2.0", + method: "session/update", + params: { + sessionId: "test", + update: { + sessionUpdate: "tool_call_update", + toolCallId: "tc-args", + title: "Shell", + status: "pending", + rawInput: '{"cmd":"echo ok"}', + }, + }, + }), + ); + + pushLine( + proc, + JSON.stringify({ + jsonrpc: "2.0", + id: promptRequest.message.id, + result: {}, + }), + ); + + proc.stdout.push(null); + + const events = []; + for await (const event of stream.events) { + events.push(event); + } + + expect(events).toContainEqual({ + type: "ToolCallPart", + payload: { tool_call_id: "tc-args", arguments_part: 'ho ok"}' }, + }); + }); + + it("emits request events from session/request_permission", async () => { + const proc = createMockProcess(); + mockSpawn.mockReturnValue(proc); + + const client = new ProtocolClient(); + client.start({ sessionId: "test", workDir: "/tmp" }); + const cursor = await completeLoadHandshake(proc); + + const stream = client.sendPrompt("Hello"); + const promptRequest = await waitForWrite(proc, "session/prompt", cursor); + + pushLine( + proc, + JSON.stringify({ + jsonrpc: "2.0", + id: "req-1", + method: "session/request_permission", + params: { + sessionId: "test", + toolCall: { + toolCallId: "tc-1", + title: "Shell", + kind: "execute", + content: [{ type: "content", content: { type: "text", text: "Run ls" } }], + }, + options: [{ optionId: "approve_once", kind: "allow" }], + }, + }), + ); + + pushLine( + proc, + JSON.stringify({ + jsonrpc: "2.0", + id: promptRequest.message.id, + result: {}, + }), + ); + + proc.stdout.push(null); + + const events = []; + for await (const event of stream.events) { + events.push(event); + } + + expect(events).toHaveLength(3); + expect(events[2]).toMatchObject({ + type: "ApprovalRequest", + payload: { id: "req-1", sender: "Shell" }, + }); + }); + + it("emits ApprovalRequest for numeric id=0 and preserves number type in response", async () => { + const proc = createMockProcess(); + mockSpawn.mockReturnValue(proc); + + const client = new ProtocolClient(); + client.start({ sessionId: "test", workDir: "/tmp" }); + const cursor = await completeLoadHandshake(proc); + + const stream = client.sendPrompt("Hello"); + const promptRequest = await waitForWrite(proc, "session/prompt", cursor); + + pushLine( + proc, + JSON.stringify({ + jsonrpc: "2.0", + id: 0, + method: "session/request_permission", + params: { + sessionId: "test", + toolCall: { + toolCallId: "tc-1", + title: "Shell", + kind: "execute", + content: [{ type: "content", content: { type: "text", text: "Run ls" } }], + }, + options: [{ optionId: "approve_once", kind: "allow" }], + }, + }), + ); + + // Approve with the numeric id + client.sendApproval(0, "approve"); + + pushLine( + proc, + JSON.stringify({ + jsonrpc: "2.0", + id: promptRequest.message.id, + result: {}, + }), + ); + + proc.stdout.push(null); + + const events = []; + for await (const event of stream.events) { + events.push(event); + } + + const approval = events.find((e) => e.type === "ApprovalRequest"); + expect(approval).toMatchObject({ + type: "ApprovalRequest", + payload: { id: 0, sender: "Shell" }, + }); + + // Verify the approval response contains the numeric id 0, not "0" + const approvalWrite = proc.stdin.write.mock.calls.find((call) => { + const msg = JSON.parse(call[0]); + return msg.id === 0 && msg.result?.outcome?.optionId === "approve_once"; + }); + expect(approvalWrite).toBeDefined(); + }); + + it("emits parse error for invalid JSON", async () => { + const proc = createMockProcess(); + mockSpawn.mockReturnValue(proc); + + const client = new ProtocolClient(); + client.start({ sessionId: "test", workDir: "/tmp" }); + const cursor = await completeLoadHandshake(proc); + + const stream = client.sendPrompt("Hello"); + const promptRequest = await waitForWrite(proc, "session/prompt", cursor); + + pushLine(proc, "not valid json{{{"); + + pushLine( + proc, + JSON.stringify({ + jsonrpc: "2.0", + id: promptRequest.message.id, + result: {}, + }), + ); + + proc.stdout.push(null); + + const events = []; + for await (const event of stream.events) { + events.push(event); + } + + expect(events).toHaveLength(3); + expect(events[2]).toMatchObject({ + type: "error", + code: "INVALID_JSON", + }); + }); + + it("ignores unknown ACP session update types", async () => { + const proc = createMockProcess(); + mockSpawn.mockReturnValue(proc); + + const client = new ProtocolClient(); + client.start({ sessionId: "test", workDir: "/tmp" }); + const cursor = await completeLoadHandshake(proc); + + const stream = client.sendPrompt("Hello"); + const promptRequest = await waitForWrite(proc, "session/prompt", cursor); + + pushLine( + proc, + JSON.stringify({ + jsonrpc: "2.0", + method: "session/update", + params: { sessionId: "test", update: { sessionUpdate: "unknown_update", value: true } }, + }), + ); + + pushLine( + proc, + JSON.stringify({ + jsonrpc: "2.0", + id: promptRequest.message.id, + result: {}, + }), + ); + + proc.stdout.push(null); + + const events = []; + for await (const event of stream.events) { + events.push(event); + } + + expect(events).toEqual([ + { type: "TurnBegin", payload: { user_input: "Hello" } }, + { type: "StepBegin", payload: { n: 1 } }, + ]); + }); + + it("resolves result promise with run result", async () => { + const proc = createMockProcess(); + mockSpawn.mockReturnValue(proc); + + const client = new ProtocolClient(); + client.start({ sessionId: "test", workDir: "/tmp" }); + const cursor = await completeLoadHandshake(proc); + + const stream = client.sendPrompt("Hello"); + const promptRequest = await waitForWrite(proc, "session/prompt", cursor); + + pushLine( + proc, + JSON.stringify({ + jsonrpc: "2.0", + id: promptRequest.message.id, + result: {}, + }), + ); + + proc.stdout.push(null); + + for await (const _ of stream.events) { + // drain + } + + const result = await stream.result; + expect(result).toEqual({ status: "finished" }); + }); + + it("handles cancelled stop reason", async () => { + const proc = createMockProcess(); + mockSpawn.mockReturnValue(proc); + + const client = new ProtocolClient(); + client.start({ sessionId: "test", workDir: "/tmp" }); + const cursor = await completeLoadHandshake(proc); + + const stream = client.sendPrompt("Hello"); + const promptRequest = await waitForWrite(proc, "session/prompt", cursor); + + pushLine( + proc, + JSON.stringify({ + jsonrpc: "2.0", + id: promptRequest.message.id, + result: { stopReason: "cancelled" }, + }), + ); + + proc.stdout.push(null); + + for await (const _ of stream.events) { + // drain + } + + const result = await stream.result; + expect(result).toEqual({ status: "cancelled" }); + }); + + it("maps max turn request stop reason to max_steps_reached", async () => { + const proc = createMockProcess(); + mockSpawn.mockReturnValue(proc); + + const client = new ProtocolClient(); + client.start({ sessionId: "test", workDir: "/tmp" }); + const cursor = await completeLoadHandshake(proc); + + const stream = client.sendPrompt("Hello"); + const promptRequest = await waitForWrite(proc, "session/prompt", cursor); + + pushLine( + proc, + JSON.stringify({ + jsonrpc: "2.0", + id: promptRequest.message.id, + result: { stopReason: "max_turn_requests" }, + }), + ); + + proc.stdout.push(null); + + for await (const _ of stream.events) { + // drain + } + + const result = await stream.result; + expect(result).toEqual({ status: "max_steps_reached" }); + }); + }); + + describe("process lifecycle", () => { + it("handles process error", async () => { + const proc = createMockProcess(); + mockSpawn.mockReturnValue(proc); + + const client = new ProtocolClient(); + client.start({ sessionId: "test", workDir: "/tmp" }); + + const stream = client.sendPrompt("Hello"); + + proc.emit("error", new Error("EPIPE")); + + await expect(stream.result).rejects.toThrow(TransportError); + }); + + it("handles process exit with non-zero code", async () => { + const proc = createMockProcess(); + mockSpawn.mockReturnValue(proc); + + const client = new ProtocolClient(); + client.start({ sessionId: "test", workDir: "/tmp" }); + + const stream = client.sendPrompt("Hello"); + + proc.exitCode = 1; + proc.emit("exit", 1); + + await expect(stream.result).rejects.toThrow(TransportError); + }); + + it("rejects pending requests when process exits with code zero", async () => { + const proc = createMockProcess(); + mockSpawn.mockReturnValue(proc); + + const client = new ProtocolClient(); + client.start({ sessionId: "test", workDir: "/tmp" }); + + const stream = client.sendPrompt("Hello"); + + proc.exitCode = 0; + proc.emit("exit", 0); + + await expect(stream.result).rejects.toThrow(TransportError); + }); + }); +}); diff --git a/apps/vscode/agent-sdk/tests/schema.test.ts b/apps/vscode/agent-sdk/tests/schema.test.ts new file mode 100644 index 0000000000..5238d229a9 --- /dev/null +++ b/apps/vscode/agent-sdk/tests/schema.test.ts @@ -0,0 +1,738 @@ +import { describe, it, expect } from "vitest"; +import { + ContentPartSchema, + TokenUsageSchema, + DisplayBlockSchema, + ToolCallSchema, + ToolCallPartSchema, + ToolResultSchema, + TurnBeginSchema, + StepBeginSchema, + StatusUpdateSchema, + ApprovalRequestPayloadSchema, + ApprovalRequestResolvedSchema, + ApprovalResponseSchema, + PlanSchema, + ConfigOptionUpdateSchema, + RunResultSchema, + RpcMessageSchema, + parseEventPayload, + parseRequestPayload, + type ContentPart, + type DisplayBlock, + type UnknownBlock, + type WireEvent, + DiffBlock, + TodoBlock, + CommandBlock, + BackgroundTaskBlock, +} from "../schema"; + +// ============================================================================ +// ContentPart Tests +// ============================================================================ +describe("ContentPartSchema", () => { + it("parses text part", () => { + const input = { type: "text", text: "hello" }; + const result = ContentPartSchema.safeParse(input); + expect(result.success).toBe(true); + expect(result.data).toEqual(input); + }); + + it("parses think part", () => { + const input = { type: "think", think: "reasoning...", encrypted: "abc123" }; + const result = ContentPartSchema.safeParse(input); + expect(result.success).toBe(true); + expect(result.data).toEqual(input); + }); + + it("parses think part with null encrypted", () => { + const input = { type: "think", think: "reasoning...", encrypted: null }; + const result = ContentPartSchema.safeParse(input); + expect(result.success).toBe(true); + }); + + it("parses image_url part", () => { + const input = { type: "image_url", image_url: { url: "data:image/png;base64,..." } }; + const result = ContentPartSchema.safeParse(input); + expect(result.success).toBe(true); + }); + + it("parses image_url part with id", () => { + const input = { type: "image_url", image_url: { url: "https://example.com/img.png", id: "img-1" } }; + const result = ContentPartSchema.safeParse(input); + expect(result.success).toBe(true); + expect((result.data as ContentPart & { type: "image_url" }).image_url.id).toBe("img-1"); + }); + + it("parses audio_url part", () => { + const input = { type: "audio_url", audio_url: { url: "data:audio/aac;base64,..." } }; + const result = ContentPartSchema.safeParse(input); + expect(result.success).toBe(true); + }); + + it("parses video_url part", () => { + const input = { type: "video_url", video_url: { url: "data:video/mp4;base64,..." } }; + const result = ContentPartSchema.safeParse(input); + expect(result.success).toBe(true); + }); + + it("rejects invalid type", () => { + const input = { type: "invalid", data: "foo" }; + const result = ContentPartSchema.safeParse(input); + expect(result.success).toBe(false); + }); +}); + +// ============================================================================ +// TokenUsage Tests +// ============================================================================ +describe("TokenUsageSchema", () => { + it("parses valid token usage", () => { + const input = { + input_other: 100, + output: 50, + input_cache_read: 20, + input_cache_creation: 10, + }; + const result = TokenUsageSchema.safeParse(input); + expect(result.success).toBe(true); + expect(result.data).toEqual(input); + }); + + it("rejects missing fields", () => { + const input = { input_other: 100 }; + const result = TokenUsageSchema.safeParse(input); + expect(result.success).toBe(false); + }); +}); + +// ============================================================================ +// DisplayBlock Tests +// ============================================================================ +describe("DisplayBlockSchema", () => { + it("parses brief block", () => { + const input = { type: "brief", text: "Summary text" }; + const result = DisplayBlockSchema.safeParse(input); + expect(result.success).toBe(true); + expect(result.data).toEqual({ type: "brief", text: "Summary text" }); + }); + + it("parses diff block", () => { + const input = { + type: "diff", + path: "/src/main.ts", + old_text: "const a = 1;", + new_text: "const a = 2;", + }; + const result = DisplayBlockSchema.safeParse(input); + expect(result.success).toBe(true); + const data = result.data! as DiffBlock; + expect(data.type).toBe("diff"); + expect(data.path).toBe("/src/main.ts"); + }); + + it("parses todo block", () => { + const input = { + type: "todo", + items: [ + { title: "Task 1", status: "done" }, + { title: "Task 2", status: "in_progress" }, + { title: "Task 3", status: "pending" }, + ], + }; + const result = DisplayBlockSchema.safeParse(input); + expect(result.success).toBe(true); + const data = result.data! as TodoBlock; + expect(data.items).toHaveLength(3); + }); + + it("normalizes completed todo status to done", () => { + const input = { + type: "todo", + items: [ + { title: "Task 1", status: "completed" }, + { title: "Task 2", status: "active" }, + ], + }; + const result = DisplayBlockSchema.safeParse(input); + expect(result.success).toBe(true); + const data = result.data! as TodoBlock; + expect(data.items.map((item) => item.status)).toEqual(["done", "in_progress"]); + }); + + it("parses command approval block", () => { + const input = { + type: "command", + language: "bash", + command: "pnpm test", + cwd: "/repo", + description: "Run tests", + danger: "none", + }; + const result = DisplayBlockSchema.safeParse(input); + expect(result.success).toBe(true); + expect((result.data as CommandBlock).command).toBe("pnpm test"); + }); + + it("parses background-task approval block with task_id", () => { + const input = { + type: "background-task", + task_id: "task-1", + kind: "shell", + status: "running", + description: "Run tests", + }; + const result = DisplayBlockSchema.safeParse(input); + expect(result.success).toBe(true); + expect((result.data as BackgroundTaskBlock).task_id).toBe("task-1"); + }); + + it("transforms unknown block type to UnknownBlock", () => { + const input = { type: "custom", foo: "bar", baz: 123 }; + const result = DisplayBlockSchema.safeParse(input); + expect(result.success).toBe(true); + expect(result.data!.type).toBe("custom"); + expect((result.data as UnknownBlock).data).toEqual({ foo: "bar", baz: 123 }); + }); +}); + +// ============================================================================ +// ToolCall Tests +// ============================================================================ +describe("ToolCallSchema", () => { + it("parses valid tool call", () => { + const input = { + type: "function", + id: "tc-123", + function: { + name: "Shell", + arguments: '{"command": "ls -la"}', + }, + }; + const result = ToolCallSchema.safeParse(input); + expect(result.success).toBe(true); + expect(result.data?.id).toBe("tc-123"); + expect(result.data?.function.name).toBe("Shell"); + }); + + it("parses tool call with null arguments (streaming)", () => { + const input = { + type: "function", + id: "tc-456", + function: { name: "ReadFile", arguments: null }, + }; + const result = ToolCallSchema.safeParse(input); + expect(result.success).toBe(true); + }); + + it("parses tool call with extras", () => { + const input = { + type: "function", + id: "tc-789", + function: { name: "WriteFile" }, + extras: { debug: true, timestamp: 1234567890 }, + }; + const result = ToolCallSchema.safeParse(input); + expect(result.success).toBe(true); + expect(result.data?.extras).toEqual({ debug: true, timestamp: 1234567890 }); + }); +}); + +// ============================================================================ +// ToolCallPart Tests +// ============================================================================ +describe("ToolCallPartSchema", () => { + it("parses arguments part", () => { + const input = { tool_call_id: "tc-1", arguments_part: '": "hello' }; + const result = ToolCallPartSchema.safeParse(input); + expect(result.success).toBe(true); + expect(result.data?.tool_call_id).toBe("tc-1"); + expect(result.data?.arguments_part).toBe('": "hello'); + }); + + it("parses null arguments part", () => { + const input = { tool_call_id: "tc-1", arguments_part: null }; + const result = ToolCallPartSchema.safeParse(input); + expect(result.success).toBe(true); + }); + + it("rejects missing tool call id", () => { + const input = {}; + const result = ToolCallPartSchema.safeParse(input); + expect(result.success).toBe(false); + }); +}); + +// ============================================================================ +// ToolResult Tests +// ============================================================================ +describe("ToolResultSchema", () => { + it("parses successful result with string output", () => { + const input = { + tool_call_id: "tc-123", + return_value: { + is_error: false, + output: "file contents here", + message: "File read successfully", + display: [{ type: "brief", text: "Read 100 lines" }], + }, + }; + const result = ToolResultSchema.safeParse(input); + expect(result.success).toBe(true); + expect(result.data?.return_value.is_error).toBe(false); + }); + + it("parses error result", () => { + const input = { + tool_call_id: "tc-456", + return_value: { + is_error: true, + output: "Error: File not found", + message: "Failed to read file", + display: [], + }, + }; + const result = ToolResultSchema.safeParse(input); + expect(result.success).toBe(true); + expect(result.data?.return_value.is_error).toBe(true); + }); + + it("parses result with ContentPart array output", () => { + const input = { + tool_call_id: "tc-789", + return_value: { + is_error: false, + output: [ + { type: "text", text: "Result part 1" }, + { type: "text", text: "Result part 2" }, + ], + message: "OK", + display: [], + }, + }; + const result = ToolResultSchema.safeParse(input); + expect(result.success).toBe(true); + }); +}); + +// ============================================================================ +// Event Payload Tests +// ============================================================================ +describe("TurnBeginSchema", () => { + it("parses string user input", () => { + const input = { user_input: "Hello, Kimi!" }; + const result = TurnBeginSchema.safeParse(input); + expect(result.success).toBe(true); + expect(result.data?.user_input).toBe("Hello, Kimi!"); + }); + + it("parses ContentPart array user input", () => { + const input = { + user_input: [ + { type: "text", text: "Check this image:" }, + { type: "image_url", image_url: { url: "data:image/png;base64,..." } }, + ], + }; + const result = TurnBeginSchema.safeParse(input); + expect(result.success).toBe(true); + }); +}); + +describe("StepBeginSchema", () => { + it("parses step begin", () => { + const input = { n: 1 }; + const result = StepBeginSchema.safeParse(input); + expect(result.success).toBe(true); + expect(result.data?.n).toBe(1); + }); + + it("parses high step number", () => { + const input = { n: 100 }; + const result = StepBeginSchema.safeParse(input); + expect(result.success).toBe(true); + }); +}); + +describe("StatusUpdateSchema", () => { + it("parses full status update", () => { + const input = { + context_usage: 0.75, + context_tokens: 12_345, + max_context_tokens: 200_000, + token_usage: { + input_other: 1000, + output: 500, + input_cache_read: 200, + input_cache_creation: 100, + }, + message_id: "msg-123", + }; + const result = StatusUpdateSchema.safeParse(input); + expect(result.success).toBe(true); + expect(result.data?.context_usage).toBe(0.75); + expect(result.data?.context_tokens).toBe(12_345); + expect(result.data?.max_context_tokens).toBe(200_000); + }); + + it("parses partial status update", () => { + const input = { context_usage: 0.5 }; + const result = StatusUpdateSchema.safeParse(input); + expect(result.success).toBe(true); + }); + + it("parses empty status update", () => { + const input = {}; + const result = StatusUpdateSchema.safeParse(input); + expect(result.success).toBe(true); + }); + + it("parses null values", () => { + const input = { + context_usage: null, + context_tokens: null, + max_context_tokens: null, + token_usage: null, + message_id: null, + }; + const result = StatusUpdateSchema.safeParse(input); + expect(result.success).toBe(true); + }); +}); + +describe("ApprovalRequestPayloadSchema", () => { + it("parses approval request", () => { + const input = { + id: "req-123", + tool_call_id: "tc-456", + sender: "Shell", + action: "run shell command", + description: "Run command `rm -rf node_modules`", + display: [{ type: "brief", text: "Delete node_modules" }], + }; + const result = ApprovalRequestPayloadSchema.safeParse(input); + expect(result.success).toBe(true); + expect(result.data?.sender).toBe("Shell"); + }); + + it("parses without display", () => { + const input = { + id: "req-789", + tool_call_id: "tc-101", + sender: "WriteFile", + action: "write file", + description: "Write to /src/main.ts", + }; + const result = ApprovalRequestPayloadSchema.safeParse(input); + expect(result.success).toBe(true); + }); +}); + +describe("PlanSchema", () => { + it("parses ACP plan entries", () => { + const input = { + entries: [ + { content: "Inspect protocol", status: "completed", priority: "high" }, + { content: "Update webview", status: "in_progress" }, + { content: "Run checks", status: "pending", priority: "medium" }, + ], + }; + const result = PlanSchema.safeParse(input); + expect(result.success).toBe(true); + expect(result.data?.entries).toHaveLength(3); + }); +}); + +describe("ConfigOptionUpdateSchema", () => { + it("parses flexible ACP config options", () => { + const input = { + configOptions: [ + { + configId: "model", + value: "kimi-k2", + options: [{ value: "kimi-k2", label: "Kimi K2" }], + }, + ], + }; + const result = ConfigOptionUpdateSchema.safeParse(input); + expect(result.success).toBe(true); + expect(result.data?.configOptions[0]).toMatchObject({ configId: "model" }); + }); +}); + +describe("ApprovalRequestResolvedSchema", () => { + it("parses all response types", () => { + const responses = ["approve", "approve_for_session", "reject"] as const; + for (const response of responses) { + const input = { request_id: "req-123", response }; + const result = ApprovalRequestResolvedSchema.safeParse(input); + expect(result.success).toBe(true); + expect(result.data?.response).toBe(response); + } + }); +}); + +describe("ApprovalResponseSchema", () => { + it("accepts valid responses", () => { + expect(ApprovalResponseSchema.safeParse("approve").success).toBe(true); + expect(ApprovalResponseSchema.safeParse("approve_for_session").success).toBe(true); + expect(ApprovalResponseSchema.safeParse("reject").success).toBe(true); + }); + + it("rejects invalid response", () => { + expect(ApprovalResponseSchema.safeParse("invalid").success).toBe(false); + }); +}); + +// ============================================================================ +// RunResult Tests +// ============================================================================ +describe("RunResultSchema", () => { + it("parses finished status", () => { + const input = { status: "finished" }; + const result = RunResultSchema.safeParse(input); + expect(result.success).toBe(true); + expect(result.data?.status).toBe("finished"); + }); + + it("parses cancelled status", () => { + const input = { status: "cancelled" }; + const result = RunResultSchema.safeParse(input); + expect(result.success).toBe(true); + }); + + it("parses max_steps_reached with steps", () => { + const input = { status: "max_steps_reached", steps: 100 }; + const result = RunResultSchema.safeParse(input); + expect(result.success).toBe(true); + expect(result.data?.steps).toBe(100); + }); +}); + +// ============================================================================ +// RpcMessage Tests +// ============================================================================ +describe("RpcMessageSchema", () => { + it("parses request message", () => { + const input = { + jsonrpc: "2.0", + id: "1", + method: "prompt", + params: { user_input: "hello" }, + }; + const result = RpcMessageSchema.safeParse(input); + expect(result.success).toBe(true); + }); + + it("parses response message", () => { + const input = { + jsonrpc: "2.0", + id: "1", + result: { status: "finished" }, + }; + const result = RpcMessageSchema.safeParse(input); + expect(result.success).toBe(true); + }); + + it("parses error response", () => { + const input = { + jsonrpc: "2.0", + id: "1", + error: { code: -32001, message: "LLM not set" }, + }; + const result = RpcMessageSchema.safeParse(input); + expect(result.success).toBe(true); + expect(result.data?.error?.code).toBe(-32001); + }); + + it("parses notification (no id)", () => { + const input = { + jsonrpc: "2.0", + method: "event", + params: { type: "ContentPart", payload: { type: "text", text: "hi" } }, + }; + const result = RpcMessageSchema.safeParse(input); + expect(result.success).toBe(true); + expect(result.data?.id).toBeUndefined(); + }); +}); + +// ============================================================================ +// parseEventPayload Tests +// ============================================================================ +describe("parseEventPayload", () => { + it("parses TurnBegin event", () => { + const result = parseEventPayload("TurnBegin", { user_input: "hello" }); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value.type).toBe("TurnBegin"); + expect((result.value as WireEvent & { type: "TurnBegin" }).payload.user_input).toBe("hello"); + } + }); + + it("parses StepBegin event", () => { + const result = parseEventPayload("StepBegin", { n: 1 }); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value.type).toBe("StepBegin"); + } + }); + + it("parses empty payload events", () => { + const emptyEvents = ["StepInterrupted", "CompactionBegin", "CompactionEnd"]; + for (const type of emptyEvents) { + const result = parseEventPayload(type, {}); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value.type).toBe(type); + } + } + }); + + it("parses StatusUpdate event", () => { + const result = parseEventPayload("StatusUpdate", { context_usage: 0.5 }); + expect(result.ok).toBe(true); + }); + + it("parses ContentPart event", () => { + const result = parseEventPayload("ContentPart", { type: "text", text: "hello" }); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value.type).toBe("ContentPart"); + } + }); + + it("parses ToolCall event", () => { + const result = parseEventPayload("ToolCall", { + type: "function", + id: "tc-1", + function: { name: "Shell", arguments: "{}" }, + }); + expect(result.ok).toBe(true); + }); + + it("parses ToolCallPart event", () => { + const result = parseEventPayload("ToolCallPart", { tool_call_id: "tc-1", arguments_part: "more args" }); + expect(result.ok).toBe(true); + }); + + it("parses ToolResult event", () => { + const result = parseEventPayload("ToolResult", { + tool_call_id: "tc-1", + return_value: { + is_error: false, + output: "ok", + message: "done", + display: [], + }, + }); + expect(result.ok).toBe(true); + }); + + it("parses ApprovalRequestResolved event", () => { + const result = parseEventPayload("ApprovalRequestResolved", { + request_id: "req-1", + response: "approve", + }); + expect(result.ok).toBe(true); + }); + + it("returns error for unknown event type", () => { + const result = parseEventPayload("UnknownType", {}); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toContain("Unknown event type"); + } + }); + + it("returns error for invalid payload", () => { + const result = parseEventPayload("StepBegin", { n: "not a number" }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toContain("Invalid payload"); + } + }); +}); + +// ============================================================================ +// parseRequestPayload Tests +// ============================================================================ +describe("parseRequestPayload", () => { + it("parses ApprovalRequest", () => { + const result = parseRequestPayload("ApprovalRequest", { + id: "req-1", + tool_call_id: "tc-1", + sender: "Shell", + action: "run command", + description: "Run `ls`", + }); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value.type).toBe("ApprovalRequest"); + expect(result.value.payload.sender).toBe("Shell"); + } + }); + + it("returns error for unknown request type", () => { + const result = parseRequestPayload("UnknownRequest", {}); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toContain("Unknown request type"); + } + }); + + it("returns error for invalid payload", () => { + const result = parseRequestPayload("ApprovalRequest", { id: 123 }); + expect(result.ok).toBe(false); + }); +}); + +// ============================================================================ +// SubagentEvent Tests +// ============================================================================ +describe("SubagentEvent parsing", () => { + it("parses nested SubagentEvent via parseEventPayload", () => { + const result = parseEventPayload("SubagentEvent", { + task_tool_call_id: "task-1", + event: { + type: "ContentPart", + payload: { type: "text", text: "from subagent" }, + }, + }); + expect(result.ok).toBe(true); + if (result.ok && result.value.type === "SubagentEvent") { + expect(result.value.payload.task_tool_call_id).toBe("task-1"); + expect(result.value.payload.event.type).toBe("ContentPart"); + } + }); +}); + +// ============================================================================ +// JSON Round-trip Tests +// ============================================================================ +describe("JSON round-trip", () => { + it("ContentPart text survives JSON round-trip", () => { + const original = { type: "text" as const, text: "hello world" }; + const json = JSON.stringify(original); + const parsed = ContentPartSchema.parse(JSON.parse(json)); + expect(parsed).toEqual(original); + }); + + it("ToolCall survives JSON round-trip", () => { + const original = { + type: "function" as const, + id: "tc-123", + function: { name: "Shell", arguments: '{"cmd":"ls"}' }, + }; + const json = JSON.stringify(original); + const parsed = ToolCallSchema.parse(JSON.parse(json)); + expect(parsed).toEqual(original); + }); + + it("RunResult survives JSON round-trip", () => { + const original = { status: "finished" as const }; + const json = JSON.stringify(original); + const parsed = RunResultSchema.parse(JSON.parse(json)); + expect(parsed).toEqual(original); + }); +}); diff --git a/apps/vscode/agent-sdk/tests/session.test.ts b/apps/vscode/agent-sdk/tests/session.test.ts new file mode 100644 index 0000000000..93b8195ce8 --- /dev/null +++ b/apps/vscode/agent-sdk/tests/session.test.ts @@ -0,0 +1,1348 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { SessionError } from "../errors"; +import type { StreamEvent, RunResult, ContentPart } from "../schema"; + +// ============================================================================ +// Mock ProtocolClient +// ============================================================================ + +const mockStart = vi.fn(); +const mockStop = vi.fn(); +const mockSendPrompt = vi.fn(); +const mockSendCancel = vi.fn(); +const mockSendApproval = vi.fn(); +const mockApplyConfig = vi.fn(); +let mockIsRunning = false; + +vi.mock("../protocol", () => { + function MockProtocolClient() { + return { + get isRunning() { + return mockIsRunning; + }, + start: mockStart, + stop: mockStop, + sendPrompt: mockSendPrompt, + sendCancel: mockSendCancel, + sendApproval: mockSendApproval, + applyConfig: mockApplyConfig, + }; + } + + return { + ProtocolClient: vi.fn(MockProtocolClient), + }; +}); + +// Import after mock +import { createSession, prompt } from "../session"; +import type { Session, Turn } from "../session"; + +// ============================================================================ +// Test Helpers +// ============================================================================ + +function createMockPromptStream(events: StreamEvent[], result: RunResult) { + return { + events: (async function* () { + for (const event of events) { + yield event; + } + })(), + result: Promise.resolve(result), + }; +} + +function createMockPromptStreamWithDelay(events: StreamEvent[], result: RunResult, delayMs = 10) { + return { + events: (async function* () { + for (const event of events) { + await new Promise((r) => setTimeout(r, delayMs)); + yield event; + } + })(), + result: Promise.resolve(result), + }; +} + +function createFailingPromptStream(error: Error) { + let rejectFn!: (err: Error) => void; + const resultPromise = new Promise<RunResult>((_, reject) => { + rejectFn = reject; + }); + resultPromise.catch(() => {}); + + return { + events: (async function* () { + // Reject the result promise when iteration starts + rejectFn(error); + throw error; + })(), + result: resultPromise, + }; +} + +// ============================================================================ +// createSession Tests +// ============================================================================ + +describe("createSession", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockIsRunning = false; + }); + + it("creates session with required options", () => { + const session = createSession({ workDir: "/project" }); + + expect(session.workDir).toBe("/project"); + expect(session.sessionId).toMatch(/^[0-9a-f-]{36}$/); // UUID format + expect(session.state).toBe("idle"); + }); + + it("creates session with custom sessionId", () => { + const session = createSession({ + workDir: "/project", + sessionId: "custom-session-123", + }); + + expect(session.sessionId).toBe("custom-session-123"); + }); + + it("creates session with all options", () => { + const session = createSession({ + workDir: "/project", + sessionId: "test-session", + model: "kimi-k2", + thinking: true, + yoloMode: true, + executable: "/usr/local/bin/kimi", + env: { MY_VAR: "value" }, + }); + + expect(session.workDir).toBe("/project"); + expect(session.sessionId).toBe("test-session"); + expect(session.model).toBe("kimi-k2"); + expect(session.thinking).toBe(true); + expect(session.mode).toBe("yolo"); + expect(session.yoloMode).toBe(true); + expect(session.executable).toBe("/usr/local/bin/kimi"); + expect(session.env).toEqual({ MY_VAR: "value" }); + }); + + it("uses default values for optional settings", () => { + const session = createSession({ workDir: "/project" }); + + expect(session.model).toBeUndefined(); + expect(session.thinking).toBe(false); + expect(session.mode).toBe("default"); + expect(session.yoloMode).toBe(false); + expect(session.executable).toBe("kimi"); + expect(session.env).toEqual({}); + }); +}); + +// ============================================================================ +// Session State Management Tests +// ============================================================================ + +describe("Session state management", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockIsRunning = false; + }); + + it("starts in idle state", () => { + const session = createSession({ workDir: "/project" }); + expect(session.state).toBe("idle"); + }); + + it("transitions to active when prompt is called", () => { + const session = createSession({ workDir: "/project" }); + + mockIsRunning = true; + mockSendPrompt.mockReturnValue(createMockPromptStream([], { status: "finished" })); + + session.prompt("Hello"); + + expect(session.state).toBe("active"); + }); + + it("transitions back to idle after turn completes", async () => { + const session = createSession({ workDir: "/project" }); + + mockIsRunning = true; + mockSendPrompt.mockReturnValue(createMockPromptStream([{ type: "ContentPart", payload: { type: "text", text: "Hi" } }], { status: "finished" })); + + const turn = session.prompt("Hello"); + + // Consume all events + for await (const _ of turn) { + // drain + } + + expect(session.state).toBe("idle"); + }); + + it("transitions to closed after close()", async () => { + const session = createSession({ workDir: "/project" }); + + await session.close(); + + expect(session.state).toBe("closed"); + }); + + it("throws SESSION_CLOSED when prompting closed session", async () => { + const session = createSession({ workDir: "/project" }); + + await session.close(); + + expect(() => session.prompt("Hello")).toThrow(SessionError); + expect(() => session.prompt("Hello")).toThrow("Session is closed"); + }); +}); + +// ============================================================================ +// Session Property Modification Tests +// ============================================================================ + +describe("Session property modification", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockIsRunning = false; + }); + + it("allows modifying model", () => { + const session = createSession({ workDir: "/project", model: "model-a" }); + + session.model = "model-b"; + + expect(session.model).toBe("model-b"); + }); + + it("allows modifying thinking", () => { + const session = createSession({ workDir: "/project", thinking: false }); + + session.thinking = true; + + expect(session.thinking).toBe(true); + }); + + it("allows modifying yoloMode", () => { + const session = createSession({ workDir: "/project", yoloMode: false }); + + session.yoloMode = true; + + expect(session.mode).toBe("yolo"); + expect(session.yoloMode).toBe(true); + }); + + it("allows modifying mode", () => { + const session = createSession({ workDir: "/project", mode: "default" }); + + session.mode = "auto"; + + expect(session.mode).toBe("auto"); + expect(session.yoloMode).toBe(false); + }); + + it("allows modifying executable", () => { + const session = createSession({ workDir: "/project" }); + + session.executable = "/custom/path/kimi"; + + expect(session.executable).toBe("/custom/path/kimi"); + }); + + it("allows modifying env", () => { + const session = createSession({ workDir: "/project" }); + + session.env = { NEW_VAR: "new_value" }; + + expect(session.env).toEqual({ NEW_VAR: "new_value" }); + }); +}); + +// ============================================================================ +// Session.prompt() Tests +// ============================================================================ + +describe("Session.prompt()", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockIsRunning = true; + mockStop.mockResolvedValue(undefined); + }); + + it("returns a Turn object", () => { + const session = createSession({ workDir: "/project" }); + + mockSendPrompt.mockReturnValue(createMockPromptStream([], { status: "finished" })); + + const turn = session.prompt("Hello"); + + expect(turn).toBeDefined(); + expect(typeof turn[Symbol.asyncIterator]).toBe("function"); + expect(turn.result).toBeInstanceOf(Promise); + }); + + it("accepts string content", async () => { + const session = createSession({ workDir: "/project" }); + + mockSendPrompt.mockReturnValue(createMockPromptStream([], { status: "finished" })); + + const turn = session.prompt("Hello, World!"); + + for await (const _ of turn) { + // drain + } + + expect(mockSendPrompt).toHaveBeenCalledWith("Hello, World!"); + }); + + it("accepts ContentPart array", async () => { + const session = createSession({ workDir: "/project" }); + + mockSendPrompt.mockReturnValue(createMockPromptStream([], { status: "finished" })); + + const content: ContentPart[] = [ + { type: "text", text: "Check this:" }, + { type: "image_url", image_url: { url: "data:image/png;base64,..." } }, + ]; + + const turn = session.prompt(content); + + for await (const _ of turn) { + // drain + } + + expect(mockSendPrompt).toHaveBeenCalledWith(content); + }); + + it("returns same Turn when called multiple times during active state", () => { + const session = createSession({ workDir: "/project" }); + + mockSendPrompt.mockReturnValue(createMockPromptStreamWithDelay([], { status: "finished" }, 100)); + + const turn1 = session.prompt("First"); + const turn2 = session.prompt("Second"); + + expect(turn1).toBe(turn2); + }); + + it("queues multiple messages", async () => { + const session = createSession({ workDir: "/project" }); + + let callCount = 0; + mockSendPrompt.mockImplementation((content) => { + callCount++; + return createMockPromptStream([{ type: "ContentPart", payload: { type: "text", text: `Response ${callCount}` } }], { status: "finished" }); + }); + + const turn = session.prompt("First"); + session.prompt("Second"); + session.prompt("Third"); + + const events: StreamEvent[] = []; + for await (const event of turn) { + events.push(event); + } + + expect(mockSendPrompt).toHaveBeenCalledTimes(3); + expect(mockSendPrompt).toHaveBeenNthCalledWith(1, "First"); + expect(mockSendPrompt).toHaveBeenNthCalledWith(2, "Second"); + expect(mockSendPrompt).toHaveBeenNthCalledWith(3, "Third"); + expect(events).toHaveLength(3); + }); + + it("starts new client on first prompt", async () => { + mockIsRunning = false; + + const session = createSession({ + workDir: "/project", + sessionId: "test-123", + model: "kimi-k2", + thinking: true, + }); + + mockSendPrompt.mockReturnValue(createMockPromptStream([], { status: "finished" })); + + // After start, client becomes running + mockStart.mockImplementation(() => { + mockIsRunning = true; + }); + + const turn = session.prompt("Hello"); + + for await (const _ of turn) { + // drain + } + + expect(mockStart).toHaveBeenCalledWith({ + sessionId: "test-123", + workDir: "/project", + model: "kimi-k2", + thinking: true, + mode: "default", + executablePath: "kimi", + environmentVariables: {}, + }); + }); +}); + +// ============================================================================ +// Session Config Hot-Reload Tests +// ============================================================================ + +describe("Session config hot-reload", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockIsRunning = true; + mockStop.mockResolvedValue(undefined); + }); + + it("applies config without restarting client when model changes", async () => { + const session = createSession({ workDir: "/project", model: "model-a" }); + + mockSendPrompt.mockReturnValue(createMockPromptStream([], { status: "finished" })); + + // First prompt + const turn1 = session.prompt("First"); + for await (const _ of turn1) { + // drain + } + + // Change model + session.model = "model-b"; + + // Second prompt - should hot-update client config + const turn2 = session.prompt("Second"); + for await (const _ of turn2) { + // drain + } + + expect(mockStop).not.toHaveBeenCalled(); + expect(mockStart).toHaveBeenCalledTimes(1); + expect(mockApplyConfig).toHaveBeenCalledWith(expect.objectContaining({ model: "model-b" })); + }); + + it("applies config without restarting client when thinking changes", async () => { + const session = createSession({ workDir: "/project", thinking: false }); + + mockSendPrompt.mockReturnValue(createMockPromptStream([], { status: "finished" })); + + const turn1 = session.prompt("First"); + for await (const _ of turn1) { + // drain + } + + session.thinking = true; + + const turn2 = session.prompt("Second"); + for await (const _ of turn2) { + // drain + } + + expect(mockStop).not.toHaveBeenCalled(); + expect(mockStart).toHaveBeenCalledTimes(1); + expect(mockApplyConfig).toHaveBeenCalledWith(expect.objectContaining({ thinking: true })); + }); + + it("applies config without restarting client when mode changes", async () => { + const session = createSession({ workDir: "/project", yoloMode: false }); + + mockSendPrompt.mockReturnValue(createMockPromptStream([], { status: "finished" })); + + const turn1 = session.prompt("First"); + for await (const _ of turn1) { + // drain + } + + session.mode = "auto"; + + const turn2 = session.prompt("Second"); + for await (const _ of turn2) { + // drain + } + + expect(mockStop).not.toHaveBeenCalled(); + expect(mockStart).toHaveBeenCalledTimes(1); + expect(mockApplyConfig).toHaveBeenCalledWith(expect.objectContaining({ mode: "auto" })); + }); + + it("restarts client when executable changes", async () => { + const session = createSession({ workDir: "/project" }); + + mockSendPrompt.mockReturnValue(createMockPromptStream([], { status: "finished" })); + + const turn1 = session.prompt("First"); + for await (const _ of turn1) { + // drain + } + + session.executable = "/new/path/kimi"; + + const turn2 = session.prompt("Second"); + for await (const _ of turn2) { + // drain + } + + expect(mockStop).toHaveBeenCalled(); + expect(mockStart).toHaveBeenLastCalledWith(expect.objectContaining({ executablePath: "/new/path/kimi" })); + }); + + it("restarts client when env changes", async () => { + const session = createSession({ workDir: "/project", env: { A: "1" } }); + + mockSendPrompt.mockReturnValue(createMockPromptStream([], { status: "finished" })); + + const turn1 = session.prompt("First"); + for await (const _ of turn1) { + // drain + } + + session.env = { B: "2" }; + + const turn2 = session.prompt("Second"); + for await (const _ of turn2) { + // drain + } + + expect(mockStop).toHaveBeenCalled(); + expect(mockStart).toHaveBeenLastCalledWith(expect.objectContaining({ environmentVariables: { B: "2" } })); + }); + + it("reuses client when config unchanged", async () => { + const session = createSession({ workDir: "/project", model: "kimi-k2" }); + + mockSendPrompt.mockReturnValue(createMockPromptStream([], { status: "finished" })); + + const turn1 = session.prompt("First"); + for await (const _ of turn1) { + // drain + } + + // No config change + + const turn2 = session.prompt("Second"); + for await (const _ of turn2) { + // drain + } + + // Should only start once + expect(mockStart).toHaveBeenCalledTimes(1); + expect(mockStop).not.toHaveBeenCalled(); + }); + + it("serializes concurrent restart calls", async () => { + const session = createSession({ workDir: "/project" }); + + mockSendPrompt.mockReturnValue(createMockPromptStream([], { status: "finished" })); + mockStart.mockImplementation(() => { + mockIsRunning = true; + }); + + const turn = session.prompt("First"); + for await (const _ of turn) { + // drain + } + + let resolveStop!: () => void; + mockStop.mockImplementation(() => { + mockIsRunning = false; + return new Promise<void>((resolve) => { + resolveStop = resolve; + }); + }); + + session.executable = "/new/path/kimi"; + + const firstRestart = session.ensureStarted(); + const secondRestart = session.ensureStarted(); + + expect(mockStop).toHaveBeenCalledTimes(1); + expect(mockStart).toHaveBeenCalledTimes(1); + + resolveStop(); + await Promise.all([firstRestart, secondRestart]); + + expect(mockStart).toHaveBeenCalledTimes(2); + }); +}); + +// ============================================================================ +// Session.close() Tests +// ============================================================================ + +describe("Session.close()", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockIsRunning = true; + mockStop.mockResolvedValue(undefined); + }); + + it("stops the client", async () => { + const session = createSession({ workDir: "/project" }); + + mockSendPrompt.mockReturnValue(createMockPromptStream([], { status: "finished" })); + + // Start a prompt to create client + const turn = session.prompt("Hello"); + for await (const _ of turn) { + // drain + } + + await session.close(); + + expect(mockStop).toHaveBeenCalled(); + }); + + it("is idempotent", async () => { + const session = createSession({ workDir: "/project" }); + + mockSendPrompt.mockReturnValue(createMockPromptStream([], { status: "finished" })); + + const turn = session.prompt("Hello"); + for await (const _ of turn) { + // drain + } + + await session.close(); + await session.close(); + await session.close(); + + // Should only stop once + expect(mockStop).toHaveBeenCalledTimes(1); + }); + + it("works when no client was created", async () => { + const session = createSession({ workDir: "/project" }); + + await session.close(); + + expect(mockStop).not.toHaveBeenCalled(); + expect(session.state).toBe("closed"); + }); + + it("supports Symbol.asyncDispose", async () => { + const session = createSession({ workDir: "/project" }); + + mockSendPrompt.mockReturnValue(createMockPromptStream([], { status: "finished" })); + + const turn = session.prompt("Hello"); + for await (const _ of turn) { + // drain + } + + await session[Symbol.asyncDispose](); + + expect(mockStop).toHaveBeenCalled(); + expect(session.state).toBe("closed"); + }); +}); + +// ============================================================================ +// Turn Async Iteration Tests +// ============================================================================ + +describe("Turn async iteration", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockIsRunning = true; + mockStop.mockResolvedValue(undefined); + }); + + it("yields all events from stream", async () => { + const session = createSession({ workDir: "/project" }); + + const expectedEvents: StreamEvent[] = [ + { type: "TurnBegin", payload: { user_input: "Hello" } }, + { type: "StepBegin", payload: { n: 1 } }, + { type: "ContentPart", payload: { type: "text", text: "Hi there!" } }, + ]; + + mockSendPrompt.mockReturnValue(createMockPromptStream(expectedEvents, { status: "finished" })); + + const turn = session.prompt("Hello"); + + const events: StreamEvent[] = []; + for await (const event of turn) { + events.push(event); + } + + expect(events).toEqual(expectedEvents); + }); + + it("returns RunResult when iteration completes", async () => { + const session = createSession({ workDir: "/project" }); + + mockSendPrompt.mockReturnValue(createMockPromptStream([], { status: "finished" })); + + const turn = session.prompt("Hello"); + + for await (const _ of turn) { + // drain + } + + const result = await turn.result; + + expect(result).toEqual({ status: "finished" }); + }); + + it("handles max_steps_reached result", async () => { + const session = createSession({ workDir: "/project" }); + + mockSendPrompt.mockReturnValue(createMockPromptStream([], { status: "max_steps_reached", steps: 100 })); + + const turn = session.prompt("Hello"); + + for await (const _ of turn) { + // drain + } + + const result = await turn.result; + + expect(result).toEqual({ status: "max_steps_reached", steps: 100 }); + expect(session.state).toBe("idle"); + }); + + it("handles cancelled result", async () => { + const session = createSession({ workDir: "/project" }); + + mockSendPrompt.mockReturnValue(createMockPromptStream([], { status: "cancelled" })); + + const turn = session.prompt("Hello"); + + for await (const _ of turn) { + // drain + } + + const result = await turn.result; + + expect(result).toEqual({ status: "cancelled" }); + expect(session.state).toBe("idle"); + }); + + it("propagates errors during iteration", async () => { + const session = createSession({ workDir: "/project" }); + + const error = new Error("Stream error"); + mockSendPrompt.mockReturnValue(createFailingPromptStream(error)); + + const turn = session.prompt("Hello"); + + await expect(async () => { + for await (const _ of turn) { + // drain + } + }).rejects.toThrow("Stream error"); + + await expect(turn.result).rejects.toThrow("Stream error"); + }); + + it("handles ToolCall events", async () => { + const session = createSession({ workDir: "/project" }); + + const events: StreamEvent[] = [ + { + type: "ToolCall", + payload: { + type: "function", + id: "tc-1", + function: { name: "Shell", arguments: '{"command":"ls"}' }, + }, + }, + { + type: "ToolResult", + payload: { + tool_call_id: "tc-1", + return_value: { + is_error: false, + output: "file1.txt\nfile2.txt", + message: "Command executed", + display: [], + }, + }, + }, + ]; + + mockSendPrompt.mockReturnValue(createMockPromptStream(events, { status: "finished" })); + + const turn = session.prompt("List files"); + + const received: StreamEvent[] = []; + for await (const event of turn) { + received.push(event); + } + + expect(received).toHaveLength(2); + expect(received[0].type).toBe("ToolCall"); + expect(received[1].type).toBe("ToolResult"); + }); + + it("handles ApprovalRequest events", async () => { + const session = createSession({ workDir: "/project" }); + + const events: StreamEvent[] = [ + { + type: "ApprovalRequest", + payload: { + id: "req-1", + tool_call_id: "tc-1", + sender: "Shell", + action: "run command", + description: "Run `rm -rf node_modules`", + }, + }, + ]; + + mockSendPrompt.mockReturnValue(createMockPromptStream(events, { status: "finished" })); + + const turn = session.prompt("Clean up"); + + const received: StreamEvent[] = []; + for await (const event of turn) { + received.push(event); + } + + expect(received).toHaveLength(1); + expect(received[0].type).toBe("ApprovalRequest"); + }); +}); + +// ============================================================================ +// Turn.interrupt() Tests +// ============================================================================ + +describe("Turn.interrupt()", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockIsRunning = true; + mockStop.mockResolvedValue(undefined); + mockSendCancel.mockResolvedValue(undefined); + }); + + it("calls sendCancel on client", async () => { + const session = createSession({ workDir: "/project" }); + + mockSendPrompt.mockReturnValue( + createMockPromptStreamWithDelay( + [ + { type: "ContentPart", payload: { type: "text", text: "Part 1" } }, + { type: "ContentPart", payload: { type: "text", text: "Part 2" } }, + ], + { status: "finished" }, + 50, + ), + ); + + const turn = session.prompt("Hello"); + + // Start iteration then interrupt + setTimeout(() => turn.interrupt(), 10); + + const events: StreamEvent[] = []; + for await (const event of turn) { + events.push(event); + } + + expect(mockSendCancel).toHaveBeenCalled(); + }); + + it("clears pending messages when interrupted during iteration", async () => { + const session = createSession({ workDir: "/project" }); + + let callCount = 0; + mockSendPrompt.mockImplementation(() => { + callCount++; + // First call takes longer, giving time to queue more and interrupt + const delay = callCount === 1 ? 30 : 10; + return createMockPromptStreamWithDelay([{ type: "ContentPart", payload: { type: "text", text: `Response ${callCount}` } }], { status: "finished" }, delay); + }); + + const turn = session.prompt("First"); + session.prompt("Second"); + session.prompt("Third"); + + // Start consuming, then interrupt after first event + const events: StreamEvent[] = []; + let interrupted = false; + + for await (const event of turn) { + events.push(event); + if (!interrupted) { + interrupted = true; + await turn.interrupt(); + } + } + + // Should have processed only the first message (interrupt cleared Second and Third) + expect(mockSendPrompt).toHaveBeenCalledTimes(1); + expect(mockSendPrompt).toHaveBeenCalledWith("First"); + }); + + it("does nothing when client is not running", async () => { + const session = createSession({ workDir: "/project" }); + + mockSendPrompt.mockReturnValue(createMockPromptStream([], { status: "finished" })); + + const turn = session.prompt("Hello"); + + // Complete the turn first + for await (const _ of turn) { + // drain + } + + mockIsRunning = false; + + await turn.interrupt(); + + expect(mockSendCancel).not.toHaveBeenCalled(); + }); +}); + +// ============================================================================ +// Turn.approve() Tests +// ============================================================================ + +describe("Turn.approve()", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockIsRunning = true; + mockStop.mockResolvedValue(undefined); + mockSendApproval.mockResolvedValue(undefined); + }); + + it("sends approval to client", async () => { + const session = createSession({ workDir: "/project" }); + + mockSendPrompt.mockReturnValue( + createMockPromptStreamWithDelay( + [ + { + type: "ApprovalRequest", + payload: { + id: "req-1", + tool_call_id: "tc-1", + sender: "Shell", + action: "run", + description: "Run command", + }, + }, + ], + { status: "finished" }, + 50, + ), + ); + + const turn = session.prompt("Hello"); + + // Consume events and approve + for await (const event of turn) { + if (event.type === "ApprovalRequest") { + await turn.approve(event.payload.id, "approve"); + } + } + + expect(mockSendApproval).toHaveBeenCalledWith("req-1", "approve"); + }); + + it("sends approve_for_session response", async () => { + const session = createSession({ workDir: "/project" }); + + mockSendPrompt.mockReturnValue( + createMockPromptStreamWithDelay( + [ + { + type: "ApprovalRequest", + payload: { + id: "req-2", + tool_call_id: "tc-2", + sender: "WriteFile", + action: "write", + description: "Write file", + }, + }, + ], + { status: "finished" }, + 50, + ), + ); + + const turn = session.prompt("Write file"); + + for await (const event of turn) { + if (event.type === "ApprovalRequest") { + await turn.approve(event.payload.id, "approve_for_session"); + } + } + + expect(mockSendApproval).toHaveBeenCalledWith("req-2", "approve_for_session"); + }); + + it("sends reject response", async () => { + const session = createSession({ workDir: "/project" }); + + mockSendPrompt.mockReturnValue( + createMockPromptStreamWithDelay( + [ + { + type: "ApprovalRequest", + payload: { + id: "req-3", + tool_call_id: "tc-3", + sender: "Shell", + action: "run", + description: "Run dangerous command", + }, + }, + ], + { status: "finished" }, + 50, + ), + ); + + const turn = session.prompt("Do something"); + + for await (const event of turn) { + if (event.type === "ApprovalRequest") { + await turn.approve(event.payload.id, "reject"); + } + } + + expect(mockSendApproval).toHaveBeenCalledWith("req-3", "reject"); + }); + + it("throws when client is not running", async () => { + const session = createSession({ workDir: "/project" }); + + mockSendPrompt.mockReturnValue(createMockPromptStream([], { status: "finished" })); + + const turn = session.prompt("Hello"); + + // Complete the turn + for await (const _ of turn) { + // drain + } + + mockIsRunning = false; + + await expect(turn.approve("req-1", "approve")).rejects.toThrow(SessionError); + await expect(turn.approve("req-1", "approve")).rejects.toThrow("Cannot approve: no active client"); + }); +}); + +// ============================================================================ +// Turn.result Tests +// ============================================================================ + +describe("Turn.result", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockIsRunning = true; + mockStop.mockResolvedValue(undefined); + }); + + it("resolves after iteration completes", async () => { + const session = createSession({ workDir: "/project" }); + + mockSendPrompt.mockReturnValue(createMockPromptStream([{ type: "ContentPart", payload: { type: "text", text: "Done" } }], { status: "finished" })); + + const turn = session.prompt("Hello"); + + // Result should be pending before iteration + let resolved = false; + turn.result.then(() => { + resolved = true; + }); + + expect(resolved).toBe(false); + + // Drain events + for await (const _ of turn) { + // drain + } + + // Now result should resolve + const result = await turn.result; + expect(result.status).toBe("finished"); + }); + + it("rejects when iteration throws", async () => { + const session = createSession({ workDir: "/project" }); + + const error = new Error("Iteration failed"); + mockSendPrompt.mockReturnValue(createFailingPromptStream(error)); + + const turn = session.prompt("Hello"); + + try { + for await (const _ of turn) { + // drain + } + } catch { + // expected + } + + await expect(turn.result).rejects.toThrow("Iteration failed"); + }); + + it("resolves with last result when processing multiple messages", async () => { + const session = createSession({ workDir: "/project" }); + + let callCount = 0; + mockSendPrompt.mockImplementation(() => { + callCount++; + return createMockPromptStream([], { + status: callCount === 3 ? "max_steps_reached" : "finished", + steps: callCount === 3 ? 100 : undefined, + }); + }); + + const turn = session.prompt("First"); + session.prompt("Second"); + session.prompt("Third"); + + for await (const _ of turn) { + // drain + } + + const result = await turn.result; + expect(result).toEqual({ status: "max_steps_reached", steps: 100 }); + }); +}); + +// ============================================================================ +// prompt() One-Shot Function Tests +// ============================================================================ + +describe("prompt() one-shot function", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockIsRunning = true; + mockStop.mockResolvedValue(undefined); + }); + + it("creates session, sends message, and returns result", async () => { + const events: StreamEvent[] = [ + { type: "TurnBegin", payload: { user_input: "Hello" } }, + { type: "ContentPart", payload: { type: "text", text: "Hi!" } }, + ]; + + mockSendPrompt.mockReturnValue(createMockPromptStream(events, { status: "finished" })); + + const { result, events: receivedEvents } = await prompt("Hello", { + workDir: "/project", + }); + + expect(result).toEqual({ status: "finished" }); + expect(receivedEvents).toEqual(events); + }); + + it("closes session after completion", async () => { + mockSendPrompt.mockReturnValue(createMockPromptStream([], { status: "finished" })); + + await prompt("Hello", { workDir: "/project" }); + + expect(mockStop).toHaveBeenCalled(); + }); + + it("closes session even when error occurs", async () => { + const error = new Error("Prompt failed"); + mockSendPrompt.mockReturnValue(createFailingPromptStream(error)); + + await expect(prompt("Hello", { workDir: "/project" })).rejects.toThrow("Prompt failed"); + + expect(mockStop).toHaveBeenCalled(); + }); + + it("passes options to session", async () => { + mockSendPrompt.mockReturnValue(createMockPromptStream([], { status: "finished" })); + + await prompt("Hello", { + workDir: "/project", + model: "kimi-k2", + thinking: true, + yoloMode: true, + }); + + expect(mockStart).toHaveBeenCalledWith( + expect.objectContaining({ + workDir: "/project", + model: "kimi-k2", + thinking: true, + mode: "yolo", + }), + ); + }); + + it("accepts ContentPart array", async () => { + mockSendPrompt.mockReturnValue(createMockPromptStream([], { status: "finished" })); + + const content: ContentPart[] = [{ type: "text", text: "Test" }]; + + await prompt(content, { workDir: "/project" }); + + expect(mockSendPrompt).toHaveBeenCalledWith(content); + }); +}); + +// ============================================================================ +// Edge Cases and Error Handling Tests +// ============================================================================ + +describe("Edge cases and error handling", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockIsRunning = true; + mockStop.mockResolvedValue(undefined); + }); + + it("handles empty event stream", async () => { + const session = createSession({ workDir: "/project" }); + + mockSendPrompt.mockReturnValue(createMockPromptStream([], { status: "finished" })); + + const turn = session.prompt("Hello"); + + const events: StreamEvent[] = []; + for await (const event of turn) { + events.push(event); + } + + expect(events).toHaveLength(0); + expect(await turn.result).toEqual({ status: "finished" }); + }); + + it("handles rapid successive prompts", async () => { + const session = createSession({ workDir: "/project" }); + + mockSendPrompt.mockReturnValue(createMockPromptStream([], { status: "finished" })); + + // Rapid fire prompts + const turn = session.prompt("1"); + session.prompt("2"); + session.prompt("3"); + session.prompt("4"); + session.prompt("5"); + + for await (const _ of turn) { + // drain + } + + expect(mockSendPrompt).toHaveBeenCalledTimes(5); + }); + + it("handles client stop error gracefully during close", async () => { + const session = createSession({ workDir: "/project" }); + + mockSendPrompt.mockReturnValue(createMockPromptStream([], { status: "finished" })); + + const turn = session.prompt("Hello"); + for await (const _ of turn) { + // drain + } + + mockStop.mockRejectedValue(new Error("Stop failed")); + + // Should not throw + await session.close(); + + expect(session.state).toBe("closed"); + }); + + it("generates unique session IDs", () => { + const sessions = Array.from({ length: 10 }, () => createSession({ workDir: "/project" })); + + const ids = sessions.map((s) => s.sessionId); + const uniqueIds = new Set(ids); + + expect(uniqueIds.size).toBe(10); + }); + + it("handles SubagentEvent in stream", async () => { + const session = createSession({ workDir: "/project" }); + + const events: StreamEvent[] = [ + { + type: "SubagentEvent", + payload: { + task_tool_call_id: "task-1", + event: { + type: "ContentPart", + payload: { type: "text", text: "From subagent" }, + }, + }, + }, + ]; + + mockSendPrompt.mockReturnValue(createMockPromptStream(events, { status: "finished" })); + + const turn = session.prompt("Start task"); + + const received: StreamEvent[] = []; + for await (const event of turn) { + received.push(event); + } + + expect(received).toHaveLength(1); + expect(received[0].type).toBe("SubagentEvent"); + }); + + it("handles StatusUpdate events", async () => { + const session = createSession({ workDir: "/project" }); + + const events: StreamEvent[] = [ + { + type: "StatusUpdate", + payload: { + context_usage: 0.75, + token_usage: { + input_other: 1000, + output: 500, + input_cache_read: 100, + input_cache_creation: 50, + }, + message_id: "msg-123", + }, + }, + ]; + + mockSendPrompt.mockReturnValue(createMockPromptStream(events, { status: "finished" })); + + const turn = session.prompt("Hello"); + + const received: StreamEvent[] = []; + for await (const event of turn) { + received.push(event); + } + + expect(received).toHaveLength(1); + expect(received[0].type).toBe("StatusUpdate"); + if (received[0].type === "StatusUpdate") { + expect(received[0].payload.context_usage).toBe(0.75); + } + }); + + it("handles CompactionBegin and CompactionEnd events", async () => { + const session = createSession({ workDir: "/project" }); + + const events: StreamEvent[] = [ + { type: "CompactionBegin", payload: {} }, + { type: "CompactionEnd", payload: {} }, + ]; + + mockSendPrompt.mockReturnValue(createMockPromptStream(events, { status: "finished" })); + + const turn = session.prompt("Long conversation"); + + const received: StreamEvent[] = []; + for await (const event of turn) { + received.push(event); + } + + expect(received).toHaveLength(2); + expect(received[0].type).toBe("CompactionBegin"); + expect(received[1].type).toBe("CompactionEnd"); + }); +}); diff --git a/apps/vscode/agent-sdk/tests/utils.test.ts b/apps/vscode/agent-sdk/tests/utils.test.ts new file mode 100644 index 0000000000..26d46b90b7 --- /dev/null +++ b/apps/vscode/agent-sdk/tests/utils.test.ts @@ -0,0 +1,171 @@ +import { describe, it, expect } from "vitest"; +import { cleanSystemTags, cleanUserInput, extractBrief, extractTextFromContentParts, formatContentOutput } from "../utils"; +import type { DisplayBlock, ContentPart } from "../schema"; + +describe("cleanSystemTags", () => { + it("removes system reminder blocks", () => { + const text = "Hello\n<system-reminder>\nCurrent todo list:\n[pending] Hidden\n</system-reminder>\nWorld"; + expect(cleanSystemTags(text)).toBe("Hello\nWorld"); + }); + + it("removes legacy system blocks", () => { + expect(cleanSystemTags("<system>hidden</system>\nVisible")).toBe("Visible"); + }); +}); + +describe("cleanUserInput", () => { + it("returns null when text only contains system reminder content", () => { + expect(cleanUserInput("<system-reminder>hidden</system-reminder>")).toBeNull(); + }); + + it("cleans text content parts and preserves media parts", () => { + const input: ContentPart[] = [ + { type: "text", text: "Visible\n<system-reminder>hidden</system-reminder>" }, + { type: "image_url", image_url: { url: "data:image/png;base64,abc" } }, + ]; + + expect(cleanUserInput(input)).toEqual([ + { type: "text", text: "Visible" }, + { type: "image_url", image_url: { url: "data:image/png;base64,abc" } }, + ]); + }); +}); + +// ============================================================================ +// extractBrief Tests +// ============================================================================ +describe("extractBrief", () => { + it("extracts brief text from display blocks", () => { + const display: DisplayBlock[] = [ + { type: "diff", path: "/file.ts", old_text: "a", new_text: "b" }, + { type: "brief", text: "Modified file.ts" }, + { type: "todo", items: [{ title: "Task", status: "done" }] }, + ]; + expect(extractBrief(display)).toBe("Modified file.ts"); + }); + + it("returns first brief when multiple exist", () => { + const display: DisplayBlock[] = [ + { type: "brief", text: "First brief" }, + { type: "brief", text: "Second brief" }, + ]; + expect(extractBrief(display)).toBe("First brief"); + }); + + it("returns empty string when no brief block", () => { + const display: DisplayBlock[] = [{ type: "diff", path: "/file.ts", old_text: "a", new_text: "b" }]; + expect(extractBrief(display)).toBe(""); + }); + + it("returns empty string for undefined", () => { + expect(extractBrief(undefined)).toBe(""); + }); + + it("returns empty string for empty array", () => { + expect(extractBrief([])).toBe(""); + }); +}); + +// ============================================================================ +// extractTextFromContentParts Tests +// ============================================================================ +describe("extractTextFromContentParts", () => { + it("extracts text from text parts", () => { + const parts: ContentPart[] = [ + { type: "text", text: "Hello" }, + { type: "text", text: "World" }, + ]; + expect(extractTextFromContentParts(parts)).toBe("Hello\nWorld"); + }); + + it("filters out non-text parts", () => { + const parts: ContentPart[] = [ + { type: "text", text: "Before" }, + { type: "think", think: "thinking...", encrypted: null }, + { type: "text", text: "After" }, + { type: "image_url", image_url: { url: "data:image/png;base64,..." } }, + ]; + expect(extractTextFromContentParts(parts)).toBe("Before\nAfter"); + }); + + it("returns empty string for empty array", () => { + expect(extractTextFromContentParts([])).toBe(""); + }); + + it("returns empty string when no text parts", () => { + const parts: ContentPart[] = [ + { type: "think", think: "thinking..." }, + { type: "image_url", image_url: { url: "..." } }, + ]; + expect(extractTextFromContentParts(parts)).toBe(""); + }); + + it("handles single text part", () => { + const parts: ContentPart[] = [{ type: "text", text: "Single" }]; + expect(extractTextFromContentParts(parts)).toBe("Single"); + }); +}); + +// ============================================================================ +// formatContentOutput Tests +// ============================================================================ +describe("formatContentOutput", () => { + it("returns string as-is", () => { + expect(formatContentOutput("Hello, World!")).toBe("Hello, World!"); + }); + + it("returns empty string as-is", () => { + expect(formatContentOutput("")).toBe(""); + }); + + it("formats ContentPart array with text parts", () => { + const parts: ContentPart[] = [ + { type: "text", text: "Line 1" }, + { type: "text", text: "Line 2" }, + ]; + expect(formatContentOutput(parts)).toBe("Line 1\nLine 2"); + }); + + it("shows placeholder for non-text parts", () => { + const parts: ContentPart[] = [ + { type: "text", text: "Before" }, + { type: "image_url", image_url: { url: "..." } }, + { type: "text", text: "After" }, + ]; + expect(formatContentOutput(parts)).toBe("Before\n[image_url]\nAfter"); + }); + + it("handles think parts", () => { + const parts: ContentPart[] = [ + { type: "think", think: "reasoning..." }, + { type: "text", text: "Result" }, + ]; + expect(formatContentOutput(parts)).toBe("[think]\nResult"); + }); + + it("handles audio_url parts", () => { + const parts: ContentPart[] = [{ type: "audio_url", audio_url: { url: "data:audio/aac;base64,..." } }]; + expect(formatContentOutput(parts)).toBe("[audio_url]"); + }); + + it("handles video_url parts", () => { + const parts: ContentPart[] = [{ type: "video_url", video_url: { url: "data:video/mp4;base64,..." } }]; + expect(formatContentOutput(parts)).toBe("[video_url]"); + }); + + it("handles empty array", () => { + expect(formatContentOutput([])).toBe(""); + }); + + it("handles mixed array with strings (edge case)", () => { + // This tests the internal string handling in the array case + const parts = ["raw string" as unknown as ContentPart]; + expect(formatContentOutput(parts)).toBe("raw string"); + }); + + it("stringifies non-array non-string input", () => { + // Edge case: input is neither string nor array + const obj = { foo: "bar" } as unknown as string | ContentPart[]; + expect(formatContentOutput(obj)).toBe('{"foo":"bar"}'); + }); +}); diff --git a/apps/vscode/agent-sdk/tsconfig.json b/apps/vscode/agent-sdk/tsconfig.json new file mode 100644 index 0000000000..0622fac815 --- /dev/null +++ b/apps/vscode/agent-sdk/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2024"], + "types": ["node"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "./dist", + "rootDir": ".", + "resolveJsonModule": true, + "isolatedModules": true, + "verbatimModuleSyntax": false, + "ignoreDeprecations": "6.0", + "allowSyntheticDefaultImports": true, + "forceConsistentCasingInFileNames": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["*.ts", "**/*.ts"], + "exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"] +} diff --git a/apps/vscode/agent-sdk/tsup.config.ts b/apps/vscode/agent-sdk/tsup.config.ts new file mode 100644 index 0000000000..35124f77d1 --- /dev/null +++ b/apps/vscode/agent-sdk/tsup.config.ts @@ -0,0 +1,37 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: { + index: "index.ts", + schema: "schema.ts", + errors: "errors.ts", + utils: "utils.ts", + paths: "paths.ts", + }, + + format: ["esm", "cjs"], + outDir: "dist", + + dts: { + entry: { + index: "index.ts", + schema: "schema.ts", + errors: "errors.ts", + utils: "utils.ts", + paths: "paths.ts", + }, + }, + + splitting: false, + clean: true, + sourcemap: true, + treeshake: true, + + target: "node18", + platform: "node", + external: ["node:*"], + + define: { + "process.env.BROWSER": "false", + }, +}); diff --git a/apps/vscode/agent-sdk/utils.ts b/apps/vscode/agent-sdk/utils.ts new file mode 100644 index 0000000000..e7ed8ade8c --- /dev/null +++ b/apps/vscode/agent-sdk/utils.ts @@ -0,0 +1,63 @@ +import type { DisplayBlock, BriefBlock, ContentPart } from "./schema"; + +// Display Block Helpers +export function extractBrief(display?: DisplayBlock[]): string { + const brief = display?.find((d): d is BriefBlock => d.type === "brief"); + return brief?.text ?? ""; +} + +// Content Part Helpers +export function extractTextFromContentParts(parts: ContentPart[]): string { + return parts + .filter((p): p is ContentPart & { type: "text" } => p.type === "text") + .map((p) => p.text) + .join("\n"); +} + +export function formatContentOutput(output: string | ContentPart[]): string { + if (typeof output === "string") { + return output; + } + + if (!Array.isArray(output)) { + return JSON.stringify(output); + } + + return output + .map((item) => { + if (typeof item === "string") { + return item; + } + if (item.type === "text") { + return item.text; + } + // Placeholder for non-text parts for debugging + return `[${item.type}]`; + }) + .filter(Boolean) + .join("\n"); +} + +export function cleanSystemTags(text: string): string { + return text.replace(/<(system-reminder|system)\b[^>]*>[\s\S]*?<\/\1>\s*/gi, "").trim(); +} + +export function cleanUserInput(input: string | ContentPart[]): string | ContentPart[] | null { + if (typeof input === "string") { + const text = cleanSystemTags(input); + return text || null; + } + + const parts = input + .map((part) => { + if (part.type !== "text") { + return part; + } + + const text = cleanSystemTags(part.text); + return text ? { ...part, text } : null; + }) + .filter((part): part is ContentPart => part !== null); + + return parts.length > 0 ? parts : null; +} diff --git a/apps/vscode/agent-sdk/vitest.config.ts b/apps/vscode/agent-sdk/vitest.config.ts new file mode 100644 index 0000000000..c05c18ed5a --- /dev/null +++ b/apps/vscode/agent-sdk/vitest.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globals: true, + environment: "node", + include: ["tests/**/*.test.ts"], + coverage: { + provider: "v8", + reporter: ["text", "html"], + include: ["*.ts"], + exclude: ["index.ts", "vitest.config.ts", "tests/**"], + }, + }, +}); diff --git a/apps/vscode/dev.js b/apps/vscode/dev.js new file mode 100644 index 0000000000..2c7b0f0fa8 --- /dev/null +++ b/apps/vscode/dev.js @@ -0,0 +1,38 @@ +const { spawn } = require("child_process"); + +console.log("🚀 Starting development environment...\n"); + +const extensionWatch = spawn("node", ["esbuild.js", "--watch"], { + cwd: __dirname, + stdio: "inherit", + shell: true, +}); + +const webviewWatch = spawn("pnpm", ["run", "build", "--", "--watch"], { + cwd: __dirname + "/webview-ui", + stdio: "inherit", + shell: true, +}); + +function shutdown() { + console.log("\n🛑 Shutting down...\n"); + extensionWatch.kill("SIGINT"); + webviewWatch.kill("SIGINT"); + process.exit(0); +} + +process.on("SIGINT", shutdown); +process.on("exit", shutdown); + +extensionWatch.on("error", (err) => { + console.error("Extension build error:", err); + shutdown(); +}); + +webviewWatch.on("error", (err) => { + console.error("Webview build error:", err); + shutdown(); +}); + +console.log("✅ Watching extension and webview for changes"); +console.log(" Press F5 in VSCode to test\n"); diff --git a/apps/vscode/esbuild.js b/apps/vscode/esbuild.js new file mode 100644 index 0000000000..e01e15e0a3 --- /dev/null +++ b/apps/vscode/esbuild.js @@ -0,0 +1,66 @@ +const esbuild = require("esbuild"); + +const production = process.argv.includes("--production"); +const watch = process.argv.includes("--watch"); + +/** + * @type {import('esbuild').Plugin} + */ +const esbuildProblemMatcherPlugin = { + name: "esbuild-problem-matcher", + + setup(build) { + build.onStart(() => { + console.log("[watch] build started"); + }); + build.onEnd((result) => { + result.errors.forEach(({ text, location }) => { + console.error(`✘ [ERROR] ${text}`); + console.error(` ${location.file}:${location.line}:${location.column}:`); + }); + console.log("[watch] build finished"); + }); + }, +}; + +async function main() { + const ctx = await esbuild.context({ + entryPoints: ["src/extension.ts"], + bundle: true, + format: "cjs", + minify: production, + sourcemap: !production, + sourcesContent: false, + platform: "node", + outfile: "dist/extension.js", + external: ["vscode"], + logLevel: "info", + loader: { + ".ts": "ts", + }, + plugins: [ + esbuildProblemMatcherPlugin, + { + name: "watch-build", + setup(build) { + build.onEnd((result) => { + console.log("[watch] build finished"); + }); + }, + }, + ], + }); + + if (watch) { + await ctx.watch(); + console.log("[watch] watching..."); + } else { + await ctx.rebuild(); + await ctx.dispose(); + } +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/apps/vscode/eslint.config.mjs b/apps/vscode/eslint.config.mjs new file mode 100644 index 0000000000..24e7814435 --- /dev/null +++ b/apps/vscode/eslint.config.mjs @@ -0,0 +1,43 @@ +import typescriptEslint from "typescript-eslint"; + +export default [ + { + ignores: ["**/dist/**", "**/node_modules/**"], + }, + { + files: ["**/*.{ts,tsx}"], + }, + { + plugins: { + "@typescript-eslint": typescriptEslint.plugin, + }, + + languageOptions: { + parser: typescriptEslint.parser, + ecmaVersion: 2022, + sourceType: "module", + parserOptions: { + ecmaFeatures: { + jsx: true, + }, + }, + }, + + rules: { + "@typescript-eslint/naming-convention": [ + "warn", + { + selector: "import", + format: ["camelCase", "PascalCase"], + }, + ], + "array-element-newline": ["error", "consistent"], + "function-call-argument-newline": ["error", "consistent"], + + curly: "warn", + eqeqeq: "warn", + "no-throw-literal": "warn", + semi: "warn", + }, + }, +]; diff --git a/apps/vscode/package.json b/apps/vscode/package.json new file mode 100644 index 0000000000..97896875b3 --- /dev/null +++ b/apps/vscode/package.json @@ -0,0 +1,235 @@ +{ + "name": "kimi-code", + "publisher": "kimi", + "displayName": "Kimi Code", + "description": "AI-powered coding assistant with Kimi Code", + "private": true, + "version": "0.0.3", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/MoonshotAI/kimi-code.git", + "directory": "apps/vscode" + }, + "engines": { + "vscode": "^1.70.0" + }, + "extensionKind": [ + "workspace" + ], + "capabilities": { + "untrustedWorkspaces": { + "supported": false + }, + "virtualWorkspaces": false + }, + "categories": [ + "AI", + "Chat", + "Programming Languages" + ], + "activationEvents": [ + "onView:kimi.webview" + ], + "main": "./dist/extension.js", + "contributes": { + "configuration": { + "title": "Kimi Code", + "properties": { + "kimi.yoloMode": { + "type": "boolean", + "default": false, + "description": "Automatically approve all tool calls without prompting" + }, + "kimi.autosave": { + "type": "boolean", + "default": true, + "description": "Automatically save files before Kimi reads or writes them" + }, + "kimi.executablePath": { + "type": "string", + "default": "", + "description": "Path to the Kimi Code executable" + }, + "kimi.enableNewConversationShortcut": { + "type": "boolean", + "default": false, + "description": "Use Cmd/Ctrl+N to start a new conversation when Kimi is focused" + }, + "kimi.useCtrlEnterToSend": { + "type": "boolean", + "default": false, + "description": "Use Ctrl/Cmd+Enter to send prompts instead of Enter" + }, + "kimi.environmentVariables": { + "type": "object", + "default": {}, + "description": "Environment variables passed to Kimi Code CLI. Useful for Kimi Code 0.14.0+ options such as KIMI_MODEL_TEMPERATURE, KIMI_MODEL_TOP_P, KIMI_MODEL_THINKING_KEEP, KIMI_CODE_NO_AUTO_UPDATE, and KIMI_CODE_EXPERIMENTAL_SUB_SKILL (experimental).", + "additionalProperties": { + "type": "string" + } + } + } + }, + "commands": [ + { + "command": "kimi.clearAllState", + "title": "Kimi Code: [DEBUG] Clear All State" + }, + { + "command": "kimi.openInTab", + "title": "Kimi Code: Open in New Tab", + "icon": "$(link-external)" + }, + { + "command": "kimi.openInSideBar", + "title": "Kimi Code: Open in Side Panel", + "icon": "$(layout-sidebar-left)" + }, + { + "command": "kimi.focusInput", + "title": "Kimi Code: Focus Input", + "icon": "$(edit)" + }, + { + "command": "kimi.insertMention", + "title": "Kimi Code: Insert Current File", + "icon": "$(mention)" + }, + { + "command": "kimi.newConversation", + "title": "Kimi Code: New Conversation", + "icon": "$(add)" + }, + { + "command": "kimi.showLogs", + "title": "Kimi Code: Show Logs", + "icon": "$(output)" + }, + { + "command": "kimi.login", + "title": "Kimi Code: Login", + "icon": "$(sign-in)" + }, + { + "command": "kimi.logout", + "title": "Kimi Code: Logout", + "icon": "$(sign-out)" + } + ], + "keybindings": [ + { + "command": "kimi.focusInput", + "key": "ctrl+shift+k", + "mac": "cmd+shift+k" + }, + { + "command": "kimi.insertMention", + "key": "alt+k", + "mac": "alt+k", + "when": "editorTextFocus" + } + ], + "viewsContainers": { + "activitybar": [ + { + "id": "kimi-sidebar", + "title": "Kimi Code", + "icon": "resources/kimi-icon.svg" + } + ] + }, + "views": { + "kimi-sidebar": [ + { + "type": "webview", + "id": "kimi.webview", + "name": "Kimi Code" + } + ] + }, + "menus": { + "view/title": [ + { + "command": "kimi.openInTab", + "when": "view == kimi.webview", + "group": "navigation" + }, + { + "command": "kimi.newConversation", + "when": "view == kimi.webview", + "group": "navigation" + } + ], + "commandPalette": [ + { + "command": "kimi.clearAllState", + "when": "isDevelopment" + }, + { + "command": "kimi.openInTab", + "when": "true" + }, + { + "command": "kimi.openInSideBar", + "when": "true" + }, + { + "command": "kimi.focusInput", + "when": "true" + }, + { + "command": "kimi.insertMention", + "when": "true" + }, + { + "command": "kimi.newConversation", + "when": "true" + }, + { + "command": "kimi.showLogs", + "when": "true" + }, + { + "command": "kimi.login", + "when": "!kimi.isLoggedIn" + }, + { + "command": "kimi.logout", + "when": "kimi.isLoggedIn" + } + ], + "editor/context": [ + { + "command": "kimi.insertMention", + "group": "kimi-code", + "when": "editorTextFocus" + } + ] + } + }, + "scripts": { + "vscode:prepublish": "pnpm run build", + "dev": "node dev.js", + "lint": "eslint src shared agent-sdk agent-display-model/src agent-display-model/test webview-ui/src", + "check:boundaries": "node scripts/check-vscode-runtime-boundaries.mjs", + "typecheck": "pnpm run build:sdk && pnpm -C ./agent-display-model run typecheck && pnpm run check:boundaries && tsc --noEmit -p tsconfig.json", + "build": "pnpm run typecheck && pnpm run build:webview && pnpm run build:extension", + "build:sdk": "pnpm -C ./agent-sdk run build", + "build:webview": "pnpm -C ./webview-ui run build", + "build:extension": "node esbuild.js --production", + "package": "vsce package --no-dependencies && node scripts/check-vsix-contents.mjs" + }, + "dependencies": { + "@moonshot-ai/kimi-code-vscode-agent-sdk": "workspace:*" + }, + "devDependencies": { + "@types/node": "^22.15.3", + "@types/vscode": "^1.70.0", + "@vscode/vsce": "^3.0.0", + "esbuild": "^0.27.1", + "eslint": "^9.39.1", + "typescript": "6.0.2", + "typescript-eslint": "^8.48.1" + } +} diff --git a/apps/vscode/resources/kimi-icon.svg b/apps/vscode/resources/kimi-icon.svg new file mode 100644 index 0000000000..db4631b8e4 --- /dev/null +++ b/apps/vscode/resources/kimi-icon.svg @@ -0,0 +1,33 @@ +<svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> + <!-- body outline --> + <rect + x="3" + y="4.5" + width="18" + height="13" + rx="2.2" + fill="none" + stroke="currentColor" + stroke-width="1.6" + /> + + <!-- left eye (filled) --> + <rect + x="9.6" + y="8.0" + width="1.4" + height="2.6" + rx="0.45" + fill="currentColor" + /> + + <!-- right eye (filled) --> + <rect + x="15.6" + y="8.0" + width="1.4" + height="2.6" + rx="0.45" + fill="currentColor" + /> +</svg> diff --git a/apps/vscode/scripts/check-vscode-runtime-boundaries.mjs b/apps/vscode/scripts/check-vscode-runtime-boundaries.mjs new file mode 100644 index 0000000000..65f202ad04 --- /dev/null +++ b/apps/vscode/scripts/check-vscode-runtime-boundaries.mjs @@ -0,0 +1,102 @@ +import { readdir, readFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const appRoot = path.resolve(scriptDir, ".."); + +const roots = [ + path.join(appRoot, "src"), + path.join(appRoot, "shared"), + path.join(appRoot, "webview-ui", "src"), + path.join(appRoot, "agent-sdk"), + path.join(appRoot, "agent-display-model", "src"), +]; + +const skipDirs = new Set(["node_modules", "dist", "coverage", ".turbo", ".vite"]); +const sourceExtensions = new Set([".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"]); + +const forbiddenPackages = [ + "@moonshot-ai/agent-core", + "@moonshot-ai/kimi-code-sdk", + "@moonshot-ai/kaos", +]; + +async function walk(dir, files = []) { + for (const entry of await readdir(dir, { withFileTypes: true })) { + if (entry.isDirectory()) { + if (!skipDirs.has(entry.name)) { + await walk(path.join(dir, entry.name), files); + } + continue; + } + + if (sourceExtensions.has(path.extname(entry.name))) { + files.push(path.join(dir, entry.name)); + } + } + return files; +} + +function isAllowedAcpProtocolImport(lines, index) { + const line = lines[index]; + if (!line.includes("@moonshot-ai/acp-adapter/protocol")) { + return true; + } + + const window = lines.slice(Math.max(0, index - 10), index + 1).join("\n"); + return /\bimport\s+type\b/.test(window); +} + +function checkFile(relativeFile, text) { + const violations = []; + const lines = text.split(/\r?\n/); + + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index]; + const lineNumber = index + 1; + + for (const pkg of forbiddenPackages) { + if (line.includes(pkg)) { + violations.push({ lineNumber, text: line.trim(), reason: `forbidden runtime package ${pkg}` }); + } + } + + if (line.includes("@moonshot-ai/acp-adapter") && !isAllowedAcpProtocolImport(lines, index)) { + violations.push({ + lineNumber, + text: line.trim(), + reason: "only `import type ... from '@moonshot-ai/acp-adapter/protocol'` is allowed in VS Code runtime code", + }); + } + } + + return violations; +} + +async function main() { + const files = (await Promise.all(roots.map((root) => walk(root)))).flat(); + const violations = []; + + for (const file of files) { + const relativeFile = path.relative(appRoot, file).replaceAll(path.sep, "/"); + const text = await readFile(file, "utf8"); + violations.push(...checkFile(relativeFile, text).map((violation) => ({ file: relativeFile, ...violation }))); + } + + if (violations.length > 0) { + console.error("VS Code runtime boundary check failed:\n"); + for (const violation of violations) { + console.error(`${violation.file}:${violation.lineNumber}: ${violation.reason}`); + console.error(` ${violation.text}`); + } + process.exit(1); + } + + console.log("VS Code runtime boundary check passed."); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/apps/vscode/scripts/check-vsix-contents.mjs b/apps/vscode/scripts/check-vsix-contents.mjs new file mode 100644 index 0000000000..4445a563d9 --- /dev/null +++ b/apps/vscode/scripts/check-vsix-contents.mjs @@ -0,0 +1,111 @@ +import { execFileSync } from "node:child_process"; +import { readdirSync, statSync } from "node:fs"; +import { basename, dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const scriptDir = dirname(fileURLToPath(import.meta.url)); +const extensionDir = resolve(scriptDir, ".."); + +function findLatestVsix() { + const candidates = readdirSync(extensionDir) + .filter((name) => /^kimi-code-.*\.vsix$/u.test(name)) + .map((name) => { + const path = join(extensionDir, name); + return { name, path, mtimeMs: statSync(path).mtimeMs }; + }) + .sort((a, b) => b.mtimeMs - a.mtimeMs); + + return candidates[0]?.path; +} + +function listVsixEntries(vsixPath) { + const output = execFileSync("unzip", ["-l", vsixPath], { encoding: "utf8" }); + const entries = []; + + for (const line of output.split("\n")) { + const match = line.match(/^\s*\d+\s+\S+\s+\S+\s+(.+?)\s*$/u); + if (match?.[1]) { + entries.push(match[1]); + } + } + + return entries; +} + +function validateEntries(entries) { + const failures = []; + const bannedDirectoryPrefixes = [ + "src/", + "shared/", + "agent-sdk/", + "agent-display-model/", + "webview-ui/", + "node_modules/", + "tests/", + "docs/", + "scripts/", + ]; + const bannedFileNames = new Set([ + "tsconfig.json", + "esbuild.js", + "dev.js", + "eslint.config.mjs", + "vite.config.ts", + "components.json", + ]); + + for (const entry of entries) { + if (bannedDirectoryPrefixes.some((prefix) => entry.startsWith(prefix))) { + failures.push(`${entry} matches a banned source/build directory`); + continue; + } + + if (/\.map$/iu.test(entry)) { + failures.push(`${entry} is a source map`); + continue; + } + + if (/\.vsix$/iu.test(entry)) { + failures.push(`${entry} is a nested VSIX`); + continue; + } + + if (bannedFileNames.has(basename(entry))) { + failures.push(`${entry} is a source/build config`); + } + } + + const packageRoot = entries.every( + (entry) => entry === "[Content_Types].xml" || entry === "extension.vsixmanifest" || entry.startsWith("extension/"), + ) + ? "extension/" + : ""; + const requiredEntries = ["dist/extension.js", "dist/webview.js", "package.json"].map((entry) => `${packageRoot}${entry}`); + for (const requiredEntry of requiredEntries) { + if (!entries.includes(requiredEntry)) { + failures.push(`missing required runtime entry ${requiredEntry}`); + } + } + + return failures; +} + +const vsixPath = process.argv[2] ? resolve(process.argv[2]) : findLatestVsix(); +if (!vsixPath) { + console.error("No kimi-code-*.vsix file found. Run `pnpm -C apps/vscode run package` first."); + process.exit(1); +} + +const entries = listVsixEntries(vsixPath); +const failures = validateEntries(entries); +if (failures.length > 0) { + console.error(`VSIX content check failed for ${vsixPath}`); + for (const failure of failures) { + console.error(`- ${failure}`); + } + process.exit(1); +} + +console.log(`VSIX content check passed: ${vsixPath}`); +console.log(`Entries: ${entries.length}`); +console.log("Required runtime entries found: dist/extension.js, dist/webview.js, package.json"); diff --git a/apps/vscode/shared/bridge.ts b/apps/vscode/shared/bridge.ts new file mode 100644 index 0000000000..a82af2dec3 --- /dev/null +++ b/apps/vscode/shared/bridge.ts @@ -0,0 +1,61 @@ +/** + * Bridge Protocol - Communication between VS Code extension and webview. + * + * Architecture: + * - Webview calls Methods via RPC (request/response) + * - Extension broadcasts Events to webview (one-way notifications) + * + * RPC flow: webview.call(method, params) -> extension.dispatch -> webview.resolve(result) + * Event flow: extension.broadcast(event, data) -> webview.on(event, handler) + */ + +export const Methods = { + CheckWorkspace: "checkWorkspace", + CheckCLI: "checkCLI", + InstallCLI: "installCLI", + + SaveConfig: "saveConfig", + SetMode: "setMode", + SetYoloMode: "setYoloMode", + GetExtensionConfig: "getExtensionConfig", + OpenSettings: "openSettings", + ReloadPlugin: "reloadPlugin", + OpenFolder: "openFolder", + GetModels: "getModels", + + GetMCPServers: "getMCPServers", + + StreamChat: "streamChat", + PrewarmSession: "prewarmSession", + AbortChat: "abortChat", + SteerChat: "steerChat", + ResetSession: "resetSession", + RespondApproval: "respondApproval", + + GetKimiSessions: "getKimiSessions", + LoadKimiSessionHistory: "loadKimiSessionHistory", + DeleteKimiSession: "deleteKimiSession", + GetProjectFiles: "getProjectFiles", + GetEditorContext: "getEditorContext", + InsertText: "insertText", + PickMedia: "pickMedia", + OpenFile: "openFile", + CheckFileExists: "checkFileExists", + CheckFilesExist: "checkFilesExist", + OpenFileDiff: "openFileDiff", + TrackFiles: "trackFiles", + ClearTrackedFiles: "clearTrackedFiles", + RevertFiles: "revertFiles", + KeepChanges: "keepChanges", +} as const; + +export const Events = { + ExtensionConfigChanged: "extensionConfigChanged", + MCPServersChanged: "mcpServersChanged", + StreamEvent: "streamEvent", + FocusInput: "focusInput", + InsertMention: "insertMention", + NewConversation: "newConversation", + FileChangesUpdated: "fileChangesUpdated", + RollbackInput: "rollbackInput", +} as const; diff --git a/apps/vscode/shared/errors.ts b/apps/vscode/shared/errors.ts new file mode 100644 index 0000000000..afdf4ccf18 --- /dev/null +++ b/apps/vscode/shared/errors.ts @@ -0,0 +1,55 @@ +import { CliErrorCodes, SessionErrorCodes, TransportErrorCodes } from "@moonshot-ai/kimi-code-vscode-agent-sdk/errors"; +import type { ErrorPhase } from "./types"; + +// Pre-flight: task didn't start at all or was blocked by "gatekeeper" +export const PREFLIGHT_CODES = new Set<string>([ + TransportErrorCodes.CLI_NOT_FOUND, + TransportErrorCodes.SPAWN_FAILED, + TransportErrorCodes.ALREADY_STARTED, + TransportErrorCodes.STDIN_NOT_WRITABLE, + TransportErrorCodes.PROCESS_CRASHED, + CliErrorCodes.AUTH_REQUIRED, + CliErrorCodes.LLM_NOT_SET, + CliErrorCodes.LLM_NOT_SUPPORTED, + CliErrorCodes.CONFIG_ERROR, + CliErrorCodes.INVALID_STATE, + SessionErrorCodes.SESSION_BUSY, +]); + +// User-friendly error messages +export const ERROR_MESSAGES: Record<string, string> = { + // Pre-flight + [TransportErrorCodes.CLI_NOT_FOUND]: "Kimi Code not found.", + [TransportErrorCodes.SPAWN_FAILED]: "Failed to start Kimi Code.", + [TransportErrorCodes.ALREADY_STARTED]: "A session is already running.", + [TransportErrorCodes.STDIN_NOT_WRITABLE]: "Failed to communicate with Kimi Code.", + [CliErrorCodes.AUTH_REQUIRED]: "Please sign in to Kimi Code to continue.", + [CliErrorCodes.LLM_NOT_SET]: "Authentication failed. Please sign in.", + [CliErrorCodes.LLM_NOT_SUPPORTED]: "This model is not supported.", + [CliErrorCodes.CONFIG_ERROR]: "Kimi Code configuration error. Check config.toml.", + [CliErrorCodes.INVALID_STATE]: "Please wait for the current operation.", + [SessionErrorCodes.SESSION_BUSY]: "A message is being sent. Please wait.", + + // Runtime + [TransportErrorCodes.HANDSHAKE_TIMEOUT]: "Connection timed out.", + [TransportErrorCodes.PROCESS_CRASHED]: "Process connection lost.", + [CliErrorCodes.CHAT_PROVIDER_ERROR]: "Service temporarily unavailable.", + [SessionErrorCodes.SESSION_CLOSED]: "Session was closed.", + [SessionErrorCodes.TURN_INTERRUPTED]: "Stopped by user.", +}; + +export function classifyError(code: string): ErrorPhase { + return PREFLIGHT_CODES.has(code) ? "preflight" : "runtime"; +} + +export function getUserMessage(code: string, fallback?: string): string { + return ERROR_MESSAGES[code] || fallback || "An unknown error occurred."; +} + +export function isPreflightError(code: string): boolean { + return PREFLIGHT_CODES.has(code); +} + +export function isUserInterrupt(code: string): boolean { + return code === SessionErrorCodes.TURN_INTERRUPTED; +} diff --git a/apps/vscode/shared/types.ts b/apps/vscode/shared/types.ts new file mode 100644 index 0000000000..cb7ca8c226 --- /dev/null +++ b/apps/vscode/shared/types.ts @@ -0,0 +1,70 @@ +import type { RunResult, StreamEvent, ContentPart, AgentMode } from "@moonshot-ai/kimi-code-vscode-agent-sdk"; + +export interface SessionConfig { + model: string; + thinking?: boolean; +} + +export interface ProjectFile { + path: string; + name: string; + isDirectory: boolean; +} + +export interface EditorContext { + content: string; + language: string; + fileName: string; + selection?: { + text: string; + startLine: number; + endLine: number; + }; +} + +export interface FileChange { + path: string; + status: "Modified" | "Added" | "Deleted"; + additions: number; + deletions: number; +} + +export interface ExtensionConfig { + executablePath: string; + yoloMode: boolean; + autosave: boolean; + useCtrlEnterToSend: boolean; + enableNewConversationShortcut: boolean; + environmentVariables: Record<string, string>; +} + +export interface WorkspaceStatus { + hasWorkspace: boolean; + path?: string; +} + +// Error handling types +export type ErrorPhase = "preflight" | "runtime"; + +export interface StreamError { + type: "error"; + code: string; + message: string; + phase: ErrorPhase; + details?: Record<string, unknown>; + raw?: string; +} + +export interface InlineError { + code: string; + message: string; + details?: Record<string, unknown>; +} + +export interface PendingInput { + content: string | ContentPart[]; + model: string; + mode: AgentMode; +} + +export type UIStreamEvent = { type: "session_start"; sessionId: string; model?: string } | { type: "stream_complete"; result: RunResult } | StreamError | StreamEvent; diff --git a/apps/vscode/shared/utils.ts b/apps/vscode/shared/utils.ts new file mode 100644 index 0000000000..fe4774199a --- /dev/null +++ b/apps/vscode/shared/utils.ts @@ -0,0 +1,3 @@ +export function cleanSystemTags(text: string): string { + return text.replace(/<(system-reminder|system)\b[^>]*>[\s\S]*?<\/\1>\s*/gi, "").trim(); +} diff --git a/apps/vscode/src/KimiWebviewProvider.ts b/apps/vscode/src/KimiWebviewProvider.ts new file mode 100644 index 0000000000..0bf5513cf5 --- /dev/null +++ b/apps/vscode/src/KimiWebviewProvider.ts @@ -0,0 +1,138 @@ +import * as vscode from "vscode"; +import { BridgeHandler } from "./bridge-handler"; + +interface RpcMessage { + id: string; + method: string; + params?: unknown; +} + +function getNonce(): string { + const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + let nonce = ""; + for (let i = 0; i < 32; i++) { + nonce += chars.charAt(Math.floor(Math.random() * chars.length)); + } + return nonce; +} + +/** + * Manages webview instances (sidebar and panels). + * Each webview gets a unique viewId for session isolation. + */ +export class KimiWebviewProvider implements vscode.WebviewViewProvider { + private webviews = new Map<string, vscode.Webview>(); + private bridgeHandler: BridgeHandler; + + constructor(private readonly extensionUri: vscode.Uri) { + this.bridgeHandler = new BridgeHandler(this.broadcastInternal.bind(this), this.reloadWebview.bind(this)); + } + + /** + * Reload only this Kimi webview (re-runs the React app's init, re-reading + * config.toml models / MCP / extension config) without reloading the whole + * window. Re-assigning `html` reloads the page; the existing + * `onDidReceiveMessage` listener on the same webview persists. + */ + private reloadWebview(webviewId: string): void { + const webview = this.webviews.get(webviewId); + if (webview) { + webview.html = this.getHtml(webviewId, webview); + } + } + + dispose(): void { + void this.bridgeHandler.dispose().catch((err) => { + console.warn("[webview-provider] Error disposing bridge handler:", err); + }); + } + + resolveWebviewView(webviewView: vscode.WebviewView): void { + const webviewId = `sidebar_${crypto.randomUUID()}`; + this.setupWebview(webviewId, webviewView.webview); + + webviewView.onDidDispose(() => { + this.bridgeHandler.disposeView(webviewId); + this.webviews.delete(webviewId); + }); + } + + createPanel(): vscode.WebviewPanel { + const webviewId = `panel_${crypto.randomUUID()}`; + + const panel = vscode.window.createWebviewPanel("kimiPanel", "Kimi Code", vscode.ViewColumn.One, { + enableScripts: true, + retainContextWhenHidden: true, + localResourceRoots: [this.extensionUri], + }); + + this.setupWebview(webviewId, panel.webview); + + panel.onDidDispose(() => { + this.bridgeHandler.disposeView(webviewId); + this.webviews.delete(webviewId); + }); + + return panel; + } + + broadcast(event: string, data: unknown): void { + this.broadcastInternal(event, data); + } + + private setupWebview(webviewId: string, webview: vscode.Webview): void { + webview.options = { + enableScripts: true, + localResourceRoots: [this.extensionUri], + }; + + webview.html = this.getHtml(webviewId, webview); + this.webviews.set(webviewId, webview); + + webview.onDidReceiveMessage(async (msg: RpcMessage & { webviewId?: string }) => { + const result = await this.bridgeHandler.handle(msg, webviewId); + webview.postMessage(result); + }); + } + + private broadcastInternal(event: string, data: unknown, targetWebviewId?: string): void { + const msg = { event, data }; + + if (targetWebviewId) { + this.webviews.get(targetWebviewId)?.postMessage(msg); + } else { + this.webviews.forEach((w) => w.postMessage(msg)); + } + } + + private getHtml(webviewId: string, webview: vscode.Webview): string { + const scriptUri = webview.asWebviewUri(vscode.Uri.joinPath(this.extensionUri, "dist", "webview.js")); + const baseUri = webview.asWebviewUri(this.extensionUri).toString(); + const nonce = getNonce(); + + const csp = [ + `default-src 'none'`, + `style-src ${webview.cspSource} 'unsafe-inline'`, + `img-src ${webview.cspSource} data: blob:`, + `font-src ${webview.cspSource}`, + `media-src ${webview.cspSource} data: blob:`, + `connect-src ${webview.cspSource}`, + `worker-src ${webview.cspSource} blob:`, + `script-src 'nonce-${nonce}' ${webview.cspSource}`, + ].join("; "); + + return `<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <meta http-equiv="Content-Security-Policy" content="${csp}"> + <title>Kimi Code + + +
+ + +`; + } +} diff --git a/apps/vscode/src/bridge-handler.ts b/apps/vscode/src/bridge-handler.ts new file mode 100644 index 0000000000..d721ef6fdb --- /dev/null +++ b/apps/vscode/src/bridge-handler.ts @@ -0,0 +1,252 @@ +import * as vscode from "vscode"; +import { VSCodeSettings } from "./config/vscode-settings"; +import { getCLIManager, FileManager, GitManager } from "./managers"; +import { handlers, type HandlerContext, type BroadcastFn } from "./handlers"; +import { createSession, parseConfig, getModelThinkingMode, getModelById, type Session, type Turn, type AgentMode } from "@moonshot-ai/kimi-code-vscode-agent-sdk"; + +interface RpcMessage { + id: string; + method: string; + params?: unknown; +} + +interface RpcResult { + id: string; + result?: unknown; + error?: string; +} + +export class BridgeHandler { + private sessions = new Map(); + private turns = new Map(); + private sessionGenerations = new Map(); + private prewarmSessions = new Map }>(); + private fileManager: FileManager; + + constructor( + private broadcast: BroadcastFn, + private reloadWebviewCb: (webviewId: string) => void, + ) { + this.fileManager = new FileManager(() => this.workDir, broadcast); + } + + async handle(msg: RpcMessage, webviewId: string): Promise { + try { + return { + id: msg.id, + result: await this.dispatch(msg.method, msg.params, webviewId), + }; + } catch (err) { + return { + id: msg.id, + error: err instanceof Error ? err.message : String(err), + }; + } + } + + private get workDir(): string | null { + return vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? null; + } + + private requireWorkDir(): string { + const w = this.workDir; + if (!w) { + throw new Error("No workspace folder open"); + } + return w; + } + + private getSessionGeneration(webviewId: string): number { + return this.sessionGenerations.get(webviewId) ?? 0; + } + + private bumpSessionGeneration(webviewId: string): number { + const next = this.getSessionGeneration(webviewId) + 1; + this.sessionGenerations.set(webviewId, next); + return next; + } + + private isSessionGeneration(webviewId: string, generation: number): boolean { + return this.getSessionGeneration(webviewId) === generation; + } + + private async dispatch(method: string, params: unknown, webviewId: string): Promise { + const handler = handlers[method]; + if (!handler) { + throw new Error(`Unknown method: ${method}`); + } + return handler(params, this.createContext(webviewId)); + } + + private createContext(webviewId: string): HandlerContext { + return { + webviewId, + workDir: this.workDir, + requireWorkDir: () => this.requireWorkDir(), + broadcast: this.broadcast, + fileManager: this.fileManager, + getSession: () => this.sessions.get(webviewId), + getSessionId: () => this.fileManager.getSessionId(webviewId), + getTurn: () => this.turns.get(webviewId), + setTurn: (turn: Turn | null) => { + if (turn) { + this.turns.set(webviewId, turn); + } else { + this.turns.delete(webviewId); + } + }, + getSessionGeneration: () => this.getSessionGeneration(webviewId), + bumpSessionGeneration: () => this.bumpSessionGeneration(webviewId), + isSessionGeneration: (generation) => this.isSessionGeneration(webviewId, generation), + getOrCreateSession: async (model, thinking, mode, sessionId) => this.getOrCreateSession(webviewId, model, thinking, mode, sessionId), + prewarmSession: async (model, thinking, mode) => this.prewarmSession(webviewId, model, thinking, mode), + closeSession: async () => { + const session = this.sessions.get(webviewId); + this.sessions.delete(webviewId); + this.turns.delete(webviewId); + if (session) { + GitManager.clearBaseline(session.workDir, session.sessionId); + void session.close().catch((err) => { + console.warn("[bridge] Error closing old session:", err); + }); + } + }, + saveAllDirty: () => this.saveAllDirty(), + reloadWebview: () => this.reloadWebviewCb(webviewId), + setLoggedIn: (loggedIn: boolean) => { + vscode.commands.executeCommand("setContext", "kimi.isLoggedIn", loggedIn); + }, + }; + } + + private resolveActualThinking(model: string, thinking: boolean): boolean { + const config = parseConfig(); + const modelConfig = getModelById(config.models, model); + const thinkingMode = modelConfig ? getModelThinkingMode(modelConfig) : "none"; + + if (thinkingMode === "always") { + return true; + } + if (thinkingMode === "none") { + return false; + } + return thinking; + } + + private getSessionConfigSignature(model: string, thinking: boolean, mode: AgentMode, executable: string, env: Record): string { + return JSON.stringify({ model, thinking, mode, executable, env }); + } + + private async prewarmSession(webviewId: string, model: string, thinking: boolean, mode: AgentMode): Promise { + const cli = getCLIManager(); + const executable = cli.getExecutablePath(); + const env = VSCodeSettings.environmentVariables; + const actualThinking = this.resolveActualThinking(model, thinking); + const signature = this.getSessionConfigSignature(model, actualThinking, mode, executable, env); + + const existing = this.sessions.get(webviewId); + if (existing) { + const existingSignature = this.getSessionConfigSignature(existing.model ?? model, existing.thinking, existing.mode, existing.executable, existing.env); + if (existingSignature === signature) { + return; + } + } + + const currentPrewarm = this.prewarmSessions.get(webviewId); + if (currentPrewarm?.signature === signature) { + return currentPrewarm.promise; + } + + const promise = (async () => { + const session = await this.getOrCreateSession(webviewId, model, thinking, mode); + await session.ensureStarted(); + })().finally(() => { + if (this.prewarmSessions.get(webviewId)?.promise === promise) { + this.prewarmSessions.delete(webviewId); + } + }); + + this.prewarmSessions.set(webviewId, { signature, promise }); + return promise; + } + + private async saveAllDirty(): Promise { + const dirty = vscode.workspace.textDocuments.filter((d) => d.isDirty && !d.isUntitled); + await Promise.all(dirty.map((d) => d.save())); + } + + private async getOrCreateSession(webviewId: string, model: string, thinking: boolean, mode: AgentMode, sessionId?: string): Promise { + const workDir = this.requireWorkDir(); + const cli = getCLIManager(); + const actualThinking = this.resolveActualThinking(model, thinking); + + const executable = cli.getExecutablePath(); + const env = VSCodeSettings.environmentVariables; + + const existing = this.sessions.get(webviewId); + + // Check if we need to restart the session + if (existing) { + const needsRestart = + (sessionId && sessionId !== existing.sessionId) || + executable !== existing.executable || + JSON.stringify(env) !== JSON.stringify(existing.env); + + if (needsRestart) { + // Reuse of the same webview session still serializes lifetime so config/env + // changes do not overlap old and new CLI processes. User-initiated new + // conversations go through closeSession(), which is intentionally non-blocking. + GitManager.clearBaseline(existing.workDir, existing.sessionId); + await existing.close(); + this.sessions.delete(webviewId); + this.turns.delete(webviewId); + } else { + existing.model = model; + existing.thinking = actualThinking; + existing.mode = mode; + } + } + + const current = this.sessions.get(webviewId); + if (current) { + return current; + } + + const session = createSession({ + workDir, + model, + thinking: actualThinking, + mode, + sessionId, + executable, + env, + }); + + this.sessions.set(webviewId, session); + return session; + } + + async disposeView(webviewId: string): Promise { + const session = this.sessions.get(webviewId); + if (session) { + GitManager.clearBaseline(session.workDir, session.sessionId); + await session.close(); + } + this.sessions.delete(webviewId); + this.turns.delete(webviewId); + this.sessionGenerations.delete(webviewId); + this.prewarmSessions.delete(webviewId); + this.fileManager.disposeView(webviewId); + } + + async dispose(): Promise { + this.fileManager.dispose(); + for (const s of this.sessions.values()) { + await s.close(); + } + this.sessions.clear(); + this.turns.clear(); + this.sessionGenerations.clear(); + this.prewarmSessions.clear(); + } +} diff --git a/apps/vscode/src/config/vscode-settings.ts b/apps/vscode/src/config/vscode-settings.ts new file mode 100644 index 0000000000..4901e4e726 --- /dev/null +++ b/apps/vscode/src/config/vscode-settings.ts @@ -0,0 +1,56 @@ +import * as vscode from "vscode"; +import type { ExtensionConfig } from "../../shared/types"; + +function getConfig() { + return vscode.workspace.getConfiguration("kimi"); +} + +export const VSCodeSettings = { + get yoloMode(): boolean { + return getConfig().get("yoloMode", false); + }, + + get autosave(): boolean { + return getConfig().get("autosave", true); + }, + + get executablePath(): string { + return getConfig().get("executablePath", ""); + }, + + get enableNewConversationShortcut(): boolean { + return getConfig().get("enableNewConversationShortcut", false); + }, + + get useCtrlEnterToSend(): boolean { + return getConfig().get("useCtrlEnterToSend", false); + }, + + get environmentVariables(): Record { + return getConfig().get>("environmentVariables", {}); + }, + + getExtensionConfig(): ExtensionConfig { + return { + executablePath: this.executablePath, + yoloMode: this.yoloMode, + autosave: this.autosave, + useCtrlEnterToSend: this.useCtrlEnterToSend, + enableNewConversationShortcut: this.enableNewConversationShortcut, + environmentVariables: this.environmentVariables, + }; + }, +}; + +export function onSettingsChange(callback: (changedKeys: string[]) => void): vscode.Disposable { + return vscode.workspace.onDidChangeConfiguration((e) => { + if (!e.affectsConfiguration("kimi")) { + return; + } + const keys = ["yoloMode", "autosave", "executablePath", "enableNewConversationShortcut", "useCtrlEnterToSend", "environmentVariables"]; + const changedKeys = keys.filter((key) => e.affectsConfiguration(`kimi.${key}`)); + if (changedKeys.length > 0) { + callback(changedKeys); + } + }); +} diff --git a/apps/vscode/src/extension.ts b/apps/vscode/src/extension.ts new file mode 100644 index 0000000000..c7070816e4 --- /dev/null +++ b/apps/vscode/src/extension.ts @@ -0,0 +1,150 @@ +import * as vscode from "vscode"; +import * as path from "node:path"; +import { KimiWebviewProvider } from "./KimiWebviewProvider"; +import { onSettingsChange, VSCodeSettings } from "./config/vscode-settings"; +import { initCLIManager, getCLIManager, GitManager } from "./managers"; +import { Events } from "../shared/bridge"; + +let outputChannel: vscode.OutputChannel; +let provider: KimiWebviewProvider; + +export function activate(context: vscode.ExtensionContext) { + outputChannel = vscode.window.createOutputChannel("Kimi Code"); + + const remoteInfo = vscode.env.remoteName ? ` (remote: ${vscode.env.remoteName})` : ""; + log(`Kimi Code extension activating...${remoteInfo}`); + + initCLIManager(); + + provider = new KimiWebviewProvider(context.extensionUri); + + // Login state is unknown at activation; it is driven by runtime auth + // signals — a completed turn sets it true, an AUTH_REQUIRED error sets it + // false (see chat.handler.ts). Default to logged-out so the Login command + // is offered until a turn succeeds. + vscode.commands.executeCommand("setContext", "kimi.isLoggedIn", false); + + context.subscriptions.push(provider); + context.subscriptions.push(outputChannel); + + context.subscriptions.push( + vscode.workspace.registerTextDocumentContentProvider("kimi-baseline", { + provideTextDocumentContent: async (uri: vscode.Uri) => { + const params = new URLSearchParams(uri.query); + const workDir = params.get("workDir"); + const sessionId = params.get("sessionId"); + if (!workDir || !sessionId) { + return ""; + } + const relativePath = uri.path.slice(1); + const absolutePath = path.join(workDir, relativePath); + const content = await GitManager.getBaselineContent(workDir, sessionId, absolutePath); + return content ?? ""; + }, + }), + ); + + context.subscriptions.push( + onSettingsChange((changedKeys) => { + provider.broadcast(Events.ExtensionConfigChanged, { + config: VSCodeSettings.getExtensionConfig(), + changedKeys, + }); + }), + ); + + context.subscriptions.push( + vscode.window.registerWebviewViewProvider("kimi.webview", provider, { + webviewOptions: { retainContextWhenHidden: true }, + }), + ); + + const commands: Record void | Promise> = { + "kimi.clearAllState": async () => { + await context.globalState.update("kimi.config", undefined); + await context.globalState.update("kimi.mcpServers", undefined); + await context.workspaceState.update("kimi.mcpEnabled", undefined); + vscode.window.showInformationMessage("Kimi: All state cleared!"); + }, + "kimi.openInTab": () => { + log("Opening Kimi in new tab"); + provider.createPanel(); + }, + "kimi.openInSideBar": async () => { + log("Opening Kimi in side bar"); + await vscode.commands.executeCommand("kimi.webview.focus"); + }, + "kimi.focusInput": async () => { + log("Focusing Kimi input"); + await vscode.commands.executeCommand("kimi.webview.focus"); + provider.broadcast(Events.FocusInput, {}); + }, + "kimi.insertMention": async () => { + const editor = vscode.window.activeTextEditor; + if (!editor) { + vscode.window.showWarningMessage("No active editor"); + return; + } + const document = editor.document; + const selection = editor.selection; + const relativePath = vscode.workspace.asRelativePath(document.uri); + + let mention: string; + if (selection.isEmpty) { + mention = `@${relativePath}`; + } else { + const startLine = selection.start.line + 1; + const endLine = selection.end.line + 1; + mention = startLine === endLine ? `@${relativePath}:${startLine}` : `@${relativePath}:${startLine}-${endLine}`; + } + + log(`Inserting mention: ${mention}`); + await vscode.commands.executeCommand("kimi.webview.focus"); + provider.broadcast(Events.InsertMention, { mention }); + }, + "kimi.newConversation": async () => { + log("Starting new conversation"); + await vscode.commands.executeCommand("kimi.webview.focus"); + provider.broadcast(Events.NewConversation, {}); + }, + "kimi.showLogs": () => { + outputChannel.show(); + }, + "kimi.login": async () => { + log("Login requested"); + const executable = getCLIManager().getExecutablePath(); + const terminal = vscode.window.createTerminal("Kimi Code Login"); + terminal.show(); + terminal.sendText(`${quoteShellArg(executable)} acp --login`); + }, + "kimi.logout": async () => { + log("Logout requested"); + vscode.commands.executeCommand("setContext", "kimi.isLoggedIn", false); + vscode.window.showInformationMessage("Kimi Code logout is managed by the CLI token store."); + }, + }; + + for (const [id, handler] of Object.entries(commands)) { + context.subscriptions.push(vscode.commands.registerCommand(id, handler)); + } + + log("Kimi extension activated"); +} + +export function deactivate() { + log("Kimi extension deactivating..."); +} + +function log(message: string) { + const timestamp = new Date().toISOString(); + outputChannel?.appendLine(`[${timestamp}] ${message}`); +} + +function quoteShellArg(value: string): string { + if (/^[A-Za-z0-9_./:-]+$/.test(value)) { + return value; + } + return `"${value.replace(/(["\\$`])/g, "\\$1")}"`; +} + +export { log }; diff --git a/apps/vscode/src/handlers/chat.handler.ts b/apps/vscode/src/handlers/chat.handler.ts new file mode 100644 index 0000000000..72e18cc7ad --- /dev/null +++ b/apps/vscode/src/handlers/chat.handler.ts @@ -0,0 +1,297 @@ +import * as vscode from "vscode"; +import { Methods, Events } from "../../shared/bridge"; +import { VSCodeSettings } from "../config/vscode-settings"; +import { GitManager } from "../managers"; +import { CliErrorCodes, TransportErrorCodes, SessionErrorCodes, getErrorCode, getErrorCategory, isAgentSdkError, CliError } from "@moonshot-ai/kimi-code-vscode-agent-sdk"; +import type { ContentPart, ApprovalResult, RunResult, AgentMode } from "@moonshot-ai/kimi-code-vscode-agent-sdk"; +import type { Handler, HandlerContext } from "./types"; +import type { ErrorPhase } from "../../shared/types"; +import { classifyError, getUserMessage } from "shared/errors"; + +interface StreamChatParams { + content: string | ContentPart[]; + model: string; + thinking: boolean; + mode?: AgentMode; + yoloMode?: boolean; + sessionId?: string; +} + +interface RespondApprovalParams { + requestId: string | number; + response?: ApprovalResult; + optionId?: string; +} + +interface SteerChatParams { + content?: string | ContentPart[]; +} + +interface PrewarmSessionParams { + model: string; + thinking: boolean; + mode?: AgentMode; + yoloMode?: boolean; +} + +function broadcastIfCurrent(ctx: HandlerContext, generation: number, event: string, data: unknown): boolean { + if (!ctx.isSessionGeneration(generation)) { + return false; + } + ctx.broadcast(event, data, ctx.webviewId); + return true; +} + +const streamChat: Handler = async (params, ctx) => { + if (!ctx.workDir) { + ctx.broadcast( + Events.StreamEvent, + { + type: "error", + code: "NO_WORKSPACE", + message: "Please open a folder to start.", + phase: "preflight" as ErrorPhase, + }, + ctx.webviewId, + ); + vscode.window.showWarningMessage("Kimi: Please open a folder first.", "Open Folder").then((a) => { + if (a) { + vscode.commands.executeCommand("vscode.openFolder"); + } + }); + return { done: false }; + } + + const streamGeneration = ctx.getSessionGeneration(); + + if (VSCodeSettings.autosave) { + await ctx.saveAllDirty(); + } + + if (!ctx.isSessionGeneration(streamGeneration)) { + return { done: false }; + } + + const existingSession = ctx.getSession(); + const isNewConversation = !existingSession && !params.sessionId; + + try { + const mode = normalizeMode(params.mode, params.yoloMode); + const session = await ctx.getOrCreateSession(params.model, params.thinking, mode, params.sessionId); + await session.ensureStarted(); + + if (!ctx.isSessionGeneration(streamGeneration)) { + return { done: false }; + } + + const startupEvents = session.consumeBufferedEvents().filter((event) => event.type === "ConfigOptionUpdate" || event.type === "AvailableCommandsUpdate"); + ctx.fileManager.setSessionId(ctx.webviewId, session.sessionId); + + broadcastIfCurrent(ctx, streamGeneration, Events.StreamEvent, { type: "session_start", sessionId: session.sessionId, model: session.model }); + for (const event of startupEvents) { + if (!broadcastIfCurrent(ctx, streamGeneration, Events.StreamEvent, event)) { + return { done: false }; + } + } + + if (isNewConversation) { + void GitManager.initBaseline(ctx.workDir, session.sessionId).catch((err) => { + const message = err instanceof Error ? err.message : String(err); + console.warn("[chat] Failed to initialize Git baseline:", err); + broadcastIfCurrent(ctx, streamGeneration, Events.StreamEvent, { + type: "error", + code: "BASELINE_INIT_FAILED", + message: `Failed to initialize file tracking baseline: ${message}`, + phase: "runtime", + }); + }); + } else { + await GitManager.commit(ctx.workDir, session.sessionId); + } + + if (!ctx.isSessionGeneration(streamGeneration)) { + return { done: false }; + } + + const turn = session.prompt(params.content); + ctx.setTurn(turn); + let result: RunResult = { status: "finished" }; + + for await (const event of turn) { + if (!broadcastIfCurrent(ctx, streamGeneration, Events.StreamEvent, event)) { + return { done: false }; + } + } + + result = await turn.result; + + if (!ctx.isSessionGeneration(streamGeneration)) { + return { done: false }; + } + + // A completed turn proves the CLI token is valid. + ctx.setLoggedIn(true); + + broadcastIfCurrent(ctx, streamGeneration, Events.StreamEvent, { type: "stream_complete", result }); + ctx.setTurn(null); + + return { done: true }; + } catch (err) { + if (!ctx.isSessionGeneration(streamGeneration)) { + return { done: false }; + } + + ctx.setTurn(null); + + const code = getErrorCode(err); + const phase = classifyError(code); + const message = getUserMessage(code, err instanceof Error ? err.message : String(err)); + + if (code === CliErrorCodes.AUTH_REQUIRED || code === TransportErrorCodes.CLI_NOT_FOUND || code === TransportErrorCodes.SPAWN_FAILED) { + ctx.setLoggedIn(false); + vscode.window.showWarningMessage("Kimi Code: Please sign in to continue.", "Login").then((a) => { + if (a) { + vscode.commands.executeCommand("kimi.login"); + } + }); + } + + broadcastIfCurrent(ctx, streamGeneration, Events.StreamEvent, { + type: "error", + code, + message, + phase, + details: errorDetails(err), + }); + + return { done: false }; + } +}; + +const prewarmSession: Handler = async (params, ctx) => { + if (!ctx.workDir) { + return { ok: false }; + } + + try { + const mode = normalizeMode(params.mode, params.yoloMode); + await ctx.prewarmSession(params.model, params.thinking, mode); + return { ok: true }; + } catch (err) { + console.warn("[chat] Failed to prewarm session:", err); + return { ok: false }; + } +}; + +function hasSteerContent(content: string | ContentPart[] | undefined): content is string | ContentPart[] { + if (typeof content === "string") { + return content.trim().length > 0; + } + return Array.isArray(content) && content.length > 0; +} + +const abortChat: Handler = async (_, ctx) => { + const turn = ctx.getTurn(); + if (turn) { + await turn.interrupt(); + ctx.setTurn(null); + } + return { aborted: true }; +}; + +const steerChat: Handler = async (params, ctx) => { + const session = ctx.getSession(); + if (!session || !hasSteerContent(params?.content)) { + return { ok: false }; + } + + const currentTurn = ctx.getTurn(); + if (currentTurn && session.state === "active") { + // Enqueue the queued message into the ACP session first. If the current turn + // is still active, this returns the existing turn; session.steer() then asks + // ACP to cancel that turn so the queued prompt becomes the next item in the + // same turn loop. + session.prompt(params.content); + await session.steer(); + return { ok: true }; + } + + // Turn has already finished naturally: fall back to stream semantics so the + // queued message is not dropped. create a new turn, register it, and iterate. + const turn = session.prompt(params.content); + ctx.setTurn(turn); + + try { + for await (const event of turn) { + ctx.broadcast(Events.StreamEvent, event, ctx.webviewId); + } + const result = await turn.result; + ctx.setLoggedIn(true); + ctx.broadcast(Events.StreamEvent, { type: "stream_complete", result }, ctx.webviewId); + ctx.setTurn(null); + return { ok: true }; + } catch (err) { + ctx.setTurn(null); + const code = getErrorCode(err); + const phase = classifyError(code); + const message = getUserMessage(code, err instanceof Error ? err.message : String(err)); + if (code === CliErrorCodes.AUTH_REQUIRED || code === TransportErrorCodes.CLI_NOT_FOUND || code === TransportErrorCodes.SPAWN_FAILED) { + ctx.setLoggedIn(false); + } + ctx.broadcast(Events.StreamEvent, { type: "error", code, message, phase, details: errorDetails(err) }, ctx.webviewId); + return { ok: false }; + } +}; + +const respondApproval: Handler = async (params, ctx) => { + const turn = ctx.getTurn(); + if (!turn) { + return { ok: false }; + } + const result: ApprovalResult = params.optionId ? { optionId: params.optionId } : params.response ?? "reject"; + await turn.approve(params.requestId, result); + return { ok: true }; +}; + +const resetSession: Handler = async (_, ctx) => { + const turn = ctx.getTurn(); + if (turn) { + try { + await turn.interrupt(); + } catch (err) { + console.warn("[chat] Failed to interrupt current turn:", err); + } + ctx.setTurn(null); + } + ctx.bumpSessionGeneration(); + await ctx.closeSession(); + ctx.fileManager.clearTracked(ctx.webviewId); + return { ok: true }; +}; + +export const chatHandlers: Record> = { + [Methods.StreamChat]: streamChat, + [Methods.PrewarmSession]: prewarmSession, + [Methods.AbortChat]: abortChat, + [Methods.SteerChat]: steerChat, + [Methods.RespondApproval]: respondApproval, + [Methods.ResetSession]: resetSession, +}; + +function errorDetails(err: unknown): Record | undefined { + if (!isAgentSdkError(err)) { + return undefined; + } + + const details: Record = { category: getErrorCategory(err) }; + if (err.context) details.context = err.context; + if (err instanceof CliError && typeof err.numericCode === "number") details.numericCode = err.numericCode; + return details; +} + +function normalizeMode(mode?: AgentMode, yoloMode?: boolean): AgentMode { + if (mode === "default" || mode === "plan" || mode === "auto" || mode === "yolo") { + return mode; + } + return yoloMode ? "yolo" : "default"; +} diff --git a/apps/vscode/src/handlers/cli.handler.ts b/apps/vscode/src/handlers/cli.handler.ts new file mode 100644 index 0000000000..0fc22689bd --- /dev/null +++ b/apps/vscode/src/handlers/cli.handler.ts @@ -0,0 +1,15 @@ +import { Methods } from "../../shared/bridge"; +import { getCLIManager } from "../managers"; +import type { Handler } from "./types"; + +export const cliHandlers: Record> = { + [Methods.CheckCLI]: async () => { + const ok = await getCLIManager().checkInstalled(); + return { ok }; + }, + + [Methods.InstallCLI]: async () => { + await getCLIManager().installCLI(); + return { ok: true }; + }, +}; diff --git a/apps/vscode/src/handlers/config.handler.ts b/apps/vscode/src/handlers/config.handler.ts new file mode 100644 index 0000000000..28600f0995 --- /dev/null +++ b/apps/vscode/src/handlers/config.handler.ts @@ -0,0 +1,61 @@ +import * as vscode from "vscode"; +import { Methods } from "../../shared/bridge"; +import { VSCodeSettings } from "../config/vscode-settings"; +import { parseConfig, saveDefaultModel } from "@moonshot-ai/kimi-code-vscode-agent-sdk"; +import type { SessionConfig, ExtensionConfig } from "../../shared/types"; +import type { KimiConfig, AgentMode } from "@moonshot-ai/kimi-code-vscode-agent-sdk"; +import type { Handler } from "./types"; + +const saveConfig: Handler = async (params) => { + saveDefaultModel(params.model, params.thinking); + return { ok: true }; +}; + +const setMode: Handler<{ mode: AgentMode }, { ok: boolean }> = async (params, ctx) => { + // Mode is ephemeral per conversation. Hot-apply it to the live ACP session; + // the webview also passes it on the next streamChat. + const session = ctx.getSession(); + if (session) { + session.mode = normalizeMode(params.mode); + await session.applyConfigNow(); + } + return { ok: true }; +}; + +const setYoloMode: Handler<{ enabled: boolean }, { ok: boolean }> = async (params, ctx) => { + return setMode({ mode: params.enabled ? "yolo" : "default" }, ctx); +}; + +const getExtensionConfig: Handler = async () => { + return VSCodeSettings.getExtensionConfig(); +}; + +const openSettings: Handler = async () => { + await vscode.commands.executeCommand("workbench.action.openSettings", "kimi"); + return { ok: true }; +}; + +const reloadPlugin: Handler = async (_params, ctx) => { + // Reload ONLY the Kimi webview (re-runs its init → re-reads config.toml + // models, MCP and extension config) without reloading the whole window. + ctx.reloadWebview(); + return { ok: true }; +}; + +const getModels: Handler = async () => { + return parseConfig(); +}; + +export const configHandlers = { + [Methods.SaveConfig]: saveConfig, + [Methods.SetMode]: setMode, + [Methods.SetYoloMode]: setYoloMode, + [Methods.GetExtensionConfig]: getExtensionConfig, + [Methods.OpenSettings]: openSettings, + [Methods.ReloadPlugin]: reloadPlugin, + [Methods.GetModels]: getModels, +} as Record>; + +function normalizeMode(mode: AgentMode): AgentMode { + return mode === "plan" || mode === "auto" || mode === "yolo" ? mode : "default"; +} diff --git a/apps/vscode/src/handlers/file.handler.ts b/apps/vscode/src/handlers/file.handler.ts new file mode 100644 index 0000000000..d0098beb77 --- /dev/null +++ b/apps/vscode/src/handlers/file.handler.ts @@ -0,0 +1,294 @@ +import * as vscode from "vscode"; +import * as path from "node:path"; +import * as fs from "fs"; +import { Methods, Events } from "../../shared/bridge"; +import { GitManager } from "../managers"; +import type { ProjectFile, EditorContext, FileChange } from "../../shared/types"; +import type { Handler } from "./types"; + +interface GetProjectFilesParams { + query?: string; + directory?: string; +} + +interface InsertTextParams { + text: string; +} + +interface PickMediaParams { + maxCount?: number; + includeVideo?: boolean; +} + +interface FilePathParams { + filePath: string; +} + +interface OptionalFilePathParams { + filePath?: string; +} + +interface TrackFilesParams { + paths: string[]; +} + +interface CheckFileExistsParams { + filePath: string; +} + +interface CheckFilesExistParams { + paths: string[]; +} + +const IMAGE_EXTENSIONS = ["png", "jpg", "jpeg", "gif", "webp"]; +const VIDEO_EXTENSIONS = ["mp4", "webm", "mov"]; + +function toAbsolute(workDir: string, filePath: string): string { + return path.isAbsolute(filePath) ? filePath : path.join(workDir, filePath); +} + +function isInsideWorkDir(workDir: string, absolutePath: string): boolean { + const rel = path.relative(workDir, absolutePath); + return !rel.startsWith("..") && !path.isAbsolute(rel); +} + +function getMimeType(ext: string): string { + const mimeTypes: Record = { + png: "image/png", + jpg: "image/jpeg", + jpeg: "image/jpeg", + gif: "image/gif", + webp: "image/webp", + mp4: "video/mp4", + webm: "video/webm", + mov: "video/quicktime", + }; + return mimeTypes[ext] || "application/octet-stream"; +} + +const getProjectFiles: Handler = async (params, ctx) => { + if (!ctx.workDir) { + return []; + } + return params.directory !== undefined ? ctx.fileManager.listDirectory(ctx.workDir, params.directory) : ctx.fileManager.searchFiles(params.query); +}; + +const getEditorContext: Handler = async () => { + const editor = vscode.window.activeTextEditor; + if (!editor) { + return null; + } + + const doc = editor.document; + const sel = editor.selection; + + return { + content: doc.getText(), + language: doc.languageId, + fileName: doc.fileName, + selection: sel.isEmpty + ? undefined + : { + text: doc.getText(sel), + startLine: sel.start.line + 1, + endLine: sel.end.line + 1, + }, + }; +}; + +const insertText: Handler = async (params) => { + const editor = vscode.window.activeTextEditor; + if (editor) { + await editor.edit((b) => b.insert(editor.selection.active, params.text)); + } +}; + +const pickMedia: Handler = async (params) => { + const maxCount = params.maxCount ?? 9; + const includeVideo = params.includeVideo ?? true; + + const filters: Record = { + Images: IMAGE_EXTENSIONS, + }; + if (includeVideo) { + filters["Videos"] = VIDEO_EXTENSIONS; + filters["All Media"] = [...IMAGE_EXTENSIONS, ...VIDEO_EXTENSIONS]; + } + + const uris = await vscode.window.showOpenDialog({ + canSelectMany: true, + filters, + title: "Select Media", + }); + + if (!uris) { + return []; + } + + const results: string[] = []; + const maxImageSize = 10 * 1024 * 1024; + const maxVideoSize = 20 * 1024 * 1024; + + for (const uri of uris.slice(0, maxCount)) { + try { + const ext = path.extname(uri.fsPath).toLowerCase().slice(1); + const isVideo = VIDEO_EXTENSIONS.includes(ext); + const maxSize = isVideo ? maxVideoSize : maxImageSize; + + const stat = await vscode.workspace.fs.stat(uri); + if (stat.size > maxSize) { + continue; + } + + const data = await vscode.workspace.fs.readFile(uri); + const mime = getMimeType(ext); + results.push(`data:${mime};base64,${Buffer.from(data).toString("base64")}`); + } catch { + // Skip files that can't be read + } + } + return results; +}; + +const openFile: Handler = async (params, ctx) => { + const workDir = ctx.requireWorkDir(); + + let absolutePath = toAbsolute(workDir, params.filePath); + + // If the path is "workDir/workDir/xxx", remove the duplicate prefix + const doubledPrefix = path.join(workDir, workDir); + if (absolutePath.startsWith(doubledPrefix)) { + absolutePath = absolutePath.slice(workDir.length); + } + + if (!isInsideWorkDir(workDir, absolutePath)) { + return { ok: false }; + } + + const uri = vscode.Uri.file(absolutePath); + await vscode.commands.executeCommand("vscode.open", uri); + + return { ok: true }; +}; + +const openFileDiff: Handler = async (params, ctx) => { + const workDir = ctx.requireWorkDir(); + const sessionId = ctx.getSessionId(); + if (!sessionId) { + return { ok: false }; + } + + const absolutePath = toAbsolute(workDir, params.filePath); + const currentUri = vscode.Uri.file(absolutePath); + const baselineUri = vscode.Uri.from({ + scheme: "kimi-baseline", + path: "/" + params.filePath, + query: new URLSearchParams({ workDir, sessionId }).toString(), + }); + + await vscode.commands.executeCommand("vscode.diff", baselineUri, currentUri, `${path.basename(params.filePath)} (changes from Kimi)`); + return { ok: true }; +}; + +const trackFiles: Handler = async (params, ctx) => { + const workDir = ctx.requireWorkDir(); + const sessionId = ctx.getSessionId(); + if (!sessionId) { + return []; + } + + for (const filePath of params.paths) { + const absolutePath = toAbsolute(workDir, filePath); + if (isInsideWorkDir(workDir, absolutePath)) { + ctx.fileManager.trackFile(ctx.webviewId, absolutePath); + } + } + + const trackedFiles = ctx.fileManager.getTracked(ctx.webviewId); + const changes = await GitManager.getChanges(workDir, sessionId, trackedFiles); + ctx.broadcast(Events.FileChangesUpdated, changes, ctx.webviewId); + return changes; +}; + +const clearTrackedFiles: Handler = async (_, ctx) => { + ctx.fileManager.clearTracked(ctx.webviewId); + ctx.broadcast(Events.FileChangesUpdated, [], ctx.webviewId); + return { ok: true }; +}; + +const revertFiles: Handler = async (params, ctx) => { + const workDir = ctx.requireWorkDir(); + const sessionId = ctx.getSessionId(); + if (!sessionId) { + return { ok: false }; + } + + if (params.filePath) { + await GitManager.revertFile(workDir, sessionId, toAbsolute(workDir, params.filePath)); + } else { + await GitManager.revertToBaseline(workDir, sessionId); + ctx.fileManager.clearTracked(ctx.webviewId); + } + + const trackedFiles = ctx.fileManager.getTracked(ctx.webviewId); + const changes = await GitManager.getChanges(workDir, sessionId, trackedFiles); + ctx.broadcast(Events.FileChangesUpdated, changes, ctx.webviewId); + return { ok: true }; +}; + +const keepChanges: Handler = async (params, ctx) => { + const workDir = ctx.requireWorkDir(); + const sessionId = ctx.getSessionId(); + if (!sessionId) { + return { ok: false }; + } + + await GitManager.updateBaseline(workDir, sessionId); + + if (params.filePath) { + const absolutePath = toAbsolute(workDir, params.filePath); + ctx.fileManager.getTracked(ctx.webviewId).delete(absolutePath); + } else { + ctx.fileManager.clearTracked(ctx.webviewId); + } + + const trackedFiles = ctx.fileManager.getTracked(ctx.webviewId); + const changes = await GitManager.getChanges(workDir, sessionId, trackedFiles); + ctx.broadcast(Events.FileChangesUpdated, changes, ctx.webviewId); + return { ok: true }; +}; + +const checkFileExists: Handler = async (params, ctx) => { + if (!ctx.workDir) { + return false; + } + const absolutePath = toAbsolute(ctx.workDir, params.filePath); + return fs.existsSync(absolutePath); +}; + +const checkFilesExist: Handler> = async (params, ctx) => { + if (!ctx.workDir) { + return {}; + } + const result: Record = {}; + for (const filePath of params.paths) { + const absolutePath = toAbsolute(ctx.workDir, filePath); + result[filePath] = fs.existsSync(absolutePath); + } + return result; +}; + +export const fileHandlers: Record> = { + [Methods.GetProjectFiles]: getProjectFiles, + [Methods.GetEditorContext]: getEditorContext, + [Methods.InsertText]: insertText, + [Methods.PickMedia]: pickMedia, + [Methods.OpenFile]: openFile, + [Methods.OpenFileDiff]: openFileDiff, + [Methods.TrackFiles]: trackFiles, + [Methods.ClearTrackedFiles]: clearTrackedFiles, + [Methods.RevertFiles]: revertFiles, + [Methods.KeepChanges]: keepChanges, + [Methods.CheckFileExists]: checkFileExists, + [Methods.CheckFilesExist]: checkFilesExist, +}; diff --git a/apps/vscode/src/handlers/index.ts b/apps/vscode/src/handlers/index.ts new file mode 100644 index 0000000000..41bd7dcc0e --- /dev/null +++ b/apps/vscode/src/handlers/index.ts @@ -0,0 +1,20 @@ +import { cliHandlers } from "./cli.handler"; +import { configHandlers } from "./config.handler"; +import { mcpHandlers } from "./mcp.handler"; +import { sessionHandlers } from "./session.handler"; +import { chatHandlers } from "./chat.handler"; +import { fileHandlers } from "./file.handler"; +import { workspaceHandlers } from "./workspace.handler"; +import type { Handler } from "./types"; + +export type { Handler, HandlerContext, BroadcastFn } from "./types"; + +export const handlers: Record> = { + ...workspaceHandlers, + ...cliHandlers, + ...configHandlers, + ...mcpHandlers, + ...sessionHandlers, + ...chatHandlers, + ...fileHandlers, +}; diff --git a/apps/vscode/src/handlers/mcp.handler.ts b/apps/vscode/src/handlers/mcp.handler.ts new file mode 100644 index 0000000000..83394c9901 --- /dev/null +++ b/apps/vscode/src/handlers/mcp.handler.ts @@ -0,0 +1,9 @@ +import { Methods } from "../../shared/bridge"; +import { MCPManager } from "../managers"; +import type { Handler } from "./types"; + +export const mcpHandlers: Record> = { + [Methods.GetMCPServers]: async () => { + return MCPManager.getServers(); + }, +}; diff --git a/apps/vscode/src/handlers/session.handler.ts b/apps/vscode/src/handlers/session.handler.ts new file mode 100644 index 0000000000..05aa758107 --- /dev/null +++ b/apps/vscode/src/handlers/session.handler.ts @@ -0,0 +1,57 @@ +import { Methods } from "../../shared/bridge"; +import { listSessions, parseSessionEvents, deleteSession, parseConfig, getErrorCode, CliErrorCodes } from "@moonshot-ai/kimi-code-vscode-agent-sdk"; +import { GitManager } from "../managers"; +import type { SessionInfo, StreamEvent } from "@moonshot-ai/kimi-code-vscode-agent-sdk"; +import type { Handler } from "./types"; + +interface LoadHistoryParams { + kimiSessionId: string; +} + +interface DeleteSessionParams { + sessionId: string; +} + +export const sessionHandlers: Record> = { + [Methods.GetKimiSessions]: async (_, ctx) => { + return ctx.workDir ? listSessions(ctx.workDir) : []; + }, + + [Methods.LoadKimiSessionHistory]: async (params: LoadHistoryParams, ctx): Promise => { + if (!ctx.workDir) { + return []; + } + + ctx.fileManager.setSessionId(ctx.webviewId, params.kimiSessionId); + await GitManager.initBaseline(ctx.workDir, params.kimiSessionId); + + const config = parseConfig(); + const model = config.defaultModel || config.models[0]?.id || ""; + // History replay creates the session; YOLO defaults to off here and is + // re-supplied by the webview on the next streamChat. + const session = await ctx.getOrCreateSession(model, config.defaultThinking, "default", params.kimiSessionId); + try { + await session.ensureStarted(); + } catch (err) { + // A failed handshake on AUTH_REQUIRED means the CLI token is no longer + // valid; flip the login context so the UI can guide re-authentication + // (mirrors streamChat). Re-throw so the bridge surfaces the error. + if (getErrorCode(err) === CliErrorCodes.AUTH_REQUIRED) { + ctx.setLoggedIn(false); + } + throw err; + } + // A successful handshake proves the CLI token is valid. + ctx.setLoggedIn(true); + const replayed = session.consumeBufferedEvents(); + + return replayed.length > 0 ? replayed : parseSessionEvents(ctx.workDir, params.kimiSessionId); + }, + + [Methods.DeleteKimiSession]: async (params: DeleteSessionParams, ctx): Promise<{ ok: boolean }> => { + if (!ctx.workDir) { + return { ok: false }; + } + return { ok: await deleteSession(ctx.workDir, params.sessionId) }; + }, +}; diff --git a/apps/vscode/src/handlers/types.ts b/apps/vscode/src/handlers/types.ts new file mode 100644 index 0000000000..9430f1aab9 --- /dev/null +++ b/apps/vscode/src/handlers/types.ts @@ -0,0 +1,28 @@ +import type { FileManager } from "../managers/file.manager"; +import type { Session, Turn, AgentMode } from "@moonshot-ai/kimi-code-vscode-agent-sdk"; + +export type BroadcastFn = (event: string, data: unknown, webviewId?: string) => void; + +export interface HandlerContext { + webviewId: string; + workDir: string | null; + requireWorkDir: () => string; + broadcast: BroadcastFn; + fileManager: FileManager; + + getSession: () => Session | undefined; + getSessionId: () => string | null; + getTurn: () => Turn | undefined; + setTurn: (turn: Turn | null) => void; + getSessionGeneration: () => number; + bumpSessionGeneration: () => number; + isSessionGeneration: (generation: number) => boolean; + getOrCreateSession: (model: string, thinking: boolean, mode: AgentMode, sessionId?: string) => Promise; + prewarmSession: (model: string, thinking: boolean, mode: AgentMode) => Promise; + closeSession: () => Promise; + saveAllDirty: () => Promise; + reloadWebview: () => void; + setLoggedIn: (loggedIn: boolean) => void; +} + +export type Handler = (params: TParams, ctx: HandlerContext) => Promise; diff --git a/apps/vscode/src/handlers/workspace.handler.ts b/apps/vscode/src/handlers/workspace.handler.ts new file mode 100644 index 0000000000..0daf60cac8 --- /dev/null +++ b/apps/vscode/src/handlers/workspace.handler.ts @@ -0,0 +1,21 @@ +import * as vscode from "vscode"; +import { Methods } from "../../shared/bridge"; +import type { Handler } from "./types"; +import { WorkspaceStatus } from "shared/types"; + +const checkWorkspace: Handler = async (_, ctx) => { + return { + hasWorkspace: ctx.workDir !== null, + path: ctx.workDir ?? undefined, + }; +}; + +const openFolder: Handler = async () => { + await vscode.commands.executeCommand("vscode.openFolder"); + return { ok: true }; +}; + +export const workspaceHandlers: Record> = { + [Methods.CheckWorkspace]: checkWorkspace, + [Methods.OpenFolder]: openFolder, +}; diff --git a/apps/vscode/src/managers/cli.manager.ts b/apps/vscode/src/managers/cli.manager.ts new file mode 100644 index 0000000000..f1102e3675 --- /dev/null +++ b/apps/vscode/src/managers/cli.manager.ts @@ -0,0 +1,89 @@ +import * as vscode from "vscode"; +import { spawn } from "child_process"; + +const MIN_CLI_VERSION = "0.14.0"; + +interface CLIInfo { + version: string; +} + +let instance: CLIManager | null = null; + +export function initCLIManager(): CLIManager { + instance = new CLIManager(); + return instance; +} + +export function getCLIManager(): CLIManager { + if (!instance) { + throw new Error("CLIManager not initialized"); + } + return instance; +} + +function exec(cmd: string, args: string[]): Promise { + return new Promise((resolve, reject) => { + const proc = spawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"] }); + let stdout = ""; + let stderr = ""; + proc.stdout.on("data", (d) => (stdout += d)); + proc.stderr.on("data", (d) => (stderr += d)); + proc.on("error", reject); + proc.on("close", (code) => (code === 0 ? resolve(stdout.trim()) : reject(new Error(stderr.trim() || `${cmd} exited with ${code}`)))); + }); +} + +function compareVersions(a: string, b: string): number { + // Compare only the numeric core, ignoring any -prerelease / +build suffix + // (e.g. "0.14.0-beta.1" is treated as "0.14.0") so Number() never sees NaN. + const core = (v: string) => v.split(/[-+]/)[0].split(".").map(Number); + const pa = core(a); + const pb = core(b); + for (let i = 0; i < Math.max(pa.length, pb.length); i++) { + const diff = (pa[i] || 0) - (pb[i] || 0); + if (diff !== 0) { + return diff; + } + } + return 0; +} + +export class CLIManager { + getExecutablePath(): string { + return vscode.workspace.getConfiguration("kimi").get("executablePath", "") || "kimi"; + } + + async checkInstalled(): Promise { + const info = await this.getInfo(this.getExecutablePath()).catch(() => null); + return info !== null && this.meetsRequirements(info); + } + + async installCLI(): Promise { + if (await this.checkInstalled()) { + return; + } + + const choice = await vscode.window.showErrorMessage( + `Kimi Code CLI is not installed or is too old. Install the latest Kimi Code CLI, then make sure \`kimi --version\` reports ${MIN_CLI_VERSION} or newer.`, + "Open Terminal", + ); + if (choice === "Open Terminal") { + const terminal = vscode.window.createTerminal("Install Kimi Code"); + terminal.show(); + terminal.sendText("npm install -g @moonshot-ai/kimi-code"); + } + throw new Error("Kimi Code CLI is not installed"); + } + + private async getInfo(execPath: string): Promise { + const env = vscode.env.remoteName ? ` (remote: ${vscode.env.remoteName})` : ""; + console.log(`[Kimi CLI] Getting version from ${execPath}${env}`); + const output = await exec(execPath, ["--version"]); + const version = output.match(/\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?/)?.[0] ?? output.trim(); + return { version }; + } + + private meetsRequirements(info: CLIInfo): boolean { + return compareVersions(info.version, MIN_CLI_VERSION) >= 0; + } +} diff --git a/apps/vscode/src/managers/file.manager.ts b/apps/vscode/src/managers/file.manager.ts new file mode 100644 index 0000000000..3b22a52387 --- /dev/null +++ b/apps/vscode/src/managers/file.manager.ts @@ -0,0 +1,162 @@ +import * as vscode from "vscode"; +import * as path from "node:path"; +import * as fs from "fs"; +import { GitManager } from "./git.manager"; +import { Events } from "../../shared/bridge"; +import type { ProjectFile } from "../../shared/types"; + +export type BroadcastFn = (event: string, data: unknown, webviewId?: string) => void; + +const IGNORE_DIRS = new Set([ + "node_modules", + ".git", + ".svn", + ".hg", + "dist", + "build", + "out", + ".next", + ".nuxt", + "__pycache__", + ".cache", + ".venv", + "venv", + ".gradle", + ".idea", + ".DS_Store", + "Thumbs.db", + "coverage", + ".nyc_output", + ".pytest_cache", + ".mypy_cache", + ".tox", + ".eggs", + ".sass-cache", + ".parcel-cache", + "bower_components", + "jspm_packages", + ".turbo", +]); + +const IGNORE_EXT = new Set([".lock", ".log", ".map", ".min.js", ".min.css", ".chunk.js", ".chunk.css"]); + +function shouldIgnore(name: string): boolean { + if (IGNORE_DIRS.has(name)) { + return true; + } + const ext = path.extname(name).toLowerCase(); + return IGNORE_EXT.has(ext); +} + +const SEARCH_EXCLUDE = `{${[...IGNORE_DIRS].map((d) => `**/${d}`).join(",")}}`; + +interface ViewState { + sessionId: string | null; + trackedFiles: Set; +} + +export class FileManager { + private viewStates = new Map(); + private disposables: vscode.Disposable[] = []; + + constructor( + private getWorkDir: () => string | null, + private broadcast: BroadcastFn, + ) { + // Watch for file changes + const watcher = vscode.workspace.createFileSystemWatcher("**/*"); + + watcher.onDidChange((uri) => this.onFileChange(uri)); + watcher.onDidCreate((uri) => this.onFileChange(uri)); + watcher.onDidDelete((uri) => this.onFileChange(uri)); + + this.disposables.push(watcher); + } + + private getViewState(webviewId: string): ViewState { + let state = this.viewStates.get(webviewId); + if (!state) { + state = { sessionId: null, trackedFiles: new Set() }; + this.viewStates.set(webviewId, state); + } + return state; + } + + setSessionId(webviewId: string, sessionId: string): void { + this.getViewState(webviewId).sessionId = sessionId; + } + + getSessionId(webviewId: string): string | null { + return this.getViewState(webviewId).sessionId; + } + + trackFile(webviewId: string, absolutePath: string): void { + this.getViewState(webviewId).trackedFiles.add(absolutePath); + } + + getTracked(webviewId: string): Set { + return this.getViewState(webviewId).trackedFiles; + } + + clearTracked(webviewId: string): void { + this.getViewState(webviewId).trackedFiles.clear(); + } + + disposeView(webviewId: string): void { + this.viewStates.delete(webviewId); + } + + private async onFileChange(uri: vscode.Uri): Promise { + const workDir = this.getWorkDir(); + if (!workDir) { + return; + } + + const absolutePath = uri.fsPath; + + for (const [webviewId, state] of this.viewStates) { + if (!state.sessionId || !state.trackedFiles.has(absolutePath)) { + continue; + } + + const changes = await GitManager.getChanges(workDir, state.sessionId, state.trackedFiles); + this.broadcast(Events.FileChangesUpdated, changes, webviewId); + } + } + + async searchFiles(query?: string): Promise { + const pattern = query ? `**/*${query}*` : "**/*"; + const files = await vscode.workspace.findFiles(pattern, SEARCH_EXCLUDE, 100); + + return files.map((uri) => ({ + path: vscode.workspace.asRelativePath(uri), + name: path.basename(uri.fsPath), + isDirectory: false, + })); + } + + async listDirectory(workDir: string, directory: string): Promise { + const dirPath = directory ? path.join(workDir, directory) : workDir; + try { + const entries = await fs.promises.readdir(dirPath, { withFileTypes: true }); + return entries + .filter((e) => !shouldIgnore(e.name)) + .map((e) => ({ + path: directory ? path.join(directory, e.name) : e.name, + name: e.name, + isDirectory: e.isDirectory(), + })) + .sort((a, b) => (a.isDirectory === b.isDirectory ? a.name.localeCompare(b.name) : a.isDirectory ? -1 : 1)); + } catch { + return []; + } + } + + dispose(): void { + for (const d of this.disposables) { + d.dispose(); + } + this.disposables = []; + this.viewStates.clear(); + } +} diff --git a/apps/vscode/src/managers/git.manager.ts b/apps/vscode/src/managers/git.manager.ts new file mode 100644 index 0000000000..8dc98a8b5e --- /dev/null +++ b/apps/vscode/src/managers/git.manager.ts @@ -0,0 +1,331 @@ +import { spawn } from "child_process"; +import * as path from "node:path"; +import * as fs from "fs"; +import { KimiPaths } from "@moonshot-ai/kimi-code-vscode-agent-sdk/paths"; +import type { FileChange } from "../../shared/types"; + +const BASELINE_REF = "refs/kimi/baseline"; +const BASELINE_FAILURE_TTL_MS = 30_000; + +interface PendingBaseline { + kind: "initializing"; + sessionId: string; + promise: Promise; + resolve: () => void; + reject: (err: unknown) => void; +} + +interface FailedBaseline { + kind: "failed"; + sessionId: string; + error: string; + failedAt: number; +} + +type BaselineState = PendingBaseline | FailedBaseline | { kind: "ready"; sessionId: string } | { kind: "idle"; sessionId: string }; + +const baselineStates = new Map(); + +function baselineKey(workDir: string, sessionId: string): string { + return `${path.resolve(workDir)}::${sessionId}`; +} + +function getBaselineState(workDir: string, sessionId: string): BaselineState { + return baselineStates.get(baselineKey(workDir, sessionId)) ?? { kind: "idle", sessionId }; +} + +function setBaselineState(workDir: string, sessionId: string, state: BaselineState): void { + baselineStates.set(baselineKey(workDir, sessionId), state); +} + +function clearBaselineState(workDir: string, sessionId: string): void { + const key = baselineKey(workDir, sessionId); + const existing = baselineStates.get(key); + if (existing?.kind === "initializing") { + existing.reject(new Error("Git baseline cleared before initialization completed")); + } + baselineStates.delete(key); +} + +function isBaselineFailureFresh(failedAt: number): boolean { + return Date.now() - failedAt < BASELINE_FAILURE_TTL_MS; +} + +function createPendingBaseline(workDir: string, sessionId: string): PendingBaseline { + let resolve!: () => void; + let reject!: (err: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + const pending = { kind: "initializing" as const, sessionId, promise, resolve, reject }; + setBaselineState(workDir, sessionId, pending); + return pending; +} + +function markBaselineReady(workDir: string, sessionId: string, pending?: PendingBaseline): void { + setBaselineState(workDir, sessionId, { kind: "ready", sessionId }); + pending?.resolve(); +} + +function markBaselineFailed(workDir: string, sessionId: string, err: unknown, pending?: PendingBaseline): void { + const error = err instanceof Error ? err.message : String(err); + setBaselineState(workDir, sessionId, { kind: "failed", sessionId, error, failedAt: Date.now() }); + pending?.reject(err); +} + +async function ensureBaselineReadyInternal(workDir: string, sessionId: string): Promise { + const state = getBaselineState(workDir, sessionId); + + if (state.kind === "ready") { + return; + } + + if (state.kind === "failed" && isBaselineFailureFresh(state.failedAt)) { + throw new Error(`Git baseline failed during last initialization: ${state.error}`); + } + + if (state.kind === "initializing") { + return state.promise; + } + + const pending = createPendingBaseline(workDir, sessionId); + try { + await ensureRepo(workDir, sessionId); + const hash = await commitAll(workDir, sessionId, "baseline"); + await setBaselineRef(workDir, sessionId, hash); + markBaselineReady(workDir, sessionId, pending); + } catch (err) { + markBaselineFailed(workDir, sessionId, err, pending); + throw err; + } +} + +function execGit(args: string[]): Promise { + return new Promise((resolve, reject) => { + const proc = spawn("git", args, { stdio: ["ignore", "pipe", "pipe"] }); + let out = ""; + let err = ""; + proc.stdout.on("data", (d) => (out += d)); + proc.stderr.on("data", (d) => (err += d)); + proc.on("close", (code) => (code === 0 ? resolve(out.trim()) : reject(new Error(err || `git exited ${code}`)))); + proc.on("error", reject); + }); +} + +function git(workDir: string, sessionId: string, ...args: string[]): Promise { + const gitDir = KimiPaths.shadowGitDir(workDir, sessionId); + return execGit([`--git-dir=${gitDir}`, `--work-tree=${workDir}`, ...args]); +} + +function toRelative(workDir: string, absolutePath: string): string { + return path.relative(workDir, absolutePath); +} + +async function ensureRepo(workDir: string, sessionId: string): Promise { + const gitDir = KimiPaths.shadowGitDir(workDir, sessionId); + if (!fs.existsSync(gitDir)) { + fs.mkdirSync(path.dirname(gitDir), { recursive: true }); + await execGit(["init", "--bare", gitDir]); + } +} + +async function commitAll(workDir: string, sessionId: string, message: string): Promise { + await git(workDir, sessionId, "add", "-A").catch(() => {}); + await git(workDir, sessionId, "commit", "--allow-empty", "-m", message).catch(() => {}); + return git(workDir, sessionId, "rev-parse", "HEAD"); +} + +async function getBaselineRef(workDir: string, sessionId: string): Promise { + try { + return await git(workDir, sessionId, "rev-parse", BASELINE_REF); + } catch { + return null; + } +} + +async function setBaselineRef(workDir: string, sessionId: string, hash: string): Promise { + await git(workDir, sessionId, "update-ref", BASELINE_REF, hash); +} + +export const GitManager = { + async initBaseline(workDir: string, sessionId: string): Promise { + await ensureBaselineReadyInternal(workDir, sessionId); + }, + + async whenBaselineReady(workDir: string, sessionId: string): Promise { + const state = getBaselineState(workDir, sessionId); + if (state.kind === "ready") { + return; + } + if (state.kind === "initializing") { + return state.promise; + } + if (state.kind === "failed" && isBaselineFailureFresh(state.failedAt)) { + throw new Error(`Git baseline failed during last initialization: ${state.error}`); + } + }, + + isBaselineReady(workDir: string, sessionId: string): boolean { + return getBaselineState(workDir, sessionId).kind === "ready"; + }, + + markBaselineInitializing(workDir: string, sessionId: string): void { + const state = getBaselineState(workDir, sessionId); + if (state.kind === "ready") { + return; + } + createPendingBaseline(workDir, sessionId); + }, + + markBaselineReady(workDir: string, sessionId: string): void { + const state = getBaselineState(workDir, sessionId); + markBaselineReady(workDir, sessionId, state.kind === "initializing" ? state : undefined); + }, + + markBaselineFailed(workDir: string, sessionId: string, err: unknown): void { + const state = getBaselineState(workDir, sessionId); + markBaselineFailed(workDir, sessionId, err, state.kind === "initializing" ? state : undefined); + }, + + clearBaseline(workDir: string, sessionId: string): void { + clearBaselineState(workDir, sessionId); + }, + + async commit(workDir: string, sessionId: string): Promise { + await ensureRepo(workDir, sessionId); + await commitAll(workDir, sessionId, "snapshot"); + }, + + async updateBaseline(workDir: string, sessionId: string): Promise { + await ensureRepo(workDir, sessionId); + const head = await git(workDir, sessionId, "rev-parse", "HEAD"); + await setBaselineRef(workDir, sessionId, head); + }, + + async revertToBaseline(workDir: string, sessionId: string): Promise { + await ensureRepo(workDir, sessionId); + let baseline = await getBaselineRef(workDir, sessionId); + if (!baseline) { + await this.whenBaselineReady(workDir, sessionId); + baseline = await getBaselineRef(workDir, sessionId); + } + if (baseline) { + await git(workDir, sessionId, "reset", "--hard", baseline); + } + }, + + async revertFile(workDir: string, sessionId: string, absolutePath: string): Promise { + await ensureRepo(workDir, sessionId); + let baseline = await getBaselineRef(workDir, sessionId); + if (!baseline) { + await this.whenBaselineReady(workDir, sessionId); + baseline = await getBaselineRef(workDir, sessionId); + } + if (!baseline) { + return; + } + + const rel = toRelative(workDir, absolutePath); + try { + await git(workDir, sessionId, "checkout", baseline, "--", rel); + } catch { + if (fs.existsSync(absolutePath)) { + fs.unlinkSync(absolutePath); + } + } + }, + + async getChanges(workDir: string, sessionId: string, trackedFiles: Set): Promise { + await ensureRepo(workDir, sessionId); + let baseline = await getBaselineRef(workDir, sessionId); + if (!baseline) { + await this.whenBaselineReady(workDir, sessionId); + baseline = await getBaselineRef(workDir, sessionId); + } + if (!baseline) { + return []; + } + + const changes: FileChange[] = []; + + for (const absolutePath of trackedFiles) { + const relativePath = toRelative(workDir, absolutePath); + if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) { + continue; + } + + const exists = fs.existsSync(absolutePath); + const baselineContent = await this.getBaselineContent(workDir, sessionId, absolutePath); + + if (!exists && baselineContent !== null) { + changes.push({ + path: relativePath, + status: "Deleted", + additions: 0, + deletions: baselineContent.split("\n").length, + }); + continue; + } + + if (exists && baselineContent === null) { + try { + const content = fs.readFileSync(absolutePath, "utf-8"); + changes.push({ + path: relativePath, + status: "Added", + additions: content.split("\n").length, + deletions: 0, + }); + } catch { + changes.push({ path: relativePath, status: "Added", additions: 0, deletions: 0 }); + } + continue; + } + + if (exists && baselineContent !== null) { + const diff = await this.diff(workDir, sessionId, absolutePath); + if (diff) { + let additions = 0; + let deletions = 0; + for (const line of diff.split("\n")) { + if (line.startsWith("+") && !line.startsWith("+++")) { + additions++; + } else if (line.startsWith("-") && !line.startsWith("---")) { + deletions++; + } + } + changes.push({ path: relativePath, status: "Modified", additions, deletions }); + } + } + } + + return changes; + }, + + async diff(workDir: string, sessionId: string, absolutePath: string): Promise { + await ensureRepo(workDir, sessionId); + const baseline = await getBaselineRef(workDir, sessionId); + if (!baseline) { + return ""; + } + + const rel = toRelative(workDir, absolutePath); + return git(workDir, sessionId, "diff", baseline, "--", rel).catch(() => ""); + }, + + async getBaselineContent(workDir: string, sessionId: string, absolutePath: string): Promise { + await ensureRepo(workDir, sessionId); + const baseline = await getBaselineRef(workDir, sessionId); + if (!baseline) { + return null; + } + + const rel = toRelative(workDir, absolutePath); + try { + return await git(workDir, sessionId, "show", `${baseline}:${rel}`); + } catch { + return null; + } + }, +}; diff --git a/apps/vscode/src/managers/index.ts b/apps/vscode/src/managers/index.ts new file mode 100644 index 0000000000..4e95bf6d48 --- /dev/null +++ b/apps/vscode/src/managers/index.ts @@ -0,0 +1,4 @@ +export { CLIManager, getCLIManager, initCLIManager } from "./cli.manager"; +export { MCPManager } from "./mcp.manager"; +export { GitManager } from "./git.manager"; +export { FileManager, type BroadcastFn } from "./file.manager"; diff --git a/apps/vscode/src/managers/mcp.manager.ts b/apps/vscode/src/managers/mcp.manager.ts new file mode 100644 index 0000000000..6961f80a5f --- /dev/null +++ b/apps/vscode/src/managers/mcp.manager.ts @@ -0,0 +1,7 @@ +import { type MCPServerConfig } from "@moonshot-ai/kimi-code-vscode-agent-sdk"; + +export const MCPManager = { + getServers(): MCPServerConfig[] { + return []; + }, +}; diff --git a/apps/vscode/src/repositories/index.ts b/apps/vscode/src/repositories/index.ts new file mode 100644 index 0000000000..e69de29bb2 diff --git a/apps/vscode/tsconfig.json b/apps/vscode/tsconfig.json new file mode 100644 index 0000000000..5bcbbe8fe6 --- /dev/null +++ b/apps/vscode/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "module": "Node16", + "target": "ES2022", + "lib": ["es2024", "dom"], + "types": ["node"], + "sourceMap": true, + "outDir": "dist", + "rootDir": "..", + "strict": true, + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "skipLibCheck": true, + "paths": { + "@/*": ["./src/*"], + "shared/*": ["./shared/*"] + } + }, + "include": ["src/**/*", "shared/**/*"], + "exclude": ["out", "dist", "node_modules", "webview-ui"] +} diff --git a/apps/vscode/webview-ui/components.json b/apps/vscode/webview-ui/components.json new file mode 100644 index 0000000000..9823830a35 --- /dev/null +++ b/apps/vscode/webview-ui/components.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "radix-nova", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/styles/index.css", + "baseColor": "zinc", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "tabler", + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "menuColor": "default", + "menuAccent": "subtle", + "registries": {} +} diff --git a/apps/vscode/webview-ui/index.html b/apps/vscode/webview-ui/index.html new file mode 100644 index 0000000000..ed8edcd58f --- /dev/null +++ b/apps/vscode/webview-ui/index.html @@ -0,0 +1,12 @@ + + + + + + Kimi Code + + +
+ + + diff --git a/apps/vscode/webview-ui/package.json b/apps/vscode/webview-ui/package.json new file mode 100644 index 0000000000..aceb8696ec --- /dev/null +++ b/apps/vscode/webview-ui/package.json @@ -0,0 +1,53 @@ +{ + "name": "@moonshot-ai/kimi-code-vscode-webview", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "build": "tsc && vite build", + "test": "vitest run" + }, + "dependencies": { + "@base-ui/react": "^1.0.0", + "@fontsource-variable/inter": "^5.2.8", + "@moonshot-ai/kimi-code-vscode-agent-sdk": "workspace:*", + "@moonshot-ai/kimi-code-vscode-display-model": "workspace:*", + "@radix-ui/react-accordion": "^1.2.12", + "@tabler/icons-react": "^3.36.0", + "ahooks": "^3.9.6", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "cmdk": "^1.1.1", + "diff": "^8.0.2", + "fuse.js": "^7.1.0", + "heic-to": "^1.0.2", + "immer": "^11.1.0", + "next-themes": "^0.4.6", + "radix-ui": "^1.4.3", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "react-markdown": "^10.1.0", + "react-scroll-to-bottom": "^4.2.0", + "react-syntax-highlighter": "^16.1.0", + "remark-gfm": "^4.0.1", + "shadcn": "^3.6.2", + "sonner": "^2.0.7", + "tailwind-merge": "^3.4.0", + "tw-animate-css": "^1.4.0", + "zustand": "^5" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.1.4", + "@types/diff": "^8.0.0", + "@types/react": "^19.1.2", + "@types/react-dom": "^19.1.2", + "@types/react-scroll-to-bottom": "^4.2.5", + "@types/react-syntax-highlighter": "^15.5.13", + "@vitejs/plugin-react": "^4.4.1", + "tailwindcss": "^4.1.4", + "typescript": "6.0.2", + "vite": "^6.3.3", + "vite-plugin-css-injected-by-js": "^3.5.2", + "vitest": "4.1.4" + } +} diff --git a/apps/vscode/webview-ui/public/kimi-banner-dark.svg b/apps/vscode/webview-ui/public/kimi-banner-dark.svg new file mode 100644 index 0000000000..746420861b --- /dev/null +++ b/apps/vscode/webview-ui/public/kimi-banner-dark.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/vscode/webview-ui/public/kimi-banner-light.svg b/apps/vscode/webview-ui/public/kimi-banner-light.svg new file mode 100644 index 0000000000..bdfe65a773 --- /dev/null +++ b/apps/vscode/webview-ui/public/kimi-banner-light.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/vscode/webview-ui/public/kimi-logo.png b/apps/vscode/webview-ui/public/kimi-logo.png new file mode 100644 index 0000000000000000000000000000000000000000..93a1b13c8e50813fc0d464382acffd588819e9de GIT binary patch literal 19541 zcmV)^K!CrAP)Px#L}ge>W=%~1DgXcg2mk?xX#fNO00031000^Q000001E2u_0{{R30RRC20H6W@ z1ONa40RR91aG(PK1ONa40RR91Z~y=R0N#R8u>b%-07*naRCoc^od=j+S9#~pRE>J? zlJ%*}wyfqR%T2{aV2UB6VvK=h>6nmBHY86L5yG?2rjQ5nB%4iu#HK?iCLtI%$#TI( zvehN4j*4Y<&8Sb`|L=Y8`+avljinhmu-PNc+3&0HWd-NE+uPe4s;jHlw6?Z#y@vb$U#3E#FsLe}`zk9d|0$)4UHa+x zRsD#tot_of9OIn6#CiT0=W$=aSh@22xF5&3AID;Ts(Be2Kno`De`+uRO&$D{2Cf(7mzx=sT zpECLL{Ila;R(BUP`+v@rm6bK0DhDn)nm?8gB!91bz}WyhiLUbc<#jIBUeu3z7duiu zFFrX3O!CV?js6Dc<;Q&Aq94(|>h6LgMZ-%@6_-+znRDXAsjo?aFYKy*-f##5A&%wc zyPKac7w7pS?N-WmX*Zigq>&*L_qkV5k=@`zsiVX1Q-|}oALkq+aWC>Ck7Ja{-;X@* zN1L4I6J=^^tJT+5%Wi3DuOB#Yz~aWn#$EYx-8t6;=vjj6&Ye5+ZPl7lUekf=P{;H0 zBCaJ}Qk;8D2%MeSz5M-X!^=b*ZJr0aX77wY9gs zS8XjgAes|22H*q^B17QPeFnsg4#F~f!^HRcasTzo za6j7QNQyqhb*U~u@GQ#kEWm?9<5^Y8kA5>L0xa?z#QZVJO7vM-z#~O|2~60TaaHMA z4psn0dFOTqkh<;d>X-g`ol4QPeW|tD(XoQ^-AUaB5OcV#t>eQq>csZu>0kim$9SxR zmA}V%fKVzM;LR%-j)AE(8S*E%R)!%|9-9z-@ zVu{u+nl$L;BD@+7E+`$Na)}Ef2f}$dayTbBfTj9TFUm%_eAzhXSYD3$Mq_R&1vj7z zDSIJ3l)o4CXrpV89i;p-%Fn51d2qCWp~(VRLc}M7AQKtdT7&y}mY?_O(>I+xdpfm= zD+MduxkA7%vIZ~k>!KV8=jkzb`z%xkn9fQit_ZGjkxG92_;YPmX#fzQbe;#It2WD@ z(RE$=LYw|12Y^ zO!`|JJgeQLL1mI^@-iw=Q&Xdc+6#4cy$aHk7v#hX0;mow(}U%lJD~zZx*43)nXmz7 z=}2A5IyaQgX@h%VEO6}!K%6kaT@aBhj_9)JkJqQo3`CWIKwP8moU5F0v>n$az?f74 zjvpCM0OjDtbA3XXC^Vv%^_@QIp93YU)t*P5i(t7Q5}Wok?&ulb&zkCLT|0Qx_4b`! zZEbCHG+72I|K}>yWqMrzu{I^c|EJf2K;97^ULv4#U8xL%a^jcDao2{Et5*Vufy8|$ zx*Hoju0k{2?wZly1P&nM=m7XVD{O#JPk9(ED(GBQ;vAm{S(&1H5!Z~5PWg4|NK+6# z$^kx!YwmHzv-pu^tLyf5O)h~`T`i8%a}b^F#)48I%BQ>o?)BkT>O&Ly%(W)te1YF6 zEV{H#{pr?)(l7ix`6Mr?8Ny@dr4WDT-N-V!y6+P}P}u4XWT};0NQU=RSPIYB$7ep6r$6( z7=uJ-ds}ggh-OmMcf+eyAg6|Y{rb)8%1`VPKsB*T?G$M2jix+M0Um8eew+hP5HpA$ z&o(0^0~8SQz&63Q3Bo|Jz zqb6biR9)@H4RhJWW{2rO#AnHJ?F@~|t?0_AIZuQt<30(I`|PQ2;1>Y7IB=SH>*B*j5!WR0!VOTPn(3Y&GnFGhZn!XTzfWCocR->Hx#aN-%K3zF;_4a> zG5O6YOezK5-XrOA?3P7yUNSC==q-#ImX~Mgvh5xbKtG^96B1 zG-_0XokVGA`sfa#vn^G8md?G=l;^f+E82~FA>!gbM+Y9z+cjtnNd6K(F_R5Vm2-}B z!y@X3n1$>3p)JZeA$&{#!H)sPWZ*jQ;0O2EgrbciR`RBsI!KyiIZUrOgu z9?_XkQIEV(8~ytAPc;^D^RlAHXA8dSQ>@wvsgoy9zE^`T1c8gCE^-pUJ2>{}FfodXbsa}`m1ey^9@bX})aBvL31lQ%;AP)k>y#U-nmcGo0NS!<(^ZDiX z^4C#L(M__8?a>yKs!F2J#k1Wm{-R!|=UM+dt&TkXqmc*RbqmKW$^Gl~qgaKw|jgn`n z98|SOn}9^(-0JGw#9o$X_g;sCAg&X+#C-u){um%gFVS2XC~jTSy~;u3i{R$V(N;(g zj2<{MqB91(D*ISm+C=}&V(n-jR9|0zAMKHfa!P|;F5)e;+)MZs9F+&K{2Xv1k4Ml+ zQiQ#@Pl_^eKR~gYcHhgzHTR7mA`Cz~ao|qpkSO{3;#!|9yLoLfA_*#>Q|=I4}Utf9#~s!7IBH6NXkE7(U)}YT$B2qJtGtU zL4%BjE5ahR7ToVUv9s9v8#saZ0Ytu;03{D%0Awza3d50fH1eq**8x(9n8?pRLlx~Z zh#L2T7;zoPJmJXWnp7#U;4bQ=j%qu%8~RmVhy1A5cj0FZyeM0KJhS3Y)0q{YFJ@CO zCLEbuBYsIDqwXP5n9$X7Gn#Fpb8pztVX0sLzJtYy4L0pE@yWe6lmevkMj{{ZEDE2A zyL`DQ19*VL4}iqAA4@tfK(=zxkVab$9=F08v~!4-0o7Gm@*zQuo{>6PaM08(?JAlH`u8U1Yt;lDh5W_5`fxq>`qR1@eo_|&~Xkuzw z^I&$P^t_VPlIYZ3eyS?{8NF)jQfpg_xDc`0ks2Br(xHO~8VGb72g>{J61?Dw^SjYG zCtr^9=nTg=M?4mRvr`d2(*fu#e7+J_W#J`tHaIYz=%UlbjDo$E9oA^nH_?|>Fg++x zJ8@3Vt$^Etlc>_U97cqL7CM$PuK!B)0Ht!!1a4h4)f8?q<0cB`pGRLU5|j0}cW;Su z_4QoS8Ld#%7HL^m*E@|JH~tRVFaXuz)Ps|FRU9M6l%*1LBfqXE){l?!zJ>C@+|ueH5;r6EIyCV+#s=gyr?M~@sELnBc6 z4S{Dp$$sTI(FTkrRx0{!>M*ICPyrm^lye-{B``Cy9qUpP!U&Lo6J>(P5GIHd&qG|A z*$&}yphi3Syy&O3bAfs`pj=yi-M!!OJYu8xe9!?7Y-(!O3WiK6MPZOK%}q_|=+VPz z{P=Oot2BSGx#gTi&MLJPe9-GXE;#G;?q4WBz_cTZiXWxuw28fJ-~dNr)&(b7Mi;-R zN5TLowdF=n)FnUsE6Q>WNM>2;JnEGnDI3Xw%i6&rRXu;Ei*L$Bef2!5UG&)AusgNX zKUFM;v_K;C>C@Z5sq59p4L!~M4$-5grrPe~VKuijE6=OWnl(q8qFQEGY9If97zakL z^1ugYz%hVmQH88voEw;#PiP#6Sfo5ZS#r@CK(%RRIwu>UO%#iq*+dAAin?$f#3r9q zevEP)gKL?11ed87c}$8Vt2C}l^~%&m>Bf0y=hi%MULM>;+c@)03ZKD|qehNSt*vcI z;j`4QZ@<*P|A2Jx;DJ=7d+cUgy=qnBJrjxC%Gc$<^PZ%P;H><;uJX8V;0Xj1kuq2~ zTAn%^+~Vc1CjFin9T1B85MBBHQ*t5fo&lAllE7DPTrvp(rZ8TU9xC|9#T z;*gSYvh;Osif6Gpc#yfsZMy#C`G z8XMA+D^|*EK4u^m8{@=?FE&)00Str%5JX*`$erp@H9%9j!h}M)_10UNw=j{T%6)3P!$dD}JO_@4mpZySxS01(*yXILJVQgG3w>#D!ph znlI129XocWLZQIv>!wAEm!y?XE-$rTRx&Kg05N}?S&nD|&^YFSElez=#~yw3qJ7A> z$@x%4LQb4$&=8#lsn@_%JLB!;#fsO>TeL5!sZnEanm~`}FTmLtbi@M1h^dy|BZ$ql zsR}8+0R#H$nJ1N(>^{1%rl#tjtJR4?d7V{!>P1eXj)PJv3{WN>av-YbDxfZ#WXtrB zL}PbTadN0aoO2`z8=D*$ym`WK&k2UOhD(`1OPVAN;V#;C$Vg=aEm@AbuB3b(s}nikTY5 z3`gU9M5fN|I^%X40ZdZNbkliuU6lau@yC~65_k_j@PJy;GqtY;f6Y=BZsQ!#>_>)E zk>hflN14c1L4lzFIr>~k)M)b)(HHb#b91w;2eQSGiGSd=1JaA<1UNdlP4UnnG?l6q zn(2`dW3_U>LX7zAcA^=0D&t_`$N4<*SqH)WUapx7o|YzvJNquOy$S#Umi7!8SRM>! zJ8?_OyBP7WJbY z4(vIA0KyMIK+FIr^5Ymp$PX~`f~(^CLdPf@Kt@@!SarebL2z1!fP=8csbyR^d(P}j z0uQnI^{*o$k$V^}0-{}dN@hOKgTO8-kuJXbj6Mc<);HB775kc9lLtpp7X8PVShyt* z{ntleTAKp;+Uu@Qy=1YHA7R8y2GC6RQVA@k4s!=E>4g78D?BuS3;=E5Xx|>D0M0qD z1P+5_z^v0%AyNfgn-P_BBK5=;U5wA3JtsZ-$YVX`Jizl^%tR{E0D$owDd!wXp9NP(X>HMs7q!NJ?#YfI5se8Kg1G2YD_3e_ zc4)$=oSG$N2fygaZ-@$N%p897Wc}|94#1Uw^m}kEb*+969_S!?6vTkDiQ_R%2nt8A z1QYv8jBr`|bLY)7o!3*bX`CmqiKA`Kw%TWn4$fmTq6dAFB2$&%PKh@X`F5pws`V8-3qJr;GDn60f+4Q*_xVA6Pev&jMP;Q&?Qz24J`9#i(RqM}!D6(} z#2v&&(!?ZT!jM>St*!M%V8&1KBjnB2_o$d9oA5dhV95qllNMqI-W?8)JFHSKsK&Nq1zl7+(ftX z2@tu*vHYi4j?wsttmDP}?wsU6@(Ggd=~P{2GCvTawY5n(>PNP|N3i!&Iq#d__-58K z=O72jb-n2_J<~ufQcqeq3pCRo=PbYJlM9_J9VlIBG*TY}SQ|rTt@i0tXGV((rPF6l znmfUSIDGhstya+PxN(=IvuB#bktYP8CPH^;NEseX^3K_5@fY{VFTdwvPx>DA?nLSsW*K=0^vH8t!d|U0iICgy&Aks4D=(&6z zV5%3@brf)pGOBu6%M)#?s?cjCn-Snh#+f=oBXkH=TcL4#s;}Q^;_kNa zOzr85E~&kh2^3Cx&%yiPfBj(k#y7s6s-;6AtLSGbj1qqtCCk(u<3PwK&f;F1#HGk& z^*DOUCO!GGI^kp+eg~@@n38$xv^^a^-e97c$;w#RJ^|>htv)envzH<_{rmSzhYlS! zx)&ei5A6DP9z06quj9PDCZ@ghmL6XMXyxrOD4t~^#|R<40eB)d9Ej+gJ-g>Ruc4ta zz3bP1J$>Wr-xBepL$fbOxQHwP#xhQ&q9lN0&(w1zxb58Yv%X|jD5BQNsyqPu1c2P? zFP16gGUJH;6NT~eK3N$rU$QV}P_^O1hZ#xm5vvvSj!9DD>2iwYu0u3Jz&tlbk@EY@ zG?K+dWjT=;wCRWJC^Rg;JHK4_xr0obaH3k52n|24A_K2yoCkQf-*H=7yXIvXAT_pl ziY{|8N&g{sjHT3H;+9%Rbs*IjqA0x;;%aVgPXh-GN+(V>Y9atuX#xOTDTTX{wXLQR z+pPV~oHqX?&b4f0Vzq+)wd2Vv)%EVxJrNqfMF#?~JeYY-G=gVb z5?lK#EA>!uOn|7-cRPhHH7ihbFpfjE9i=6@&<0Z#!fjB{jC-!x0qN|`^vURj`I8V zlY6jzyF}^HG(Zcug9b7gPTH0V+=-z>2WV?WLppfqfZcCyZs{~F>;YZ<$^#YmO`LVX zk&%xM;fLh)OEZu`gcar`F8n(<(8VYgTuGF=!LqzZ5f(V_jyvv1FTJ!{1QjP~GO>ut zhj$R8IL#3V0GyB>^pbbkJAom9diAO?i;yoZ@Jtob&`f9bW4ZXvLn^>*u)jJ(KcFZ<8T~vhTxs3tj zIgRT`Q7*3I80Fz2ND;}e}zr9?BL3tiQ&keOSW z&M`cTA5R<^i-+{!41VHua28w%;%UE%L|%=Sf6k`fy?a}Hv#zdAN4>Bj@oStd6bXxw zm^^#;?o|-`jM4arT!$e;hRD)9nNFS3*DEw}PM$htEAcq;Fo$jQ_3E$xI{*mI0;oJ# zO#G`44i+H7Nghx@yrYv5p=^WTt4tUiA4|I zm=K82QKLuMk`bGn4j(+M32;V1>}%9sr7bDZWfqf3hYlSoPAkhO|CI+SfD54FnmUok zdDLNs1c;vR1NH^qY}#?7n<>u)_uY4I`r6mN*3Ft;46%9J?Mu>{m)48u?lVHXNQgxT z3s6}NSN}VahlmoUz3Vf~{76`fjN7)vYjRQ!gNB5IL39YNYtr^Wn=EQKL@~4@Bv&X* zOvjHM)jHo$yB;)XP&%viKk6MheAvpe$_Yrgffe%(^A)MD zX(glq2;GPHtkqTsj;%6em=K6ttWvlTt_5^}Wn)q#o`Lx7CpY5f4?iMK^<8L8lve3d zbS%#Mxw8tPwV$$TNC>tY`Y56$9i8wH-$g(G6JW(P#{ephQJ3bToVBA)m%uEEp4?+E zc*+1T9lRxJ-P%oA`!V4do6a?QbP+>s)5{~|VO${QJe7u=_!vH0(Gm#P)Ep>H$ z>?IV|^AMwytCjCY|FJx&kC^r4D8G-z%H-g+vGzRtRJe{wHgxDGUN6dl7ju?iBQBqJN=k|s?}EoRs#SM+Pu1Yv2* zLw(SPA9Nh90Ex-Mo2RvKOMd|tJ*h7=+3EvjaZ7slu1y7Ph9;zT z_^{jvh#7!Ts2q?ypceux+JIKxChcZavUXkMcpTHq@VsAk`ozaSk+y8vk}kjeN_!NB zg-2P$ALt+Tm#`M1kbpIu5_?kMF?1Pv&r*nf9ep((6P6WpEJhd87#;?=z>JiqA$c{m zP7BTt9X@Dcmx#+X*It_jDH?;`+q-ACNv@OPJT@}1xJ!Q#srB`{1f*U^Q@1KZlmS3F zcszgSp)-#8{8DSbyt6`~kk+nUn?Cxnj~aO99@s=f4?0Na zWH3PZTJ4Gk6gNWPBIz`7WG61nx{8h>`Ld52D5f82Ld!r*4v1{Db&^3?M|6M?>5% zL*F&Cr9CrHFdW+2Gw}$h8fW};MH9d-krNRNZJfR~Q6xfRBUB8Q-PV&QPu7aa8i~#E z>5N1=ksNzZf)`lS2$KSR2+exc)o7*uoD8PZY4G5I^v4a20EC7)kDqu@S4V&f4)c0& zG0_U=ya37e%iz4~o=9NFuG;%m^fXpE`9?FLjPLeavKvkEFBAbXK1t89P=6kN{lfmJ$uZ6~y*ntNamD+3!WZ z)lr^hGa$)+I?XKRXg-yh$doDRS&ReaP$PZvlb=*9^9^kbw|08^i)Y?*&ppz8Ew<^Y zw_aUtY0b>7O)A%$d#=!*CDPW`uLOQx@0dWtpF?RoR0&puLTJ5O} z29o=Z7&@YY=+sNT9-IWdU(?okwo5e03TXd1UeIB#` zHj{F^+qYrML`S)F|PawE{2URgPZJ5W?h!_oBKG$+CmxNND$yZDxudN)Q;&( zO`rWApG`YI+0m*%2U#tzy;Myw}Z2xgrK~HfHBK z+S0UE!HB_PF!5?N)}?_VHPa-{awbS=`}XZxARb`vL;y6FDlP@@2F|+bDy`BF@2mrZ z5M7B+^74s?@RJ9`Ijyr;wLE5R8F0+IhPaj+3JF6HcWeFniN*QZ_6E2glC$D|f$Ps$A`k=Lh zp6t`7pLS)BO~)IK+Y2sc#8~4Qc)@9Lvz?sDO0AVFMavTrOY0l-7lT|{0lFnE3cekq7$%eiHL|w z)^Fn?S(9O~pzAq@Yne=_3Pfh-gj8YzAf8>lVBFwNuxN`BGu1Q| zA(~YEO}tN;IwkF{-<=wcA5$dexIQy-)Pmn;#PG1-IOYlG%r3)Azvz}F%kWueofUV* z{uGYD#sI0&Rq}l2LOOW=C-3hO$K=8D_RoLLBR_v6?UoZfd)Dk8wH^H(sBqPXKJ+1t zqcW{r`INGmxbzu-bgEw+60HD6+>_-cj)+?(sqKN$xz&qCGkU;bX24`CAG}IN9&ST( zbDLh`?4L%C7?JLI&ppy}O=;xFk!i*9<=RRyEFIQ1!m;BJu_rXip0gwRvy&fKd>$tm z+Z!ikz}dNOx%+@y;CeCKh2S=-qvOJG;W~fr_l0y1()r+(D(BngoNe*9-F~~Qy82R~ z9-jU2AN`TU@^{h%?Mrd784UoW8!6-jO;|lZ-*uJf6Tl-nImeAa!r|XtCO3gJcH9_S zaVPkF^yo3YeY;bx!dY|dF^Wi7pEEAMJQWI)q$B&ulD-!=qH39oOLQ09M^Vi(1JK0+ zx1;6|)g=Q#4{br?95qYd&(oJK9@K4mLWm*;d>2L^b)mB2b^?HFNlZHHlmu4$A} z^EZF*J12u=occ9bU>($=t@NEh zoiu4u>MLVqs9vL{-O-~*%h)-Yu2rn`si#)jCaGqvKN2sMs5j+h;{^ntO%58FE-oFA zIFBPDGmIFH;ZIt=IvV1I&W90W57IB5gIQcC6e6q3hbmW1Jd%kv5trv+CGOe1 zH{E&Xo$2#`_W4VB%z1U@&Yfdm0Ulh6Uc)W0@iAd#fw4y9Uk$3NV-)n{n=MO&c&uPCz=5CFF=+4WEo3i<7)Tq%IG47B99Oonhl%sT_ zE{fN7Q@>mXBLAbj6RoSV)PabI7Lv_8Z7d27vCMA%3NE8K!10rx2ytC3#~ZUoO#13? zx_)lipIV6OjTku5UD}58j%g<~-}7W!MQu%Q8{>cheQoWIJ^>n%zyK7TJy^h3>6^9Z z^Q1M$QV}Z-qer==Nfd@%*#rTwMz_AQRp*uVQVY*Kc<_)`EP4Z=SY9AS3h=0CgqJ)o zPkC;Pa|6{(ZP!E8f$Q=Fj&|Ub;M&N$&=Eoz5ExN9C(&+s+n$H1PC1p)OC;6e3|s&wY3K#DkV5Pp1FkBT|rIpx9L3oQq=nWuACr;oRupx;EjxS3&aLy1IGSC1z>)W3h zQ)nv&FLLVUmd%?@2cFb>7FePEq#p^ovm+ZvU$xWhIdjd8s7FW8kcD|fAXm`NVF2XU znQYfI3SCD>Lj-%E7VdPwnR&r=l;b)QAoPR-O*E=qYcp%#0L|Kr$zad0SGMQ}Pph<5 zZT7zSm;O3zcNaUIch~Q-Fx(ga`mdu~Jvz3Cdr0gF?Js8=?()~4`{)^3<`KC@jvQt8 zh~R_-fbcl&BSs9hr7Tv~iFXc_L(d}ZD_ggiC?L z7T?XTH&oCOZec3k0Ov_`1`u>IbQ{3YF*8!IsPyCDdQ8+obD-dcPN3g{Sz+5=H|%pP2-oTei#=NP8+$dFO%e zwvezk(SHL5_E&V}oLrdWrWbhzeZZI@6$%rx)%#lO3ooxg?+qK)ry;|Jm`G)N1tJn) zksePx{)D*^Y%AO?qh-(Dy^AQC;IWl7oL}UaoGGr z^x)`GqtZNa(1Ml-K_#PR0uJFq)?(7=JVIqK+9`|Dj!DE0w$*v`Cw(Sf`kJ;I+RH8y z|M-V5C}ul-+O*nALN{=lSxKf}T<5`U00o%*0G#PNxD3}r1`1ARq@w7`^it$=3;+O? zdPeLn%H`{HbzTk<6QQfb^8f|0iDV(J2q#1eZe^CUp^3MLj1KDGkAL(dt+mZc_uqg2 z1%V{2McTY+vxxb;amjc^NdSn~pIL;RrFbmKc9zNCBTb(^Ee#t!G!+U3<5mI;z1T{x zErNXDjyI0#)StnF2Z{c~M+c-3?|feCh8yOq47&*U10m-Qu^LeiyR!=zpiulIKtevV z7sIZze9q1F$lQY9G;~|J)tLt_&W&TsM4dh00>AhWK-BM43^zd&B2tv0PpAX;(FVu? zZqoSp(w}cxxALlsj;Yj!TmJYk+X)$KW8*0cE`LK`s+c$D>hxauV_2&VW$TUXUFj{o z$F@iImCl_z&rE%yDVHl=iMVDb8B0Ng!PxJ@y=~ieq`~qVubOq0xPF(>9OGq;xb@80 zQ;PP?)%}5H(2(CpKREA;7cWZh{Iz$aZ++`qY1gh@X@o@T@L|In#GzHoCCe8&w^f>6 zV5!DuDIc&wROs7Xn$-bT5R5W#1u5>w(Kt;Y$8{Vj%S3^|U;C%8+PW8cw4iBYXZc_6 zfB*Z_9)*nJIr)2>=i4*xZD*%HC02 z4pzW19L}C?mdF}x@95pA^|)Euvw}$6xN(#1BHJg9WAd^o7a*5QKMfu-*c|S9E&ucp zH`VF|P>h>vubn4{{6N|zaXn0{>C>l8RV?(d_RI7Vv()H?)0fit%f@TT=(vei0^RG@ zZBS^fV1~;Eg#^)mlp!X1^w_Z#`d(;9J>rvu|H1c1B4M32>2DUOf1;FgCWOc83xYxf zQh*zTD?0h&h9Ghr<9Y9U-}{R7tP%a93 zT*X+IFMq-&0Sk zWy3M6?62rJea4KmPrhGk8^%?W88%q7Sjb29-XJ3RwfzTd+u>ybAC)KHc0B;j)iUJ5 zUi!)cI)j;mg@sb3BZ$`KX8xu@o-3T-0L~ZqTn}McB4)FDcJOnEM80zoXAn-Dq8T5| z5_JhCkQdhmhjAbE(09RI-gc=?V!D10&;T8S07C$Crzr2?RF*5P;4IIO(Ue7=OyzQ)J@pam?X%dfz zbeyhj5hIO`(2I>oYuB!`?Sp+33d8zGbRIi;R4z|-x@zWCGM@J6RWiLOqia5qNgJ$q z@EX9)H{YxmLD^2BH?ArxepsbgT0tjwGb=H9VR0-o0<0v6TxBm~;x`R6PJ~eW%Sfm* zL~w}{AwHypZr6^SO*ZQJ1SFJ2 zJTjJi0z5br@Zvo^&IxG9O4eg|qlv%SfJo)5NZ0{5>wNkj3l}b#F=Kjq{<-IkLx_X! z*|X2Y@9EPgtt?jV35o9062&*%ut4od7YZ0&x_SKZ$Hj?bOpKzRF?_ae-D>eq#6JGw zf(17kkgpwh&3r>FSPY`Aue_qqkz9pa@=%p5se-^D48r)U_&cX5t z(be;aBB3W;63|!1W#!dRJEy;zAsuZFSVudYOK-VW8BJjHci+C!Z4zP3{NDP88Q<;1 zd~a!PO9RApwykg22n!6LlUOz2Ws|8>r&v3DQG!(ptVK5U&X9<7<7cxyZ_=d6CSLJJ z=`Vh$bej8-@)4O3Fx2H&T#+7o?6I_P(ZclXv(IWpVz+&MWXqOK>F&GV@%bvvswQo= zxgR@*POwj(7wru!d#ltr5?$Z~!H5Z9c$a|St6Q7abNiKXn(0+1T--CMn{lgs&LeY2 zjLhONh{+Z{HoY3B!gN4TJ7=_;axPl?Y@&>WFO8Bd5^2ghhDeM-B4Oc9{nTv1Pt_##I^$_Amy=J%uv%gU-{*8R{+ZMje9j=;0owLbu3c2fv8k( z(dU|Lu1+`Jc%y;Fau0^cYy0uQBs6 zIzCPZC)rQtfb)?Y42nqHBS?%+gT4&XxLoxQ%xz0Qb0-cM64qwLv@-$<->( zCOo!%+cvE(Y&CBE!N2^AR@`?e%2H?C#YzKvUikJ4%R@VKkLzLjCfcY`BNc{Qr)?nD z$^|)?zWd$pq?cB&PFG)jjp0GLi4!MoVRXnsl=7UzM-I1>GyS>2jc z>)STesefKI-8QvNY5n>2_N2%$*XCSfE(@t5&ZvzYuG7&+a`5eFwu3raLEFggRKj z^A$#;-*B-Ew13;aefvX3>ABa;9o%rN;ck@Xy7lXsKJlU-NS{U?o79GU#goD@mZaV&n2Re8hE{BpwtH zFLKi+U0a$^m}qSrJb2jZ_U|u!r%guy%DUgSt=lyy^+Blbe|5jM!;g{m_=?8XC=2nB zIPjeQoX-TaJa4=0HnSpe6HYXq)bsHNtMqY#D~+bdjy0tH62IrP*X3%7<`Dvk7d#*S z*~7NF!Iw3zymF@L!LehYUAF2*@tulP6Bv1L+*%2n!AHh7Qeu6)D)a!A!;RqzwQ7TmwK8 zgQNY4Q5j75Z7BSsNA)dCm1KJz_u2T0?jqEM7&NYe<{9UjQKIJ~I*t4En^YqM3ww)| zaKv&`^I1LHIg^QGZi4zkpN0&@VjLx1#>eCinl(6e>SV3I?K2SIs*!r(5(2X-fPR9I z_;`f77(c57?CHd?L6l;!FxmI-f7SdxxECp~S&YG+&oQG%%4j@c-$SEc{EaBUojiGp zEhpKZ-_ZaFlB0w1|L`LTwdpm2-~H)Nf7)OGYQLeD$yyc;8aSXpNND@Etr~Qj%-=rh z)G!fD->cM)VfJM*TUi1bp-99Teb)<0V?EI^tVd=z78e|bH3oQCsvJp#keJOxFZdfl z7!0;XC%+3Tx1~ve1BqJ_kq|()B`rjsI$~nb-~}Xck$znKy6B`HVi(S1%Q~3z{FWBQ zb8W(?oT}0{CK3@Ac2)yZ?5XM*n;YW!ewc_q9{lr_MQ>| zXTK)ygKyucNXPX>L1LS{pt5P>X5E{rULnPHD6TnCUnJaSe{_qnW2A7LBk{@-)6N~+ zjXu09&qTq(U9eyQBGXhAi@LYpdHV|OYX1^rH2^u$0|x6ehfDcF-XZxj$lBq5 zFlCOF$$}*c6h|Lrj$S(@5D_ybnnV;VS%~5by%3$HDt#7FR701ZIN4x8K|p*>GdM(K z?h^&#D}TMTF^K1b6Xgg9^VJ73T+d;|z)64skR_exc`jlR{pQ4LGGZtJCdCKKfZ)Rf zv^`o}P^F0khfS5Vfnx}VF+t&o?b3DlUTonTq3wQ$w3&#N3pN!oaoNI8`O79;CjGVB z+Qczm`ldS#bX*U(2A6`b4sKVttxl7d&uF1H@f~p`A_zce4i&?Mhl??GczI`n+D8rT z+q+km>;BInJ{fBfz(S$0W!Uf$f5=`Dja%E+hMRfJ?1D&e2{R5u1!D)!05mKnW+$D2 zv!=_0W^oeTfH?854t2Zm|LL5yS)e3QGZ->2@0x0M7?i(}Gd`|HfYmWW2 z5PJI5nM_xqv-sK(PPT7lh`;X4fY7&a5)uUWflwdM6lJ}>p21g?Zn^m;m66D$jM9w@ z7D)fEH*R_Asiy=a+aiV-zyOW$rs#*R3Gc| z`uW$Vr=Nb>d`RB?1=FRo=giVt;(pVK#GP-t>1G3wO-x7%3?Bep zv}kd?qBfsHeA4eCfQZq7g9lc~iCxHhXUtw^+yOo4ltM!|*0X?;>dlLE_(0q~hd(b2%8;}{WS_)agxD8P1@WPsy+2LTd*I+fC|ZY35bPE<)O z-S%|-_1ByEu9a;QPi&LeCiQ`SoF>&^Q;55irYEh!L^;_pWys`0^ z867+n&S49|jhZ#gO#G)I0(hUC)M^2Q9v!A1-T^>|4H%$re2G)0Oqnc^ut)VP%t8M7 zBadja&v^MlBkXlzoZ8W&#+Z14^pSqmNrY_{DIa6kfJ6%%CUIdB3A>kqL$ zW5zU1j#`=i~K8_UzrQqt|Er_+_)S7>ihC?+UBr z&;yZ*OM#Eb__1)OD^|K*uEczCA!XRta?35Z7}p}9a4V2*&nXE0*0;Vze!l}uFSw7fbpjABoHKh)yACzbx)5q+k5vxpad9*l^`aiRS|f zju6~l{=^g75H(p6MxT^a|Cyj{p@3Ed6406J)~yxyjWl~f!RtTy_{Tr~hz4Fvod6_v z*REa9$r4+r#tO(>8pTJ!KVxe*V8C?;6d^~1E`8Hdn^_YJ6V0q<+a50;GFWCYIud;_ zcI-Hbol({v@3>)MF;g)j5NE^}(QQnQojZ4!{+v8{qHT%C72#9F=wl`nt_}6EQW3-K ztQ$0FfC#QH+KMyb47iW4{~eJYzD#B~lj+W-OC?bV`BkS)8#bjr{rZ^5MNHjz;{v@n z^0WaCdb|MvU8pRT;(3R&ST0<1{^gQ2$NkqM*~`1%ii@PqFz zBA1<<2O#-+$dlJxcg=m`#yS=oCrzGYRv?zsREa(Ib=-8*P3E5f0)HNqHL%<7xLvcn zL;8T(ZN7?ugIM9OGow3LCp#kls35?1@2tPcP_otj0|Qk#uD2Pr1Y(SW8@4!`^?j}y3QNDSg36B zUA&k-^ZKM&hNuF*29GWWBmm=UyNA@TMT-{O_6|6S`v9_O;|4QUj%rNkdgF3UBz6Wc zxe&4V%}la^>Mwh1Mo2{rm8Hs?SM-gs+6T@xv7dkLc>{dmqD7J@gDuu7yQ4w6?}k;Y zR+-_F2eC^4DR}hw(Wdd^E?bH&n?L^slhZRL)?RpFm4yM;3n}z@21;+xS;EFbfWH6$ z3$sZ?K~zC>>dBQWO@A>15kqh+pN~SRFJ7`p9s5PPS@M66bRM&ProeZ{$TlF42t`3u~P$-y1&5{kAi(!VD&$=DNX2&!M4nb!DB%*!Qs@0lU^%CVnj5~St zi|7o|64w5jc;{Wl?XUmFZ)oDU&V-Zkv9G^=zTARe&@D62_`C>xLqhSTCKl6iCF0H8 zIa<(t+ue76<|7~e@Wa-bvR_>SNEs>e=YH*-@2u6#x|zYUv`@#eL~)3DTL!(H>q}pi}~%gG4pprVxoD z(2oO-oq+CTeCT(Klg+XT@R6xIK>-Z5U7+)Dxe%)a8`z=tz3+WblW(EDLQO1`?G=j_ zEmXfu@BWFv`|~o~ikGhoASE7s^wB3IDkq9~b68ZoUGf{^V(sBp%$_~R-Y}h~Pi6e# zx#!H!3DrDK#ag$SbJ=dBNT;SL^}{>#`Me0FM|tM!6y7fLVut)c%kye7hljz z9mmb&CWgsuMZZmx&-sEZxVOFiZ6e5VBknui@lJ`ky{bFXM9>YA9zXcO4=fOjBmDjE zf8WI7bI(3!?Jr)kIQ{SkKQx!&g!DG7gFa=$63a)#b_s>Ce1l}biAS`ucN2Ai^l6vd zLXZM0lR$_`5?RWC)FL}!^{N+bk0>9rTcbZR!bE@k(I?EXVHew7Z+Ww+8aNmLkvgwP z0%_0a1Iv~z`?L|{b$(qCK$Fo)N+H0>%Yu3K3rIWSkZf$Kx#1M{J zAg8*&oOcL7%<HMN;l&qMWnY4?vidml=rD{Aj0kiYe&)=+*z9@i{)R!L~c=KD{JW&DZIml_u`xVPqSV)Rl&1{)0LkFU-k?D+4 zfY?ON69xkqj0MClGq+xV;s7IlzNPmOYGrI-g|1k>(zX@g^TCa5@~Nq*Ht|jTaMGkn zrY|recF72Vt7+rbTW?7}`N>aAEMpu!^2pEAZ~yjhDS+Ob3KI+I8F4&D!B@WeRTGQX zUw@tYb4q$_pWGaMOIz*XO5l&;>X46qMkg{>`oppgJ|v0wB;%tWL`pECu#(Z8sE2Xm z#u_IQz=y*b|1s5hO%s@f0lqeY56B8VlY`ZF!e=+gfuA;YD(ybd9q{N&w*aKz(@#D9 zV?{37v<+@CYh74@%s@m4ehb7!w6bmPWr0Z4VyFD6k#ZyWJYth}w&9jwBwT;p_37DP zJZn3w8l^{g2OYrJa*m&e!^|4k_4DVug)3qruGvR5i`pVN@UATvUn~lFF$|y=W2JBO)lIYqzZZr3;S8s1AMmn zVL=ZWGR(v<&)|JN#>c+2b14vu4PFF6ybLmmN+O$AotPULjcx-h;?>zkQ z&qTBin@KE*FyjanQHJ;;p)%^syLz6LLj;T@hMOS>;VA%v(;(Kn-t{g6 zW{rr0n0#4)Zr;4fB*EL>`c^Xzh~xkqFOpzrKyY3J;hlxIstnw~Vk|qn%(<4q!KNeJ z4g9TR@*B5leGl$poev$(M^>>&XU@FJA}xdnS3dQmbRL#2do-K1T7I5zhPE2`V>)0Jej2`-iv?+W5m;8rZsBLJ!O zv!DI!1v!-8l}Wx-gC4d)A{8-;@3&BvC(0fl2X_J@{p(Nu)c}w#H7k&v%;+)1%(0`# z#hp{qRIPy14ncvw(iIRHs}xX}spvr*VhpEk+qR{5{o1>$; zQ6ntuC)Z(x;<0ZkkFpd|y1+?~0i-QrapK0mz3G;lcI&&Fb0qf%tzNa-EH{=ta9%N$ z*%N|%C$hro16CR?2^J#0&#Rh^&piE%aVQ){;F$O((Ry45zPPkaBA4&A;9s#cGfn3> zzJ#Z+bny>)I|#A7SwRP)Lv!UjZ4!|#S5ybiV}}_;#QOcz?|w>urSqHV-S@m(9JbZK zMIWzTwJH;@hwRhG{wKey3{aMeaRFFnL(HMu&~;2Ej2?m++F)b`-JL{QWPzf0c@JXo zqQw@#z%pO4;zBIv?7!>HZ~mh%{Mny&WM%Ld$ znq_CCkB0BqxpNqLIem4TxZ_bR|mgan6`5Er5r9e~(l0}-A#b%^S4 zpZzIt1m-)hVi8QHEDr7i`XUn+C>9@44Pu<=WUNB0V{{GW{zV&#Fx?4Hp&#M)x8HrY zNe;v{A-H|7?n~EPeXaV*-=~nWU;Mxa zKJe4WS3JJ9va))RA~%I6o_O4xSN1zDk_Cupg!9-WthZ{+tzgz7YQFQG?-=pfjf{bU zn*g^EIwP>mYsUOZ8jKdiDQlIFJ@#n&o!|K#0~cKh=&UHf$%s)LdE6Gb?5M8qyYGF* zEr`O21-&S;W4m!DTPRtW9V0zQS=~b zM{y)kPyr^+6_#TRIQ2wn2udJ^Wnr$6)%m5BD_6d6*UnuJ%T2=ceM9LD0~k*SNclHw zwEgV?85<9XsB7=K`)!Tew!BjJ>i+%1h^pUp*IOjMzF`~$5GZtX0UxjdJi9Y)lRA}+=W`)AOl5EVlMs}tj7iS!%p%56(-OD{-FA!66AU1Rbku{=M;J!xwjM-}yeB{~uYw&;3UL7j)NS ze>Lfk|M-s=@Fa<%#S*i4e%>p{aX6?$3AZBf=@m5w7DkuSQsXRwBW{n{D*%`?|%2Y^)=od7A)X1 z{pcws2*6-5qxT5UAzIOC-~84$Y~QPg2>rbfxQ5B4302?R%!VwFUuvbHcKf!STW`B< zF;Zf;q(sNo?OS(C3e|t%^IvF?G+3+Em|a%km;CGh0+%(I1bXdnX#fBK07*qoM6N<$ Ef(QA;ZvX%Q literal 0 HcmV?d00001 diff --git a/apps/vscode/webview-ui/src/App.tsx b/apps/vscode/webview-ui/src/App.tsx new file mode 100644 index 0000000000..2e337d7d12 --- /dev/null +++ b/apps/vscode/webview-ui/src/App.tsx @@ -0,0 +1,92 @@ +import { useEffect } from "react"; +import { Header } from "./components/Header"; +import { ChatArea } from "./components/ChatArea"; +import { InputArea } from "./components/inputarea/InputArea"; +import { ApprovalDialog } from "./components/ApprovalDialog"; +import { MCPServersModal } from "./components/MCPServersModal"; +import { ConfigErrorScreen } from "./components/ConfigErrorScreen"; +import { Toaster, toast } from "./components/ui/sonner"; +import { useChatStore, useSettingsStore } from "./stores"; +import { bridge, Events } from "./services"; +import { useAppInit } from "./hooks/useAppInit"; +import { isPreflightError, getUserMessage } from "shared/errors"; +import type { UIStreamEvent, StreamError } from "shared/types"; +import type { MCPServerConfig } from "@moonshot-ai/kimi-code-vscode-agent-sdk"; +import "./styles/index.css"; + +function MainContent() { + const enableNewConversationShortcut = useSettingsStore((state) => state.extensionConfig.enableNewConversationShortcut); + + useEffect(() => { + return bridge.on(Events.StreamEvent, (event: UIStreamEvent) => { + useChatStore.getState().processEvent(event); + + // Pre-flight 错误显示 Toast + if (event.type === "error") { + const streamError = event as StreamError; + const code = streamError.code || "UNKNOWN"; + + if (isPreflightError(code)) { + const message = getUserMessage(code, streamError.message); + toast.error(message); + } + } + }); + }, []); + + useEffect(() => { + const unsubs = [ + bridge.on(Events.MCPServersChanged, (servers) => useSettingsStore.getState().setMCPServers(servers)), + bridge.on(Events.FocusInput, () => document.querySelector("textarea")?.focus()), + bridge.on(Events.NewConversation, () => useChatStore.getState().startNewConversation()), + ]; + return () => unsubs.forEach((u) => u()); + }, []); + + useEffect(() => { + if (!enableNewConversationShortcut) { + return; + } + const handler = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === "n") { + e.preventDefault(); + useChatStore.getState().startNewConversation(); + } + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [enableNewConversationShortcut]); + + return ( + <> +
+ +
+ + + + + ); +} + +export default function App() { + const { status, errorType, errorMessage } = useAppInit(); + + if (status !== "ready") { + return ( +
+
+ + +
+ ); + } + + return ( +
+
+ + +
+ ); +} diff --git a/apps/vscode/webview-ui/src/components/ActionMenu.tsx b/apps/vscode/webview-ui/src/components/ActionMenu.tsx new file mode 100644 index 0000000000..dd3beeb351 --- /dev/null +++ b/apps/vscode/webview-ui/src/components/ActionMenu.tsx @@ -0,0 +1,59 @@ +import { useState } from "react"; +import { IconRefresh, IconSettings, IconServer } from "@tabler/icons-react"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Button } from "@/components/ui/button"; +import { Separator } from "@/components/ui/separator"; +import { useSettingsStore } from "@/stores"; +import { bridge } from "@/services"; +import { cn } from "@/lib/utils"; + +export function ActionMenu({ className }: { className?: string }) { + const [open, setOpen] = useState(false); + const setMCPModalOpen = useSettingsStore((state) => state.setMCPModalOpen); + + const handleReloadPlugin = () => { + // Reload only the Kimi panel — re-reads config.toml (models), MCP and + // extension config — without reloading the whole VS Code window. + bridge.reloadPlugin(); + setOpen(false); + }; + + const handleOpenSettings = () => { + bridge.openSettings(); + setOpen(false); + }; + + const handleOpenMCPServers = () => { + setMCPModalOpen(true); + setOpen(false); + }; + + return ( + + + + + + + + + + + + ); +} diff --git a/apps/vscode/webview-ui/src/components/ApprovalDialog.tsx b/apps/vscode/webview-ui/src/components/ApprovalDialog.tsx new file mode 100644 index 0000000000..951dbdf84a --- /dev/null +++ b/apps/vscode/webview-ui/src/components/ApprovalDialog.tsx @@ -0,0 +1,177 @@ +import { useState, useRef, useLayoutEffect, useMemo, useEffect } from "react"; +import { IconChevronDown, IconChevronUp } from "@tabler/icons-react"; +import { useShallow } from "zustand/react/shallow"; +import { useChatStore } from "@/stores"; +import { DisplayBlocks } from "./DisplayBlocks"; +import { cn } from "@/lib/utils"; +import { displayBlocksToLegacy } from "@/lib/display-block-adapter"; +import type { DisplayApprovalOption } from "@moonshot-ai/kimi-code-vscode-display-model"; +import type { ApprovalResponse } from "@moonshot-ai/kimi-code-vscode-agent-sdk/schema"; + +type DialogOption = + | { type: "legacy"; key: ApprovalResponse; label: string; index: number } + | { type: "dynamic"; option: DisplayApprovalOption; index: number }; + +function ApprovalDialogContent() { + const { pending, respondApproval } = useChatStore( + useShallow((state) => ({ + pending: state.displayState.pendingApprovals, + respondApproval: state.respondApproval, + })), + ); + const [selectedIndex, setSelectedIndex] = useState(1); + const [expanded, setExpanded] = useState(false); + const containerRef = useRef(null); + const [collapsedRect, setCollapsedRect] = useState<{ + height: number; + bottom: number; + } | null>(null); + + useLayoutEffect(() => { + if (!expanded && containerRef.current) { + const rect = containerRef.current.getBoundingClientRect(); + setCollapsedRect({ + height: rect.height, + bottom: window.innerHeight - rect.bottom, + }); + } + }, [expanded, pending]); + + const req = pending[0]; + const hasDisplay = req.displayBlocks && req.displayBlocks.length > 0; + + const handleLegacyResponse = async (response: ApprovalResponse) => { + await respondApproval(req.requestId, response); + setSelectedIndex(1); + setExpanded(false); + }; + + const handleDynamicResponse = async (optionId: string) => { + await respondApproval(req.requestId, { optionId }); + setSelectedIndex(1); + setExpanded(false); + }; + + const options: DialogOption[] = useMemo(() => { + if (req.options && req.options.length > 0) { + return req.options.map((option, idx) => ({ type: "dynamic", option, index: idx + 1 })); + } + return [ + { type: "legacy", key: "approve", label: "Allow once", index: 1 }, + { type: "legacy", key: "approve_for_session", label: "Allow always", index: 2 }, + { type: "legacy", key: "reject", label: "Reject", index: 3 }, + ]; + }, [req.options]); + + const selectOption = (opt: DialogOption) => (opt.type === "dynamic" ? handleDynamicResponse(opt.option.optionId) : handleLegacyResponse(opt.key)); + + // Keyboard interaction: number keys jump straight to an option (mirrors the + // CLI), arrows move the highlight, Enter confirms it. Ignore keys typed into + // the message input so composing a queued message never triggers approval. + useEffect(() => { + const onKeyDown = (e: KeyboardEvent) => { + const target = e.target as HTMLElement | null; + if (target && (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable)) { + return; + } + + if (e.key >= "1" && e.key <= "9") { + const opt = options[Number(e.key) - 1]; + if (opt) { + e.preventDefault(); + void selectOption(opt); + } + return; + } + + if (e.key === "ArrowDown") { + e.preventDefault(); + setSelectedIndex((i) => Math.min(i + 1, options.length)); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + setSelectedIndex((i) => Math.max(i - 1, 1)); + } else if (e.key === "Enter") { + const opt = options[selectedIndex - 1]; + if (opt) { + e.preventDefault(); + void selectOption(opt); + } + } + }; + + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + // selectOption closes over req/options; re-bind when the pending request changes. + }, [options, selectedIndex, req.requestId]); + + const title = req.options && req.options.length > 0 ? "Review this request?" : `Allow this ${req.action.toLowerCase()}?`; + + const content = ( +
+
+
{title}
+ {hasDisplay && ( + + )} +
+ +
+
{req.description}
+ + {hasDisplay && } + +
{req.sender}
+
+ +
+ {options.map((opt) => { + const key = opt.type === "dynamic" ? opt.option.optionId : opt.key; + const label = opt.type === "dynamic" ? opt.option.name : opt.label; + return ( + + ); + })} +
+
+ ); + + if (expanded && collapsedRect) { + return ( + <> +
+
+ {content} +
+ + ); + } + + return ( +
+ {content} +
+ ); +} + +export function ApprovalDialog() { + const hasPending = useChatStore((state) => state.displayState.pendingApprovals.length > 0); + if (!hasPending) return null; + return ; +} diff --git a/apps/vscode/webview-ui/src/components/ChatArea.tsx b/apps/vscode/webview-ui/src/components/ChatArea.tsx new file mode 100644 index 0000000000..b8529fc268 --- /dev/null +++ b/apps/vscode/webview-ui/src/components/ChatArea.tsx @@ -0,0 +1,63 @@ +import ScrollToBottom, { useScrollToBottom, useSticky } from "react-scroll-to-bottom"; +import { IconArrowDown } from "@tabler/icons-react"; +import { useShallow } from "zustand/react/shallow"; +import { ChatMessage } from "./ChatMessage"; +import { WelcomeScreen } from "./WelcomeScreen"; +import { useChatStore } from "@/stores"; +import { cn } from "@/lib/utils"; + +function ScrollButton() { + const scrollToBottom = useScrollToBottom(); + const [sticky] = useSticky(); + + if (sticky) return null; + + return ( + + ); +} + +function MessageList() { + const { messages, isStreaming } = useChatStore( + useShallow((state) => ({ + messages: state.displayState.messages, + isStreaming: state.displayState.isStreaming, + })), + ); + + return ( + <> +
+ {messages.map((message, idx) => ( + + ))} +
+ + + ); +} + +export function ChatArea() { + const hasMessages = useChatStore((state) => state.displayState.messages.length > 0); + + if (!hasMessages) { + return ( +
+ +
+ ); + } + + return ( +
+ + + +
+ ); +} diff --git a/apps/vscode/webview-ui/src/components/ChatMessage.tsx b/apps/vscode/webview-ui/src/components/ChatMessage.tsx new file mode 100644 index 0000000000..d2eaeccc7a --- /dev/null +++ b/apps/vscode/webview-ui/src/components/ChatMessage.tsx @@ -0,0 +1,271 @@ +import { memo, useState } from "react"; +import { IconLoader3 } from "@tabler/icons-react"; +import { cn } from "@/lib/utils"; +import { Markdown } from "./Markdown"; +import { DisplayToolCallCard } from "./ToolRenderers"; +import { getDisplayPartCopyContent, type DisplayMediaPart, type DisplayMessage, type DisplayPart, type DisplayStep } from "@moonshot-ai/kimi-code-vscode-display-model"; +import { CopyButton } from "./CopyButton"; +import { ThinkingBlock } from "./ThinkingBlock"; +import { CompactionCard } from "./CompactionCard"; +import { MediaThumbnail } from "./MediaThumbnail"; +import { MediaPreviewModal } from "./MediaPreviewModal"; +import { InlineError } from "./InlineError"; +import { PlanBlock } from "./PlanBlock"; +import { useChatStore } from "@/stores"; + +interface ChatMessageProps { + message: DisplayMessage; + isStreaming?: boolean; +} + +function ThinkingIndicator() { + return ( +
+ + Processing... +
+ ); +} + +function isVisibleDisplayPart(part: DisplayPart): boolean { + return part.type !== "approval" && part.type !== "interrupt" && part.type !== "status"; +} + +function visibleDisplayParts(parts: DisplayPart[]): DisplayPart[] { + return parts.filter(isVisibleDisplayPart); +} + +function DisplayPartContent({ part, isStreaming }: { part: DisplayPart; isStreaming?: boolean }) { + switch (part.type) { + case "thinking": + return ; + case "text": + return ; + case "media": + return ; + case "plan": + return ; + case "tool-call": + return ; + case "compaction": + return ; + case "error": + return !isStreaming ? : null; + default: + return null; + } +} + +function DisplayMediaPartContent({ part }: { part: DisplayMediaPart }) { + const [previewMedia, setPreviewMedia] = useState(null); + + if (part.kind === "audio") { + return