From d6525227db698ce8f6d7af027f600b04b38af037 Mon Sep 17 00:00:00 2001 From: Pidbid Date: Tue, 28 Jul 2026 14:27:59 +0800 Subject: [PATCH 1/2] feat: port portable upstream fixes and multi-skill prompts Curate and adapt 30 generally useful fixes from MoonshotAI/kimi-code, then add atomic inline multi-skill prompts across the CLI, SDK, and both agent engines. --- .changeset/anthropic-max-tokens-fallback.md | 5 + .changeset/bound-git-diff-output.md | 5 + .changeset/calm-usage-count.md | 5 + .changeset/clean-catalog-payloads.md | 5 + .changeset/fix-acp-multi-question-prompts.md | 5 + .../fix-bun-global-install-detection.md | 5 + .changeset/fix-headless-signal-output.md | 5 + .changeset/fix-interrupted-thinking-resume.md | 7 + .changeset/fix-kimi-mcp-required-schema.md | 5 + .changeset/git-status-untracked-all.md | 5 + .changeset/globstar-zero-directories.md | 5 + .changeset/inline-multi-skill-prompts.md | 6 + .changeset/mcp-http1-remote-transports.md | 5 + .changeset/pi-tui-above-viewport-inplace.md | 5 + .changeset/quiet-pandas-rest.md | 5 + .changeset/quiet-terminal-exits.md | 5 + .changeset/skill-load-failed-warning.md | 8 + .changeset/skill-load-warning.md | 6 + .changeset/skip-legacy-wire-records.md | 5 + .changeset/steady-session-metadata.md | 5 + .changeset/stream-agent-task-events.md | 5 + .changeset/stream-wire-jsonl-reads.md | 5 + .changeset/strict-teams-attend.md | 5 + .changeset/tidy-rivers-merge.md | 5 + .changeset/tool-args-type-coercion.md | 5 + .changeset/tui-usage-flicker.md | 5 + .changeset/windows-git-bash-path-bridge.md | 5 + .gitignore | 1 + apps/kimi-code/src/cli/run-prompt.ts | 30 +- apps/kimi-code/src/cli/run-shell.ts | 33 ++- apps/kimi-code/src/cli/update/source.ts | 6 +- apps/kimi-code/src/cli/v2/run-v2-print.ts | 21 +- apps/kimi-code/src/tui/commands/dispatch.ts | 10 + apps/kimi-code/src/tui/commands/skills.ts | 35 ++- .../editor/file-mention-provider.ts | 102 +++++++ apps/kimi-code/src/tui/index.ts | 2 +- apps/kimi-code/src/tui/kimi-tui.ts | 74 ++++- apps/kimi-code/src/tui/types.ts | 1 + .../src/utils/clipboard/clipboard-image.ts | 49 ++-- apps/kimi-code/test/cli/run-prompt.test.ts | 101 +++++++ apps/kimi-code/test/cli/run-shell.test.ts | 106 +++++++ apps/kimi-code/test/cli/update/source.test.ts | 27 +- .../test/tui/commands/skills.test.ts | 30 +- .../editor/file-mention-provider.test.ts | 60 ++++ .../test/tui/kimi-tui-message-flow.test.ts | 36 +++ .../test/tui/signal-handlers.test.ts | 75 ++++- .../utils/clipboard/clipboard-image.test.ts | 73 +++++ apps/vscode/src/KimiWebviewProvider.ts | 5 +- docs/en/reference/tools.md | 2 +- docs/zh/reference/tools.md | 2 +- packages/acp-adapter/src/question.ts | 105 +++---- packages/acp-adapter/src/session.ts | 179 ++++++++---- packages/acp-adapter/test/question.test.ts | 37 ++- .../test/session-question-handler.test.ts | 154 ++++++++-- .../agent-core-v2/docs/state-manifest.d.ts | 2 +- .../src/_base/execEnv/globPattern.ts | 20 +- .../src/_base/execEnv/shellPathBridge.ts | 166 +++++++++++ .../agent-core-v2/src/_base/utils/abort.ts | 21 +- .../agent/llmRequester/llmRequesterService.ts | 43 ++- .../src/agent/mcp/client-http.ts | 4 +- .../src/agent/mcp/client-remote.ts | 36 +++ .../agent-core-v2/src/agent/mcp/client-sse.ts | 4 +- .../src/agent/mcp/client-stdio.ts | 80 +++++- .../agent-core-v2/src/agent/rpc/core-api.ts | 7 + .../src/agent/rpc/prompt-metadata.ts | 3 + .../agent-core-v2/src/agent/rpc/rpcService.ts | 2 +- .../agent-core-v2/src/agent/skill/skill.ts | 10 +- .../src/agent/skill/skillService.ts | 78 +++-- .../agent/toolExecutor/toolExecutorService.ts | 48 +++- .../ask-user-question/askUserQuestionTool.ts | 15 +- .../src/agent/tools/os/bash/bashTool.ts | 18 +- .../agent-core-v2/src/app/git/gitService.ts | 100 +++++-- .../app/skillCatalog/fileSkillDiscovery.ts | 7 + .../src/kosong/contract/errors.ts | 28 ++ .../provider/bases/anthropic/anthropic.ts | 19 +- .../provider/bases/openai/openai-common.ts | 2 +- .../provider/bases/openai/openai-responses.ts | 2 +- .../provider/providers/kimi/kimi-schema.ts | 19 ++ .../src/session/question/question.ts | 1 + .../agent-core-v2/src/tool/args-normalize.ts | 206 ++++++++++++++ .../agent-core-v2/src/tool/args-validator.ts | 30 +- .../agent-core-v2/src/tool/path-access.ts | 44 ++- .../agent-core-v2/src/wire/wireService.ts | 15 +- .../test/_base/execEnv/globPattern.test.ts | 62 ++++ .../_base/execEnv/shellPathBridge.test.ts | 268 ++++++++++++++++++ .../test/_base/utils/abort.test.ts | 33 +++ .../llmRequester/llmRequesterService.test.ts | 92 ++++++ .../test/agent/mcp/client-http.test.ts | 38 ++- .../test/agent/mcp/client-stdio.test.ts | 63 +++- .../agent-core-v2/test/agent/mcp/stubs.ts | 139 +++++++++ .../questionTools/tools/ask-user.test.ts | 23 ++ .../test/agent/skill/skill.test.ts | 45 +++ .../agent/toolExecutor/toolExecutor.test.ts | 86 ++++++ .../test/app/git/gitService.test.ts | 21 +- .../skillCatalog/fileSkillDiscovery.test.ts | 24 ++ .../test/kosong/contract/errors.test.ts | 28 ++ .../test/kosong/provider/composition.test.ts | 81 +++++- .../providers/kimi/kimi-schema.test.ts | 82 ++++++ .../test/tool/args-normalize.test.ts | 176 ++++++++++++ .../test/tool/args-validator.test.ts | 36 +++ .../test/tool/path-access.test.ts | 56 +++- .../test/wire/wire-compat.test.ts | 57 ++++ .../src/agent/background/agent-task.ts | 75 +++++ .../agent-core/src/agent/background/index.ts | 13 +- .../agent-core/src/agent/context/index.ts | 21 +- packages/agent-core/src/agent/index.ts | 24 ++ packages/agent-core/src/agent/skill/index.ts | 43 ++- packages/agent-core/src/mcp/client-http.ts | 5 +- packages/agent-core/src/mcp/client-remote.ts | 42 +++ packages/agent-core/src/mcp/client-sse.ts | 5 +- packages/agent-core/src/mcp/client-stdio.ts | 86 +++++- packages/agent-core/src/rpc/core-api.ts | 7 + packages/agent-core/src/rpc/sdk-api.ts | 1 + .../src/services/fs/fsGitService.ts | 67 ++++- .../src/services/message/transcript.ts | 27 +- .../src/services/question/question.ts | 2 +- .../src/session/export/wire-scan.ts | 75 ++--- packages/agent-core/src/session/index.ts | 52 +++- .../agent-core/src/session/prompt-metadata.ts | 3 + .../src/session/store/session-store.ts | 11 +- .../agent-core/src/session/subagent-host.ts | 25 +- packages/agent-core/src/skill/registry.ts | 14 +- packages/agent-core/src/skill/scanner.ts | 69 +++-- .../src/tools/builtin/collaboration/agent.ts | 10 +- .../tools/builtin/collaboration/ask-user.ts | 15 +- .../src/tools/builtin/shell/bash.ts | 19 +- .../src/tools/policies/path-access.ts | 49 ++-- packages/agent-core/src/utils/fs.ts | 27 +- .../background/agent-task-events.test.ts | 185 ++++++++++++ packages/agent-core/test/agent/events.test.ts | 49 ++++ packages/agent-core/test/agent/resume.test.ts | 44 +++ .../test/harness/model-alias-session.test.ts | 14 +- .../test/harness/skill-session.test.ts | 99 +++++++ .../agent-core/test/mcp/client-http.test.ts | 196 +++++++++++++ .../agent-core/test/mcp/client-stdio.test.ts | 85 +++++- .../test/services/fs-git-service.test.ts | 48 +++- .../test/services/question-adapter.test.ts | 2 + packages/agent-core/test/session/init.test.ts | 38 +++ .../agent-core/test/session/store.test.ts | 26 +- .../test/session/subagent-host.test.ts | 40 ++- .../agent-core/test/skill/registry.test.ts | 100 ++++++- .../agent-core/test/skill/scanner.test.ts | 75 +++++ .../agent-core/test/tools/ask-user.test.ts | 23 ++ .../agent-core/test/tools/path-guard.test.ts | 55 ++++ packages/kaos/src/index.ts | 6 + packages/kaos/src/internal.ts | 34 ++- packages/kaos/src/kaos.ts | 11 + packages/kaos/src/local.ts | 50 +++- packages/kaos/src/shell-path-bridge.ts | 188 ++++++++++++ packages/kaos/src/ssh.ts | 130 ++++++++- packages/kaos/test/internal.test.ts | 58 +++- packages/kaos/test/local.test.ts | 29 +- packages/kaos/test/shell-path-bridge.test.ts | 267 +++++++++++++++++ packages/kaos/test/ssh.test.ts | 148 +++++++++- packages/kap-server/src/routes/questions.ts | 7 +- packages/kap-server/src/routes/sessions.ts | 26 +- .../src/services/snapshot/snapshotReader.ts | 65 +++-- packages/kap-server/test/questions.test.ts | 6 +- packages/kap-server/test/sessions.test.ts | 24 ++ packages/kosong/src/errors.ts | 32 +++ packages/kosong/src/providers/anthropic.ts | 21 +- .../kosong/src/providers/openai-common.ts | 2 +- .../kosong/src/providers/openai-legacy.ts | 48 +++- .../kosong/src/providers/openai-responses.ts | 2 +- packages/kosong/test/anthropic.test.ts | 26 ++ packages/kosong/test/errors.test.ts | 38 +++ packages/kosong/test/kimi.test.ts | 13 + packages/kosong/test/openai-legacy.test.ts | 192 ++++++++++++- packages/kosong/test/openai-responses.test.ts | 26 ++ packages/node-sdk/src/catalog.ts | 14 +- packages/node-sdk/src/rpc.ts | 7 + packages/node-sdk/src/session.ts | 26 ++ packages/node-sdk/test/catalog.test.ts | 22 ++ packages/node-sdk/test/session-skills.test.ts | 17 ++ packages/pi-tui/AGENTS.md | 4 +- packages/pi-tui/src/terminal.ts | 32 ++- packages/pi-tui/src/tui.ts | 79 +++++- packages/pi-tui/test/terminal.test.ts | 26 +- packages/pi-tui/test/tui-render.test.ts | 131 +++++++++ 179 files changed, 7000 insertions(+), 611 deletions(-) create mode 100644 .changeset/anthropic-max-tokens-fallback.md create mode 100644 .changeset/bound-git-diff-output.md create mode 100644 .changeset/calm-usage-count.md create mode 100644 .changeset/clean-catalog-payloads.md create mode 100644 .changeset/fix-acp-multi-question-prompts.md create mode 100644 .changeset/fix-bun-global-install-detection.md create mode 100644 .changeset/fix-headless-signal-output.md create mode 100644 .changeset/fix-interrupted-thinking-resume.md create mode 100644 .changeset/fix-kimi-mcp-required-schema.md create mode 100644 .changeset/git-status-untracked-all.md create mode 100644 .changeset/globstar-zero-directories.md create mode 100644 .changeset/inline-multi-skill-prompts.md create mode 100644 .changeset/mcp-http1-remote-transports.md create mode 100644 .changeset/pi-tui-above-viewport-inplace.md create mode 100644 .changeset/quiet-pandas-rest.md create mode 100644 .changeset/quiet-terminal-exits.md create mode 100644 .changeset/skill-load-failed-warning.md create mode 100644 .changeset/skill-load-warning.md create mode 100644 .changeset/skip-legacy-wire-records.md create mode 100644 .changeset/steady-session-metadata.md create mode 100644 .changeset/stream-agent-task-events.md create mode 100644 .changeset/stream-wire-jsonl-reads.md create mode 100644 .changeset/strict-teams-attend.md create mode 100644 .changeset/tidy-rivers-merge.md create mode 100644 .changeset/tool-args-type-coercion.md create mode 100644 .changeset/tui-usage-flicker.md create mode 100644 .changeset/windows-git-bash-path-bridge.md create mode 100644 packages/agent-core-v2/src/_base/execEnv/shellPathBridge.ts create mode 100644 packages/agent-core-v2/src/tool/args-normalize.ts create mode 100644 packages/agent-core-v2/test/_base/execEnv/globPattern.test.ts create mode 100644 packages/agent-core-v2/test/_base/execEnv/shellPathBridge.test.ts create mode 100644 packages/agent-core-v2/test/kosong/provider/providers/kimi/kimi-schema.test.ts create mode 100644 packages/agent-core-v2/test/tool/args-normalize.test.ts create mode 100644 packages/agent-core/test/agent/background/agent-task-events.test.ts create mode 100644 packages/agent-core/test/agent/events.test.ts create mode 100644 packages/kaos/src/shell-path-bridge.ts create mode 100644 packages/kaos/test/shell-path-bridge.test.ts diff --git a/.changeset/anthropic-max-tokens-fallback.md b/.changeset/anthropic-max-tokens-fallback.md new file mode 100644 index 0000000000..71c9e64db6 --- /dev/null +++ b/.changeset/anthropic-max-tokens-fallback.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix 400 rejections from Anthropic-compatible providers that enforce a lower max_tokens output limit than the default 128000 fallback. Custom endpoints now use a conservative 32768 default, and a rejected request is automatically retried with the provider's declared limit. diff --git a/.changeset/bound-git-diff-output.md b/.changeset/bound-git-diff-output.md new file mode 100644 index 0000000000..1f69c50410 --- /dev/null +++ b/.changeset/bound-git-diff-output.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Limit file diff output by UTF-8 byte size while it is read so large changes no longer cause excessive memory use. diff --git a/.changeset/calm-usage-count.md b/.changeset/calm-usage-count.md new file mode 100644 index 0000000000..64c1ef96d6 --- /dev/null +++ b/.changeset/calm-usage-count.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Prevent partial OpenAI-compatible usage reports from producing negative uncached input token counts. diff --git a/.changeset/clean-catalog-payloads.md b/.changeset/clean-catalog-payloads.md new file mode 100644 index 0000000000..fb3c2a1ff8 --- /dev/null +++ b/.changeset/clean-catalog-payloads.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code-sdk": patch +--- + +Reject invalid catalog payloads consistently. diff --git a/.changeset/fix-acp-multi-question-prompts.md b/.changeset/fix-acp-multi-question-prompts.md new file mode 100644 index 0000000000..3acbe22bd9 --- /dev/null +++ b/.changeset/fix-acp-multi-question-prompts.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix ACP clients dropping questions after the first prompt and preserve multi-select answers and skipped-question notes. diff --git a/.changeset/fix-bun-global-install-detection.md b/.changeset/fix-bun-global-install-detection.md new file mode 100644 index 0000000000..296072b9f3 --- /dev/null +++ b/.changeset/fix-bun-global-install-detection.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix install-source detection for bun global installs by recognizing the `~/.bun/node_modules/` layout used by recent bun versions. diff --git a/.changeset/fix-headless-signal-output.md b/.changeset/fix-headless-signal-output.md new file mode 100644 index 0000000000..35be4e0704 --- /dev/null +++ b/.changeset/fix-headless-signal-output.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Preserve buffered headless output when a signal arrives during prompt cleanup. diff --git a/.changeset/fix-interrupted-thinking-resume.md b/.changeset/fix-interrupted-thinking-resume.md new file mode 100644 index 0000000000..f748fe8845 --- /dev/null +++ b/.changeset/fix-interrupted-thinking-resume.md @@ -0,0 +1,7 @@ +--- +"@moonshot-ai/agent-core": patch +"@moonshot-ai/kimi-code": patch +"@moonshot-ai/kosong": patch +--- + +Fix resumed sessions failing after an assistant response is interrupted during thinking. diff --git a/.changeset/fix-kimi-mcp-required-schema.md b/.changeset/fix-kimi-mcp-required-schema.md new file mode 100644 index 0000000000..785726edcc --- /dev/null +++ b/.changeset/fix-kimi-mcp-required-schema.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix Kimi-compatible APIs rejecting MCP tools with malformed required fields. diff --git a/.changeset/git-status-untracked-all.md b/.changeset/git-status-untracked-all.md new file mode 100644 index 0000000000..30897c4d43 --- /dev/null +++ b/.changeset/git-status-untracked-all.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/agent-core-v2": patch +--- + +Fix git status collapsing untracked directories: files created inside an untracked directory now appear individually in the Changes panel instead of being hidden behind the directory entry. diff --git a/.changeset/globstar-zero-directories.md b/.changeset/globstar-zero-directories.md new file mode 100644 index 0000000000..5807cf61ee --- /dev/null +++ b/.changeset/globstar-zero-directories.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix glob patterns with `**/` so they also match files in the starting directory. diff --git a/.changeset/inline-multi-skill-prompts.md b/.changeset/inline-multi-skill-prompts.md new file mode 100644 index 0000000000..d02cc98cd7 --- /dev/null +++ b/.changeset/inline-multi-skill-prompts.md @@ -0,0 +1,6 @@ +--- +"@moonshot-ai/kimi-code": minor +"@moonshot-ai/kimi-code-sdk": minor +--- + +Add atomic multi-Skill activation within a single prompt. Type multiple `/skill:` tokens in one message to activate them together. diff --git a/.changeset/mcp-http1-remote-transports.md b/.changeset/mcp-http1-remote-transports.md new file mode 100644 index 0000000000..364d0a62cb --- /dev/null +++ b/.changeset/mcp-http1-remote-transports.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix remote MCP servers over HTTP/2 hanging during startup: the stream and requests now use HTTP/1.1 connections. diff --git a/.changeset/pi-tui-above-viewport-inplace.md b/.changeset/pi-tui-above-viewport-inplace.md new file mode 100644 index 0000000000..011962fc47 --- /dev/null +++ b/.changeset/pi-tui-above-viewport-inplace.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/pi-tui": patch +--- + +Skip the destructive full redraw when lines above the viewport change in place, so scrollback and scroll position survive above-viewport status ticks. diff --git a/.changeset/quiet-pandas-rest.md b/.changeset/quiet-pandas-rest.md new file mode 100644 index 0000000000..a3279f44b2 --- /dev/null +++ b/.changeset/quiet-pandas-rest.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix Python-based MCP servers failing to start under HTTP proxies while preserving Node launchers' IPv6 loopback bypass. diff --git a/.changeset/quiet-terminal-exits.md b/.changeset/quiet-terminal-exits.md new file mode 100644 index 0000000000..d113a99c18 --- /dev/null +++ b/.changeset/quiet-terminal-exits.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Persist terminal stream failure diagnostics before emergency exits. diff --git a/.changeset/skill-load-failed-warning.md b/.changeset/skill-load-failed-warning.md new file mode 100644 index 0000000000..a80ffbb938 --- /dev/null +++ b/.changeset/skill-load-failed-warning.md @@ -0,0 +1,8 @@ +--- +"@moonshot-ai/kimi-code": patch +"@moonshot-ai/agent-core": patch +"@moonshot-ai/agent-core-v2": patch +"@moonshot-ai/kap-server": patch +--- + +Fix a skill with a `SKILL.md` that fails to parse (e.g. invalid frontmatter) being dropped from the skill list with no signal. A session-start warning now names the skill file and the reason it was not loaded, in both the CLI/TUI engine and `kimi web`/print mode. diff --git a/.changeset/skill-load-warning.md b/.changeset/skill-load-warning.md new file mode 100644 index 0000000000..4dafada185 --- /dev/null +++ b/.changeset/skill-load-warning.md @@ -0,0 +1,6 @@ +--- +"@moonshot-ai/agent-core": patch +"@moonshot-ai/kimi-code": patch +--- + +Surface a warning when a skill file fails to parse at session start, instead of dropping it silently. The warning names the offending file and appears in the TUI status line. diff --git a/.changeset/skip-legacy-wire-records.md b/.changeset/skip-legacy-wire-records.md new file mode 100644 index 0000000000..f26cb0e9a7 --- /dev/null +++ b/.changeset/skip-legacy-wire-records.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix spurious "Unknown wire record type" errors when restoring sessions whose history contains records the engine deliberately no longer replays, such as those from the retired micro-compaction experiment. diff --git a/.changeset/steady-session-metadata.md b/.changeset/steady-session-metadata.md new file mode 100644 index 0000000000..a6f9eb9682 --- /dev/null +++ b/.changeset/steady-session-metadata.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Write session metadata atomically to avoid unreadable sessions after interrupted updates. diff --git a/.changeset/stream-agent-task-events.md b/.changeset/stream-agent-task-events.md new file mode 100644 index 0000000000..238cadceaf --- /dev/null +++ b/.changeset/stream-agent-task-events.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Stream live subagent events into the background task output buffer. While a subagent launched through the `Agent` tool is still running, `TaskOutput` and the `/tasks` panel now show its turns, tool calls, and thinking/assistant text as they happen, instead of only the final summary after completion. diff --git a/.changeset/stream-wire-jsonl-reads.md b/.changeset/stream-wire-jsonl-reads.md new file mode 100644 index 0000000000..1ba70b5530 --- /dev/null +++ b/.changeset/stream-wire-jsonl-reads.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Read session wire logs line-by-line instead of loading whole files into memory, cutting peak memory when serving session snapshots, history transcripts, and debug exports of long sessions. diff --git a/.changeset/strict-teams-attend.md b/.changeset/strict-teams-attend.md new file mode 100644 index 0000000000..e63188f981 --- /dev/null +++ b/.changeset/strict-teams-attend.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/pi-tui": patch +--- + +fix(pi-tui): skip Kitty keyboard protocol on Windows Terminal to prevent Cyrillic doubling diff --git a/.changeset/tidy-rivers-merge.md b/.changeset/tidy-rivers-merge.md new file mode 100644 index 0000000000..ea80a31d6b --- /dev/null +++ b/.changeset/tidy-rivers-merge.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix streamed assistant replies rendering one token per line with OpenAI-compatible providers that pad empty reasoning fields into every chunk. diff --git a/.changeset/tool-args-type-coercion.md b/.changeset/tool-args-type-coercion.md new file mode 100644 index 0000000000..3ec3cb1563 --- /dev/null +++ b/.changeset/tool-args-type-coercion.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix tool calls failing validation when a model sends numeric or boolean arguments as strings. diff --git a/.changeset/tui-usage-flicker.md b/.changeset/tui-usage-flicker.md new file mode 100644 index 0000000000..8b9433a388 --- /dev/null +++ b/.changeset/tui-usage-flicker.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix the TUI flickering and losing scroll position when live agent status updates continue above the visible viewport, such as after opening /usage during a running task. diff --git a/.changeset/windows-git-bash-path-bridge.md b/.changeset/windows-git-bash-path-bridge.md new file mode 100644 index 0000000000..a2a1fc868f --- /dev/null +++ b/.changeset/windows-git-bash-path-bridge.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix file tools failing to open files referenced by Git Bash paths such as /tmp or /home on Windows. diff --git a/.gitignore b/.gitignore index e62f42128f..b517518fbe 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ coverage/ .vitest-results/ .vite/ .DS_Store +.idea/ .playwright-mcp/ .claude .conductor diff --git a/apps/kimi-code/src/cli/run-prompt.ts b/apps/kimi-code/src/cli/run-prompt.ts index abee299622..de09db1399 100644 --- a/apps/kimi-code/src/cli/run-prompt.ts +++ b/apps/kimi-code/src/cli/run-prompt.ts @@ -14,11 +14,13 @@ import { type SessionStatus, type TelemetryClient, } from '@moonshot-ai/kimi-code-sdk'; +import { Writable } from 'node:stream'; import { resolve } from 'pathe'; import { CLI_SHUTDOWN_TIMEOUT_MS, PROMPT_CLEANUP_TIMEOUT_MS } from '#/constant/app'; import { isKimiV2Enabled } from './experimental-v2'; +import { drainStdio } from './headless-exit'; import { resolveOutputFormat } from './options'; import type { CLIOptions, PromptOutputFormat } from './options'; import { @@ -113,6 +115,9 @@ export async function runPrompt( const stdout = io.stdout ?? process.stdout; const stderr = io.stderr ?? process.stderr; const promptProcess = io.process ?? process; + const streams = [stdout, stderr].filter( + (stream): stream is Writable => stream instanceof Writable, + ); const outputFormat = resolveOutputFormat(opts); const workDir = process.cwd(); const telemetryBootstrap = createCliTelemetryBootstrap(); @@ -146,9 +151,9 @@ export async function runPrompt( let restorePromptSessionPermission = async (): Promise => {}; let removeTerminationCleanup: (() => void) | undefined; let cleanupPromise: Promise | undefined; + let outputDrainPromise: Promise | undefined; const cleanupPromptRun = async (): Promise => { const pending = (cleanupPromise ??= (async () => { - removeTerminationCleanup?.(); setCrashPhase('shutdown'); try { await restorePromptSessionPermission(); @@ -162,9 +167,24 @@ export async function runPrompt( // keep a completed headless run alive forever. The cleanup keeps running in // the background if it overruns; the caller (`kimi -p`) force-exits shortly // after, so any straggling work is torn down with the process. - await raceWithTimeout(pending, PROMPT_CLEANUP_TIMEOUT_MS); + const cleanupOutcome = await raceWithTimeout(pending, PROMPT_CLEANUP_TIMEOUT_MS).then( + () => ({ ok: true }) as const, + (error: unknown) => ({ ok: false, error }) as const, + ); + await (outputDrainPromise ??= drainStdio(streams).finally(() => { + // Keep the signal handlers installed through async cleanup and output + // flushing. Removing them earlier restores Node's default immediate + // signal exit, which can discard the final assistant and resume hint. + removeTerminationCleanup?.(); + })); + if (!cleanupOutcome.ok) { + throw cleanupOutcome.error; + } }; - removeTerminationCleanup = installPromptTerminationCleanup(promptProcess, cleanupPromptRun); + removeTerminationCleanup = installPromptTerminationCleanup( + promptProcess, + cleanupPromptRun, + ); try { await harness.ensureConfigFile(); @@ -422,6 +442,10 @@ function installHeadlessHandlers(session: PromptSession): void { session.setQuestionHandler(() => null); } +/** + * Install one-shot signal handlers that clean up and flush queued output before + * forcing the process to exit with the signal's conventional exit code. + */ export function installPromptTerminationCleanup( promptProcess: PromptProcess, cleanup: () => Promise, diff --git a/apps/kimi-code/src/cli/run-shell.ts b/apps/kimi-code/src/cli/run-shell.ts index 7e0a8ce711..9c7f88e97a 100644 --- a/apps/kimi-code/src/cli/run-shell.ts +++ b/apps/kimi-code/src/cli/run-shell.ts @@ -22,7 +22,7 @@ import { detectPendingMigration } from '#/migration/index'; import type { TuiConfig } from '#/tui/config'; import { loadTuiConfig, TuiConfigParseError } from '#/tui/config'; import { CHROME_GUTTER } from '#/tui/constant/rendering'; -import { KimiTUI } from '#/tui/index'; +import { KimiTUI, type DeadTerminalErrorContext } from '#/tui/index'; import { currentTheme, getColorPalette } from '#/tui/theme'; import { combineStartupNotice } from '#/tui/utils/startup'; import { toTerminalHyperlink } from '#/utils/terminal-hyperlink'; @@ -121,6 +121,37 @@ export async function runShell( }); setCrashPhase('runtime'); + tui.onDeadTerminalError = (context: DeadTerminalErrorContext): void => { + try { + log.error('terminal output stream failed, restoring terminal and exiting', { + error: context.error, + stream: context.stream, + errorCode: context.error.code, + errno: context.error.errno, + syscall: context.error.syscall, + sessionId: context.sessionId, + turnId: context.turnId, + step: context.step, + streamingPhase: context.streamingPhase, + exitCode: 129, + stdoutIsTTY: process.stdout.isTTY, + stderrIsTTY: process.stderr.isTTY, + stdoutDestroyed: process.stdout.destroyed, + stderrDestroyed: process.stderr.destroyed, + pid: process.pid, + ppid: process.ppid, + }); + } catch { + /* ignore */ + } + try { + // The TUI calls process.exit() immediately after this callback returns. + flushDiagnosticLogsSync(); + } catch { + /* ignore */ + } + }; + const trackLifecycleForSession = ( sessionId: string, event: string, diff --git a/apps/kimi-code/src/cli/update/source.ts b/apps/kimi-code/src/cli/update/source.ts index 7d6904b673..2a896ce80c 100644 --- a/apps/kimi-code/src/cli/update/source.ts +++ b/apps/kimi-code/src/cli/update/source.ts @@ -39,7 +39,7 @@ export function detectNativeInstall(): boolean { // Path heuristic markers (compared in lowercase; both forward and backward slashes accepted). const PNPM_PATH_SEGMENT = 'pnpm/global/'; const YARN_PATH_SEGMENTS = ['.config/yarn/global/', '/.yarn/global/']; -const BUN_PATH_SEGMENT = '.bun/install/global/'; +const BUN_PATH_SEGMENTS = ['/.bun/install/global/', '/.bun/node_modules/']; // Homebrew installs formulae under its Cellar directory. Avoid matching the // broader /homebrew/ prefix — on Apple Silicon, npm itself lives under // /opt/homebrew/, so `npm install -g` paths also contain /homebrew/. @@ -60,7 +60,9 @@ export function classifyByPathHeuristic(packageRoot: string): InstallSource | nu for (const seg of YARN_PATH_SEGMENTS) { if (normalized.includes(seg)) return 'yarn-global'; } - if (normalized.includes(BUN_PATH_SEGMENT)) return 'bun-global'; + for (const seg of BUN_PATH_SEGMENTS) { + if (normalized.includes(seg)) return 'bun-global'; + } if (normalized.includes(HOMEBREW_PATH_SEGMENT)) return 'homebrew'; return null; } diff --git a/apps/kimi-code/src/cli/v2/run-v2-print.ts b/apps/kimi-code/src/cli/v2/run-v2-print.ts index ff0c96c590..a78ace8606 100644 --- a/apps/kimi-code/src/cli/v2/run-v2-print.ts +++ b/apps/kimi-code/src/cli/v2/run-v2-print.ts @@ -17,6 +17,7 @@ */ import { readFile } from 'node:fs/promises'; +import { Writable } from 'node:stream'; import { IAgentGoalService, @@ -73,6 +74,7 @@ import { parseHeadlessGoalCreate, type HeadlessGoalCreate, } from '../goal-prompt'; +import { drainStdio } from '../headless-exit'; import { type PromptRunIO, configuredModel, @@ -112,6 +114,9 @@ export async function runV2Print( const stdout = io.stdout ?? process.stdout; const stderr = io.stderr ?? process.stderr; const promptProcess = io.process ?? process; + const streams = [stdout, stderr].filter( + (stream): stream is Writable => stream instanceof Writable, + ); const outputFormat = resolveOutputFormat(opts); const workDir = process.cwd(); @@ -164,10 +169,10 @@ export async function runV2Print( let restorePermission = async (): Promise => {}; let removeTerminationCleanup: (() => void) | undefined; let cleanupPromise: Promise | undefined; + let outputDrainPromise: Promise | undefined; let telemetryService: ITelemetryService | undefined; const cleanup = async (): Promise => { const pending = (cleanupPromise ??= (async () => { - removeTerminationCleanup?.(); try { await restorePermission(); } finally { @@ -177,7 +182,19 @@ export async function runV2Print( app.dispose(); } })()); - await raceWithTimeout(pending, PROMPT_CLEANUP_TIMEOUT_MS); + const cleanupOutcome = await raceWithTimeout(pending, PROMPT_CLEANUP_TIMEOUT_MS).then( + () => ({ ok: true }) as const, + (error: unknown) => ({ ok: false, error }) as const, + ); + await (outputDrainPromise ??= drainStdio(streams).finally(() => { + // Keep the signal handlers installed through async cleanup and output + // flushing. Removing them earlier restores Node's default immediate + // signal exit, which can discard the final assistant and resume hint. + removeTerminationCleanup?.(); + })); + if (!cleanupOutcome.ok) { + throw cleanupOutcome.error; + } }; removeTerminationCleanup = installPromptTerminationCleanup(promptProcess, cleanup); diff --git a/apps/kimi-code/src/tui/commands/dispatch.ts b/apps/kimi-code/src/tui/commands/dispatch.ts index dcfb904733..6f09637893 100644 --- a/apps/kimi-code/src/tui/commands/dispatch.ts +++ b/apps/kimi-code/src/tui/commands/dispatch.ts @@ -45,6 +45,7 @@ import { handleProviderCommand } from './provider'; import type { BuiltinSlashCommandName } from './registry'; import { handleReloadCommand, handleReloadTuiCommand } from './reload'; import { resolveSlashCommandInput, slashBusyMessage } from './resolve'; +import { findInlineSkillNames } from './skills'; import { handleExportDebugZipCommand, handleExportMdCommand, @@ -207,6 +208,15 @@ async function executeSlashCommand(host: SlashCommandHost, input: string): Promi host.showError(LLM_NOT_SET_MESSAGE); return; } + const inlineSkillNames = findInlineSkillNames(input, host.skillCommandMap); + if (inlineSkillNames.length > 1) { + host.track('input_command', { + command: 'multi-skill', + skill_names: inlineSkillNames, + }); + host.sendNormalUserInput(input); + return; + } host.track('input_command', { command: intent.commandName, skill_name: intent.skillName, diff --git a/apps/kimi-code/src/tui/commands/skills.ts b/apps/kimi-code/src/tui/commands/skills.ts index 2997a8b151..fada3956ee 100644 --- a/apps/kimi-code/src/tui/commands/skills.ts +++ b/apps/kimi-code/src/tui/commands/skills.ts @@ -6,6 +6,7 @@ export type SkillListSession = Pick; export interface SkillSlashCommands { readonly commands: readonly KimiSlashCommand[]; + readonly inlineCommands: readonly KimiSlashCommand[]; readonly commandMap: ReadonlyMap; } @@ -32,17 +33,47 @@ function getSkillSlashCommandGroup(source: SkillSummary['source']): number { export function buildSkillSlashCommands(skills: readonly SkillSummary[]): SkillSlashCommands { const commandMap = new Map(); const sortedSkills = [...skills].toSorted(compareSkillSlashCommands); - const commands = sortedSkills.filter(isUserActivatableSkill).map((skill) => { + const activatableSkills = sortedSkills.filter(isUserActivatableSkill); + const commands = activatableSkills.map((skill) => { const commandName = skill.source === 'builtin' || skill.isSubSkill === true ? skill.name : `skill:${skill.name}`; commandMap.set(commandName, skill.name); + commandMap.set(`skill:${skill.name}`, skill.name); return { name: commandName, aliases: [], description: skill.description ?? '', }; }); - return { commands, commandMap }; + const inlineCommands = activatableSkills.map((skill) => ({ + name: `skill:${skill.name}`, + aliases: [], + description: skill.description ?? '', + })); + return { commands, inlineCommands, commandMap }; +} + +export function findInlineSkillNames( + input: string, + skillCommandMap: ReadonlyMap, +): readonly string[] { + const names: string[] = []; + const seen = new Set(); + const matches = input.matchAll( + /(?:^|[\t\n\r ])\/(skill:[A-Za-z0-9][A-Za-z0-9._-]*)(?=$|[\t\n\r ,.?!;()[\]{}])/g, + ); + for (const match of matches) { + let commandName = match[1]!; + let skillName = skillCommandMap.get(commandName); + while (skillName === undefined && commandName.endsWith('.')) { + commandName = commandName.slice(0, -1); + skillName = skillCommandMap.get(commandName); + } + if (skillName === undefined || seen.has(skillName)) continue; + seen.add(skillName); + names.push(skillName); + } + return names; } diff --git a/apps/kimi-code/src/tui/components/editor/file-mention-provider.ts b/apps/kimi-code/src/tui/components/editor/file-mention-provider.ts index 722682db60..9ecad3dc58 100644 --- a/apps/kimi-code/src/tui/components/editor/file-mention-provider.ts +++ b/apps/kimi-code/src/tui/components/editor/file-mention-provider.ts @@ -45,6 +45,7 @@ export class FileMentionProvider implements AutocompleteProvider { private readonly fdPath: string | null, additionalDirs: readonly string[] = [], private readonly getInputMode: () => 'prompt' | 'bash' = () => 'prompt', + private readonly inlineSkillCommands: readonly SlashAutocompleteCommand[] = [], ) { this.additionalDirs = additionalDirs.map((dir) => normalizePath(resolve(workDir, dir))); // Build an expanded list that includes alias entries so that @@ -100,6 +101,22 @@ export class FileMentionProvider implements AutocompleteProvider { } } + if ( + this.getInputMode() !== 'bash' && + options.force !== true && + this.inlineSkillCommands.length > 0 + ) { + const inlineSkillPrefix = extractInlineSkillPrefix(lines, cursorLine, cursorCol); + if (inlineSkillPrefix !== null) { + const slashArgumentSuggestions = await getSlashArgumentSuggestions( + this.slashCommands, + textBeforeCursor, + ); + if (slashArgumentSuggestions !== null) return slashArgumentSuggestions; + return getInlineSkillSuggestions(this.inlineSkillCommands, inlineSkillPrefix); + } + } + if (shouldSuppressLeadingWhitespaceSlashPath(textBeforeCursor, options.force)) { return null; } @@ -206,6 +223,12 @@ export class FileMentionProvider implements AutocompleteProvider { item: AutocompleteItem, prefix: string, ): { lines: string[]; cursorLine: number; cursorCol: number } { + if ( + this.getInputMode() !== 'bash' && + isInlineSkillCompletion(lines, cursorLine, cursorCol, prefix, this.inlineSkillCommands) + ) { + return applyInlineSkillCompletion(lines, cursorLine, cursorCol, item, prefix); + } // In bash mode a leading `/` is a path, but pi-tui's applyCompletion // mistakes it for a slash command (prefix starts with `/`, nothing before // it, no second `/`) and prepends another `/`, producing e.g. @@ -219,6 +242,85 @@ export class FileMentionProvider implements AutocompleteProvider { } } +export function extractInlineSkillPrefix( + lines: readonly string[], + cursorLine: number, + cursorCol: number, +): string | null { + const textBeforeCursor = (lines[cursorLine] ?? '').slice(0, cursorCol); + const match = textBeforeCursor.match(/(?:^|[\t ])(\/[^\t ]*)$/); + if (match === null) return null; + const prefix = match[1]!; + const prefixStart = textBeforeCursor.length - prefix.length; + if (cursorLine === 0 && prefixStart === 0) return null; + return prefix; +} + +function getInlineSkillSuggestions( + commands: readonly SlashAutocompleteCommand[], + prefix: string, +): AutocompleteSuggestions | null { + const query = prefix.slice(1); + const tokens = query + .trim() + .split(/\s+/) + .filter((token) => token.length > 0); + const matches = commands + .map((command) => ({ command, score: scoreTokens(tokens, command.name) })) + .filter( + ( + match, + ): match is { + command: SlashAutocompleteCommand; + score: number; + } => match.score !== null, + ) + .toSorted((a, b) => a.score - b.score); + if (matches.length === 0) return null; + return { + prefix, + items: matches.map(({ command }) => ({ + value: command.name, + label: command.name, + description: formatSlashCommandDescription(command), + })), + }; +} + +function isInlineSkillCompletion( + lines: readonly string[], + cursorLine: number, + cursorCol: number, + prefix: string, + commands: readonly SlashAutocompleteCommand[], +): boolean { + if (!prefix.startsWith('/')) return false; + const inlinePrefix = extractInlineSkillPrefix(lines, cursorLine, cursorCol); + if (inlinePrefix !== prefix) return false; + return commands.length > 0; +} + +function applyInlineSkillCompletion( + lines: string[], + cursorLine: number, + cursorCol: number, + item: AutocompleteItem, + prefix: string, +): { lines: string[]; cursorLine: number; cursorCol: number } { + const currentLine = lines[cursorLine] ?? ''; + const beforePrefix = currentLine.slice(0, cursorCol - prefix.length); + const replacement = `/${item.value} `; + const afterCursor = currentLine.slice(cursorCol); + const newLines = [...lines]; + newLines[cursorLine] = + beforePrefix + replacement + (afterCursor.startsWith(' ') ? afterCursor.slice(1) : afterCursor); + return { + lines: newLines, + cursorLine, + cursorCol: beforePrefix.length + replacement.length, + }; +} + export function extractAtPrefix(text: string): string | null { let tokenStart = 0; for (let i = text.length - 1; i >= 0; i -= 1) { diff --git a/apps/kimi-code/src/tui/index.ts b/apps/kimi-code/src/tui/index.ts index 06af194eca..5ae02866db 100644 --- a/apps/kimi-code/src/tui/index.ts +++ b/apps/kimi-code/src/tui/index.ts @@ -1,3 +1,3 @@ export { KimiTUI } from './kimi-tui'; -export type { KimiTUIStartupInput } from './kimi-tui'; +export type { DeadTerminalErrorContext, KimiTUIStartupInput } from './kimi-tui'; export type { KimiTUIOptions } from './types'; diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index f0764c82b9..5f57eb4bef 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -38,6 +38,7 @@ import { BUILTIN_SLASH_COMMANDS, buildPluginSlashCommands, buildSkillSlashCommands, + findInlineSkillNames, isExperimentalFlagEnabled, setExperimentalFeatures, sortSlashCommands, @@ -184,6 +185,15 @@ export interface KimiTUIStartupInput { readonly migrateOnly?: boolean; } +export interface DeadTerminalErrorContext { + readonly stream: 'stdout' | 'stderr'; + readonly error: NodeJS.ErrnoException; + readonly sessionId?: string; + readonly turnId?: string; + readonly step: number; + readonly streamingPhase: AppState['streamingPhase']; +} + type EffectiveActivityPaneMode = ActivityPaneMode | 'idle' | 'session'; type LoadingTipKind = 'moon' | 'composing'; @@ -243,6 +253,7 @@ interface SendMessageOptions { readonly parts?: readonly PromptPart[]; readonly imageAttachmentIds?: readonly number[]; readonly hasMedia?: boolean; + readonly skillNames?: readonly string[]; } /** @@ -301,6 +312,7 @@ export class KimiTUI { private readonly questionController = new QuestionController(); private readonly reverseRpcDisposers: Array<() => void> = []; private skillCommands: readonly KimiSlashCommand[] = []; + private inlineSkillCommands: readonly KimiSlashCommand[] = []; readonly skillCommandMap = new Map(); private pluginCommands: readonly KimiSlashCommand[] = []; readonly pluginCommandMap = new Map(); @@ -359,6 +371,9 @@ export class KimiTUI { public onExit?: (exitCode?: number) => Promise; + /** Called synchronously before a dead output stream triggers the emergency exit. */ + public onDeadTerminalError?: (context: DeadTerminalErrorContext) => void; + /** URL opened in the browser just before exit (e.g. by `/web`); printed by onExit. */ public exitOpenUrl: string | undefined; @@ -453,6 +468,7 @@ export class KimiTUI { this.fdPath, this.state.appState.additionalDirs, () => this.state.appState.inputMode, + this.inlineSkillCommands, ); this.state.editor.setAutocompleteProvider(provider); @@ -474,6 +490,7 @@ export class KimiTUI { async refreshSkillCommands(session?: SkillListSession): Promise { if (session === undefined) { this.skillCommands = []; + this.inlineSkillCommands = []; this.skillCommandMap.clear(); this.setupAutocomplete(); return; @@ -487,6 +504,7 @@ export class KimiTUI { } const skillCommands = buildSkillSlashCommands(skills); this.skillCommands = skillCommands.commands; + this.inlineSkillCommands = skillCommands.inlineCommands; this.skillCommandMap.clear(); for (const [commandName, skillName] of skillCommands.commandMap) { this.skillCommandMap.set(commandName, skillName); @@ -906,18 +924,35 @@ export class KimiTUI { }); } - const terminalErrorHandler = (error: Error): void => { - if (isDeadTerminalError(error)) { + const createTerminalErrorHandler = + (stream: DeadTerminalErrorContext['stream']) => + (error: Error): void => { + if (!isDeadTerminalError(error)) return; + try { + const sessionId = this.getCurrentSessionId(); + const { turnId, step } = this.streamingUI.getTurnContext(); + this.onDeadTerminalError?.({ + stream, + error: error as NodeJS.ErrnoException, + sessionId: sessionId === '' ? undefined : sessionId, + turnId, + step, + streamingPhase: this.state.appState.streamingPhase, + }); + } catch { + // Diagnostic recording is best-effort and must not block emergency exit. + } this.emergencyTerminalExit(); - } - }; - process.stdout.on('error', terminalErrorHandler); - process.stderr.on('error', terminalErrorHandler); + }; + const stdoutErrorHandler = createTerminalErrorHandler('stdout'); + const stderrErrorHandler = createTerminalErrorHandler('stderr'); + process.stdout.on('error', stdoutErrorHandler); + process.stderr.on('error', stderrErrorHandler); this.signalCleanupHandlers.push(() => { - process.stdout.off('error', terminalErrorHandler); + process.stdout.off('error', stdoutErrorHandler); }); this.signalCleanupHandlers.push(() => { - process.stderr.off('error', terminalErrorHandler); + process.stderr.off('error', stderrErrorHandler); }); } @@ -1153,12 +1188,16 @@ export class KimiTUI { this.showError(LLM_NOT_SET_MESSAGE); return; } + const skillNames = findInlineSkillNames(text, this.skillCommandMap); if (extraction.hasMedia) { this.sendMessage(session, text, { hasMedia: true, parts: extraction.parts, imageAttachmentIds: extraction.imageAttachmentIds, + skillNames, }); + } else if (skillNames.length > 0) { + this.sendMessage(session, text, { skillNames }); } else { this.sendMessage(session, text); } @@ -1243,6 +1282,7 @@ export class KimiTUI { text, agentId: this.harness.interactiveAgentId, parts: options?.parts, + skillNames: options?.skillNames, imageAttachmentIds: options?.imageAttachmentIds !== undefined && options.imageAttachmentIds.length > 0 ? options.imageAttachmentIds @@ -1284,6 +1324,7 @@ export class KimiTUI { this.sendMessageInternal(session, item.text, { parts: item.parts, imageAttachmentIds: item.imageAttachmentIds, + skillNames: item.skillNames, }); }); } @@ -1314,7 +1355,10 @@ export class KimiTUI { // continuation boundary and is rejected with `turn.agent_busy`, dropping // the message. Steer instead: the engine buffers it into the running goal // turn, or launches a turn of its own if the loop just ended. - if (this.state.appState.goal?.status === 'active') { + if ( + this.state.appState.goal?.status === 'active' && + (options?.skillNames === undefined || options.skillNames.length === 0) + ) { void session.steer(sdkInput).catch((error: unknown) => { const message = formatErrorMessage(error); // Same reset as the prompt path: beginSessionRequest already moved the @@ -1325,6 +1369,18 @@ export class KimiTUI { }); return; } + if (options?.skillNames !== undefined && options.skillNames.length > 0) { + void session + .promptWithSkills( + options.skillNames.map((name) => ({ name })), + sdkInput, + ) + .catch((error: unknown) => { + const message = formatErrorMessage(error); + this.failSessionRequest(`Failed to send: ${message}`); + }); + return; + } void session.prompt(sdkInput).catch((error: unknown) => { const message = formatErrorMessage(error); this.failSessionRequest(`Failed to send: ${message}`); diff --git a/apps/kimi-code/src/tui/types.ts b/apps/kimi-code/src/tui/types.ts index d895b11e12..4f3c7d1176 100644 --- a/apps/kimi-code/src/tui/types.ts +++ b/apps/kimi-code/src/tui/types.ts @@ -208,6 +208,7 @@ export interface QueuedMessage { readonly agentId?: string; readonly parts?: readonly PromptPart[]; readonly imageAttachmentIds?: readonly number[]; + readonly skillNames?: readonly string[]; /** `bash` for a `!` shell command queued while another command is running; * undefined (=`prompt`) for a normal message. */ readonly mode?: 'prompt' | 'bash'; diff --git a/apps/kimi-code/src/utils/clipboard/clipboard-image.ts b/apps/kimi-code/src/utils/clipboard/clipboard-image.ts index 6aae761c44..b5428164f4 100644 --- a/apps/kimi-code/src/utils/clipboard/clipboard-image.ts +++ b/apps/kimi-code/src/utils/clipboard/clipboard-image.ts @@ -128,8 +128,11 @@ function selectPreferredImageMimeType(candidates: string[]): string | null { const match = normalized.find((t) => t.base === preferred); if (match !== undefined) return match.raw; } - const anyImage = normalized.find((t) => t.base.startsWith('image/')); - return anyImage?.raw ?? null; + // Only accept formats the image pipeline can actually decode. Anything + // else (e.g. WSLg bridges Windows clipboard images as image/bmp only) + // would be dropped downstream anyway — and would short-circuit the WSL + // PowerShell fallback that could have produced a PNG. + return null; } function videoMimeFromPath(path: string): string | null { @@ -241,8 +244,8 @@ function runCommand(command: string, args: string[], options?: RunCommandOptions }); } -function readClipboardFileMediaViaWlPaste(): ClipboardMedia | null { - const list = runCommand('wl-paste', ['--list-types'], { +function readClipboardFileMediaViaWlPaste(run: RunCommand): ClipboardMedia | null { + const list = run('wl-paste', ['--list-types'], { timeoutMs: DEFAULT_LIST_TIMEOUT_MS, }); if (!list.ok) return null; @@ -251,12 +254,12 @@ function readClipboardFileMediaViaWlPaste(): ClipboardMedia | null { const uriType = types.find((t) => baseMimeType(t) === 'text/uri-list'); if (uriType === undefined) return null; - const uris = runCommand('wl-paste', ['--type', uriType, '--no-newline']); + const uris = run('wl-paste', ['--type', uriType, '--no-newline']); return uris.ok ? readMediaFromText(uris.stdout.toString('utf-8')) : null; } -function readClipboardImageViaWlPaste(): ClipboardImage | null { - const list = runCommand('wl-paste', ['--list-types'], { +function readClipboardImageViaWlPaste(run: RunCommand): ClipboardImage | null { + const list = run('wl-paste', ['--list-types'], { timeoutMs: DEFAULT_LIST_TIMEOUT_MS, }); if (!list.ok) return null; @@ -264,13 +267,13 @@ function readClipboardImageViaWlPaste(): ClipboardImage | null { const selected = selectPreferredImageMimeType(parseTargetList(list.stdout)); if (selected === null) return null; - const data = runCommand('wl-paste', ['--type', selected, '--no-newline']); + const data = run('wl-paste', ['--type', selected, '--no-newline']); if (!data.ok || data.stdout.length === 0) return null; return { kind: 'image', bytes: data.stdout, mimeType: baseMimeType(selected) }; } -function readClipboardFileMediaViaXclip(): ClipboardMedia | null { - const targets = runCommand('xclip', ['-selection', 'clipboard', '-t', 'TARGETS', '-o'], { +function readClipboardFileMediaViaXclip(run: RunCommand): ClipboardMedia | null { + const targets = run('xclip', ['-selection', 'clipboard', '-t', 'TARGETS', '-o'], { timeoutMs: DEFAULT_LIST_TIMEOUT_MS, }); if (!targets.ok) return null; @@ -279,12 +282,12 @@ function readClipboardFileMediaViaXclip(): ClipboardMedia | null { const uriType = candidates.find((t) => baseMimeType(t) === 'text/uri-list'); if (uriType === undefined) return null; - const uris = runCommand('xclip', ['-selection', 'clipboard', '-t', uriType, '-o']); + const uris = run('xclip', ['-selection', 'clipboard', '-t', uriType, '-o']); return uris.ok ? readMediaFromText(uris.stdout.toString('utf-8')) : null; } -function readClipboardImageViaXclip(): ClipboardImage | null { - const targets = runCommand('xclip', ['-selection', 'clipboard', '-t', 'TARGETS', '-o'], { +function readClipboardImageViaXclip(run: RunCommand): ClipboardImage | null { + const targets = run('xclip', ['-selection', 'clipboard', '-t', 'TARGETS', '-o'], { timeoutMs: DEFAULT_LIST_TIMEOUT_MS, }); @@ -296,7 +299,7 @@ function readClipboardImageViaXclip(): ClipboardImage | null { : [...SUPPORTED_IMAGE_MIME_TYPES]; for (const mime of tryTypes) { - const data = runCommand('xclip', ['-selection', 'clipboard', '-t', mime, '-o']); + const data = run('xclip', ['-selection', 'clipboard', '-t', mime, '-o']); if (data.ok && data.stdout.length > 0) { return { kind: 'image', bytes: data.stdout, mimeType: baseMimeType(mime) }; } @@ -310,27 +313,29 @@ function readClipboardImageViaXclip(): ClipboardImage | null { * we round-trip via a temp PNG because binary stdout is unreliable * across the WSL interop boundary. */ -function readClipboardImageViaPowerShell(): ClipboardImage | null { +function readClipboardImageViaPowerShell(run: RunCommand): ClipboardImage | null { const tmpFile = join(tmpdir(), `kimi-wsl-clip-${randomUUID()}.png`); try { - const winPathResult = runCommand('wslpath', ['-w', tmpFile], { + const winPathResult = run('wslpath', ['-w', tmpFile], { timeoutMs: DEFAULT_LIST_TIMEOUT_MS, }); if (!winPathResult.ok) return null; const winPath = winPathResult.stdout.toString('utf-8').trim(); if (winPath.length === 0) return null; + // The temp path is embedded in the script text: WSL only forwards + // environment variables listed in WSLENV to Win32 processes, so an + // env-var hand-off would silently arrive empty without user config. const psScript = [ 'Add-Type -AssemblyName System.Windows.Forms', 'Add-Type -AssemblyName System.Drawing', - '$path = $env:KIMI_WSL_CLIPBOARD_IMAGE_PATH', + `$path = '${winPath.replaceAll("'", "''")}'`, '$img = [System.Windows.Forms.Clipboard]::GetImage()', "if ($img) { $img.Save($path, [System.Drawing.Imaging.ImageFormat]::Png); Write-Output 'ok' } else { Write-Output 'empty' }", ].join('; '); - const result = runCommand('powershell.exe', ['-NoProfile', '-Command', psScript], { + const result = run('powershell.exe', ['-NoProfile', '-Command', psScript], { timeoutMs: DEFAULT_POWERSHELL_TIMEOUT_MS, - env: { ...process.env, KIMI_WSL_CLIPBOARD_IMAGE_PATH: winPath }, }); if (!result.ok) return null; if (result.stdout.toString('utf-8').trim() !== 'ok') return null; @@ -419,12 +424,12 @@ export async function readClipboardMedia(options?: { const wsl = isWSL(env); if (wayland || wsl) { - const fileMedia = readClipboardFileMediaViaWlPaste() ?? readClipboardFileMediaViaXclip(); + const fileMedia = readClipboardFileMediaViaWlPaste(run) ?? readClipboardFileMediaViaXclip(run); if (fileMedia !== null) return fileMedia; - image = readClipboardImageViaWlPaste() ?? readClipboardImageViaXclip(); + image = readClipboardImageViaWlPaste(run) ?? readClipboardImageViaXclip(run); } if (image === null && wsl) { - image = readClipboardImageViaPowerShell(); + image = readClipboardImageViaPowerShell(run); } if (image === null && !wayland) { const nativeFileMedia = await readClipboardFileMediaViaNativeText(clip); diff --git a/apps/kimi-code/test/cli/run-prompt.test.ts b/apps/kimi-code/test/cli/run-prompt.test.ts index 4bad127d89..9b2d398376 100644 --- a/apps/kimi-code/test/cli/run-prompt.test.ts +++ b/apps/kimi-code/test/cli/run-prompt.test.ts @@ -1,4 +1,5 @@ import type { createKimiDeviceId as createKimiDeviceIdFn } from '@moonshot-ai/kimi-code-oauth'; +import { Writable } from 'node:stream'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { runPrompt } from '#/cli/run-prompt'; @@ -1033,6 +1034,106 @@ describe('runPrompt', () => { expect(mocks.harnessClose).toHaveBeenCalledTimes(1); }); + it('preserves completed stream-json output when signalled during cleanup', async () => { + let releaseCleanup: (() => void) | undefined; + mocks.harnessClose.mockImplementationOnce( + () => + new Promise((resolve) => { + releaseCleanup = resolve; + }), + ); + + const chunks: string[] = []; + let blocked = true; + let releaseBufferedWrite: (() => void) | undefined; + const stdout = new Writable({ + highWaterMark: 1, + write(chunk, _encoding, callback) { + chunks.push(chunk.toString()); + if (!blocked) { + callback(); + return; + } + releaseBufferedWrite = callback; + }, + }); + + const processMock = fakeProcess(); + const run = runPrompt(opts({ outputFormat: 'stream-json' }), '1.2.3-test', { + stdout, + stderr: writer(), + process: processMock, + } as Parameters[2] & { process: ReturnType }); + + await waitForAssertion(() => { + expect(mocks.harnessClose).toHaveBeenCalled(); + }); + expect(stdout.writableNeedDrain).toBe(true); + const signalHandler = processMock.listener('SIGTERM'); + if (signalHandler === undefined) { + blocked = false; + releaseBufferedWrite?.(); + releaseCleanup?.(); + await run; + } + expect(signalHandler).toBeDefined(); + + const termination = signalHandler?.(); + releaseCleanup?.(); + await Promise.resolve(); + expect(processMock.exit).not.toHaveBeenCalled(); + + blocked = false; + releaseBufferedWrite?.(); + await termination; + await run; + + expect(chunks.join('')).toContain('{"role":"assistant","content":"hello world"}\n'); + expect(chunks.join('')).toContain('"type":"session.resume_hint"'); + expect(processMock.exit).toHaveBeenCalledWith(143); + }); + + it('propagates cleanup failures after buffered output finishes draining', async () => { + vi.useFakeTimers(); + try { + mocks.harnessClose.mockRejectedValueOnce(new Error('close failed')); + + let releaseBufferedWrite: (() => void) | undefined; + let blocked = true; + const stdout = new Writable({ + highWaterMark: 1, + write(_chunk, _encoding, callback) { + if (!blocked) { + callback(); + return; + } + releaseBufferedWrite = callback; + }, + }); + const run = runPrompt(opts({ outputFormat: 'stream-json' }), '1.2.3-test', { + stdout, + stderr: writer(), + }); + + for ( + let attempt = 0; + attempt < 20 && mocks.harnessClose.mock.calls.length === 0; + attempt += 1 + ) { + await Promise.resolve(); + } + expect(mocks.harnessClose).toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(PROMPT_CLEANUP_TIMEOUT_MS); + blocked = false; + releaseBufferedWrite?.(); + + await expect(run).rejects.toThrow('close failed'); + } finally { + vi.useRealTimers(); + } + }); + it('waits for the pending auto permission write before signal restore', async () => { let releaseAutoPermission!: () => void; let releasePrompt!: () => void; diff --git a/apps/kimi-code/test/cli/run-shell.test.ts b/apps/kimi-code/test/cli/run-shell.test.ts index 2275d3a267..ea1ca11fc0 100644 --- a/apps/kimi-code/test/cli/run-shell.test.ts +++ b/apps/kimi-code/test/cli/run-shell.test.ts @@ -1,3 +1,9 @@ +/** + * Scenario: CLI shell startup, shutdown, and fatal-diagnostic wiring. + * Contract: fatal records are persisted synchronously before immediate process exits. + * Boundaries: harness, TUI, telemetry, logger, child_process, and process I/O are mocked. + * Run: pnpm exec vitest run apps/kimi-code/test/cli/run-shell.test.ts + */ import { execSync } from 'node:child_process'; import type { createKimiDeviceId as createKimiDeviceIdFn } from '@moonshot-ai/kimi-code-oauth'; @@ -9,6 +15,22 @@ import { captureProcessWrite, ExitCalled, mockProcessExit } from '../helpers/pro type CreateKimiDeviceId = typeof createKimiDeviceIdFn; +const initialUncaughtExceptionListeners = new Set(process.listeners('uncaughtException')); +const initialUnhandledRejectionListeners = new Set(process.listeners('unhandledRejection')); + +function restoreProcessCrashListeners(): void { + for (const listener of process.listeners('uncaughtException')) { + if (!initialUncaughtExceptionListeners.has(listener)) { + process.off('uncaughtException', listener); + } + } + for (const listener of process.listeners('unhandledRejection')) { + if (!initialUnhandledRejectionListeners.has(listener)) { + process.off('unhandledRejection', listener); + } + } +} + const mocks = vi.hoisted(() => { type TuiConfigFallback = { theme: 'dark' | 'light' | 'auto'; @@ -59,6 +81,8 @@ const mocks = vi.hoisted(() => { })), resolveKimiHome: vi.fn((homeDir?: string) => homeDir ?? '/tmp/kimi-code-test-home'), flushDiagnosticLogsSync: vi.fn(), + logError: vi.fn(), + logInfo: vi.fn(), harnessCreatesDeviceIdOnConstruction: false, execSync: vi.fn(), TuiConfigParseError, @@ -71,6 +95,13 @@ vi.mock('@moonshot-ai/kimi-code-sdk', async (importOriginal) => { ...actual, resolveKimiHome: mocks.resolveKimiHome, flushDiagnosticLogsSync: mocks.flushDiagnosticLogsSync, + log: { + error: mocks.logError, + warn: actual.log.warn.bind(actual.log), + info: mocks.logInfo, + debug: actual.log.debug.bind(actual.log), + createChild: actual.log.createChild.bind(actual.log), + }, createKimiHarness: (...args: unknown[]) => { const options = args[0] as { readonly homeDir?: string } | undefined; const homeDir = options?.homeDir ?? '/tmp/kimi-code-test-home'; @@ -121,6 +152,7 @@ vi.mock('../../src/tui/config', () => ({ vi.mock('../../src/tui/index', () => ({ KimiTUI: class { onExit?: () => Promise; + onDeadTerminalError?: (context: Record) => void; constructor(...args: unknown[]) { mocks.kimiTuiConstructor(this, ...args); @@ -147,6 +179,7 @@ vi.mock('node:child_process', () => ({ describe('runShell', () => { afterEach(() => { + restoreProcessCrashListeners(); vi.clearAllMocks(); mocks.harnessGetConfig.mockResolvedValue({ providers: {}, @@ -577,6 +610,79 @@ describe('runShell', () => { } }); + it('flushes a structured dead-terminal record synchronously', async () => { + mocks.loadTuiConfig.mockResolvedValue({ + theme: 'dark', + editorCommand: null, + notifications: { enabled: true, condition: 'unfocused' }, + }); + mocks.tuiStart.mockResolvedValue(undefined); + + await runShell( + { + session: undefined, + continue: false, + yolo: false, + auto: false, + plan: false, + model: undefined, + outputFormat: undefined, + prompt: undefined, + skillsDirs: [], + agent: undefined, + agentFiles: [], + }, + '1.2.3-test', + ); + + const [tui] = mocks.kimiTuiConstructor.mock.calls[0]!; + const error = Object.assign(new Error('write EPIPE'), { + code: 'EPIPE', + errno: -32, + syscall: 'write', + }); + const record = ( + tui as { + onDeadTerminalError: (context: Record) => void; + } + ).onDeadTerminalError; + + record({ + stream: 'stdout', + error, + sessionId: 'ses-dead-terminal', + turnId: 'turn-28', + step: 13, + streamingPhase: 'thinking', + }); + + expect(mocks.logError).toHaveBeenCalledWith( + 'terminal output stream failed, restoring terminal and exiting', + expect.objectContaining({ + error, + stream: 'stdout', + errorCode: 'EPIPE', + errno: -32, + syscall: 'write', + sessionId: 'ses-dead-terminal', + turnId: 'turn-28', + step: 13, + streamingPhase: 'thinking', + exitCode: 129, + stdoutIsTTY: process.stdout.isTTY, + stderrIsTTY: process.stderr.isTTY, + stdoutDestroyed: expect.any(Boolean), + stderrDestroyed: expect.any(Boolean), + pid: process.pid, + ppid: process.ppid, + }), + ); + expect(mocks.flushDiagnosticLogsSync).toHaveBeenCalledOnce(); + expect(mocks.logError.mock.invocationCallOrder[0]!).toBeLessThan( + mocks.flushDiagnosticLogsSync.mock.invocationCallOrder[0]!, + ); + }); + it('flushes diagnostic logs synchronously before exiting on an unhandled rejection', async () => { mocks.loadTuiConfig.mockResolvedValue({ theme: 'dark', diff --git a/apps/kimi-code/test/cli/update/source.test.ts b/apps/kimi-code/test/cli/update/source.test.ts index dd88d32c3c..bd6c12d5f0 100644 --- a/apps/kimi-code/test/cli/update/source.test.ts +++ b/apps/kimi-code/test/cli/update/source.test.ts @@ -41,12 +41,24 @@ describe('classifyByPathHeuristic', () => { ).toBe('yarn-global'); }); - it('detects bun global', () => { + it('detects bun global (install/global layout)', () => { expect( classifyByPathHeuristic('/Users/me/.bun/install/global/node_modules/@moonshot-ai/kimi-code'), ).toBe('bun-global'); }); + it('detects bun global (node_modules layout)', () => { + expect( + classifyByPathHeuristic('/Users/me/.bun/node_modules/@moonshot-ai/kimi-code'), + ).toBe('bun-global'); + }); + + it('does not treat a local project named foo.bun as bun global', () => { + expect( + classifyByPathHeuristic('/work/foo.bun/node_modules/@moonshot-ai/kimi-code'), + ).toBeNull(); + }); + it('detects homebrew on macOS (Cellar path)', () => { expect( classifyByPathHeuristic('/opt/homebrew/Cellar/kimi-code/0.5.0/libexec/lib/node_modules/@moonshot-ai/kimi-code'), @@ -108,7 +120,7 @@ describe('detectInstallSource', () => { ).resolves.toBe('yarn-global'); }); - it('returns bun-global when packageRoot matches bun heuristic', async () => { + it('returns bun-global when packageRoot matches bun heuristic (install/global layout)', async () => { await expect( detectInstallSource({ getPackageRoot: () => '/Users/me/.bun/install/global/node_modules/@moonshot-ai/kimi-code', @@ -119,6 +131,17 @@ describe('detectInstallSource', () => { ).resolves.toBe('bun-global'); }); + it('returns bun-global when packageRoot matches bun heuristic (node_modules layout)', async () => { + await expect( + detectInstallSource({ + getPackageRoot: () => '/Users/me/.bun/node_modules/@moonshot-ai/kimi-code', + getGlobalPrefix: async () => '/usr/local', + detectNative: () => false, + platform: 'darwin', + }), + ).resolves.toBe('bun-global'); + }); + it('returns npm-global when packageRoot matches npm prefix', async () => { await expect( detectInstallSource({ diff --git a/apps/kimi-code/test/tui/commands/skills.test.ts b/apps/kimi-code/test/tui/commands/skills.test.ts index b9cf46bd60..51c364c3bc 100644 --- a/apps/kimi-code/test/tui/commands/skills.test.ts +++ b/apps/kimi-code/test/tui/commands/skills.test.ts @@ -1,4 +1,8 @@ -import { buildSkillSlashCommands, isUserActivatableSkill } from '#/tui/commands/index'; +import { + buildSkillSlashCommands, + findInlineSkillNames, + isUserActivatableSkill, +} from '#/tui/commands/index'; import type { SkillSummary } from '@moonshot-ai/kimi-code-sdk'; import { describe, expect, it } from 'vitest'; @@ -76,10 +80,18 @@ describe('skill slash commands', () => { ]); expect([...built.commandMap.entries()]).toEqual([ ['mcp-config', 'mcp-config'], + ['skill:mcp-config', 'mcp-config'], ['update-config', 'update-config'], + ['skill:update-config', 'update-config'], ['skill:alpha', 'alpha'], ['skill:zeta', 'zeta'], ]); + expect(built.inlineCommands.map((command) => command.name)).toEqual([ + 'skill:mcp-config', + 'skill:update-config', + 'skill:alpha', + 'skill:zeta', + ]); }); it('keeps disableModelInvocation skills slash-invocable', () => { @@ -102,4 +114,20 @@ describe('skill slash commands', () => { expect(built.commands.map((command) => command.name)).toEqual(['outer.inner']); expect(built.commandMap.get('outer.inner')).toBe('outer.inner'); }); + + it('finds unique inline skill tokens in first-occurrence order', () => { + const built = buildSkillSlashCommands([ + skill('review', 'prompt', { source: 'project' }), + skill('commit', 'flow', { source: 'user' }), + skill('mcp-config', 'inline', { source: 'builtin' }), + ]); + + expect( + findInlineSkillNames( + 'Use /skill:review, then /skill:mcp-config and /skill:review before /skill:commit.', + built.commandMap, + ), + ).toEqual(['review', 'mcp-config', 'commit']); + expect(findInlineSkillNames('Keep /skill:missing as text.', built.commandMap)).toEqual([]); + }); }); diff --git a/apps/kimi-code/test/tui/components/editor/file-mention-provider.test.ts b/apps/kimi-code/test/tui/components/editor/file-mention-provider.test.ts index b53b49a830..ed824b635f 100644 --- a/apps/kimi-code/test/tui/components/editor/file-mention-provider.test.ts +++ b/apps/kimi-code/test/tui/components/editor/file-mention-provider.test.ts @@ -270,6 +270,66 @@ describe('FileMentionProvider', () => { expect(result).toBeNull(); }); + it('offers only canonical skill commands after whitespace and on later lines', async () => { + const inlineSkills = [ + { name: 'skill:review', aliases: [], description: 'Review code' }, + { name: 'skill:commit', aliases: [], description: 'Commit changes' }, + ]; + const provider = new FileMentionProvider( + [HELP_COMMAND], + workDir, + NO_FD, + [], + () => 'prompt', + inlineSkills, + ); + + const inline = await provider.getSuggestions(['Please use /sk'], 0, 14, { signal: ctrl() }); + const multiline = await provider.getSuggestions(['Please use', '/sk'], 1, 3, { + signal: ctrl(), + }); + + expect(inline).toMatchObject({ + prefix: '/sk', + items: [ + expect.objectContaining({ value: 'skill:review' }), + expect.objectContaining({ value: 'skill:commit' }), + ], + }); + expect(multiline?.items.map((item) => item.value)).toEqual([ + 'skill:review', + 'skill:commit', + ]); + }); + + it('inserts an inline skill token without replacing the surrounding prompt', () => { + const inlineSkills = [ + { name: 'skill:review', aliases: [], description: 'Review code' }, + ]; + const provider = new FileMentionProvider( + [], + workDir, + NO_FD, + [], + () => 'prompt', + inlineSkills, + ); + + expect( + provider.applyCompletion( + ['Please /sk then continue'], + 0, + 'Please /sk'.length, + { value: 'skill:review', label: 'skill:review' }, + '/sk', + ), + ).toEqual({ + lines: ['Please /skill:review then continue'], + cursorLine: 0, + cursorCol: 'Please /skill:review '.length, + }); + }); + it('still allows forced root path completion after leading whitespace', async () => { const provider = new FileMentionProvider([], workDir, NO_FD); const result = await provider.getSuggestions([' /'], 0, 2, { signal: ctrl(), force: true }); diff --git a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts index 13ee90ceba..88145578c0 100644 --- a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts @@ -96,6 +96,7 @@ function stripSgr(text: string): string { interface MessageDriver { state: TUIState; + skillCommandMap: Map; streamingUI: StreamingUIController; sessionEventHandler: { startSubscription(): void; @@ -232,6 +233,7 @@ function makeSession(overrides: Record = {}) { reloadPlugins: vi.fn(async () => ({ added: [], removed: [], errors: [] })), reloadSession: vi.fn(async () => ({})), activateSkill: vi.fn(async () => {}), + promptWithSkills: vi.fn(async () => {}), getPluginInfo: vi.fn(async (id: string) => ({ id, displayName: id, @@ -2102,6 +2104,40 @@ command = "vim" expect(stripSgr(renderTranscript(driver))).toContain('Failed to prepare media attachment'); }); + it('submits inline skills once in first-occurrence order with the original prompt', async () => { + const session = makeSession(); + const { driver } = await makeDriver(session); + driver.state.appState.model = 'k2'; + driver.skillCommandMap.set('skill:review', 'review'); + driver.skillCommandMap.set('skill:commit', 'commit'); + + driver.handleUserInput( + 'Use /skill:review, fix the issue, then /skill:review and /skill:commit.', + ); + + expect(session.promptWithSkills).toHaveBeenCalledWith( + [{ name: 'review' }, { name: 'commit' }], + 'Use /skill:review, fix the issue, then /skill:review and /skill:commit.', + ); + expect(session.prompt).not.toHaveBeenCalled(); + }); + + it('submits multiple skills as one prompt when the first skill starts the message', async () => { + const session = makeSession(); + const { driver } = await makeDriver(session); + driver.state.appState.model = 'k2'; + driver.skillCommandMap.set('skill:review', 'review'); + driver.skillCommandMap.set('skill:commit', 'commit'); + + driver.handleUserInput('/skill:review check this, then /skill:commit'); + + expect(session.promptWithSkills).toHaveBeenCalledWith( + [{ name: 'review' }, { name: 'commit' }], + '/skill:review check this, then /skill:commit', + ); + expect(session.activateSkill).not.toHaveBeenCalled(); + }); + it('shows an error instead of throwing when plugin command media materialization fails', async () => { const activatePluginCommand = vi.fn(async () => {}); const session = makeSession({ activatePluginCommand }); diff --git a/apps/kimi-code/test/tui/signal-handlers.test.ts b/apps/kimi-code/test/tui/signal-handlers.test.ts index 91dc5beacf..63633a9657 100644 --- a/apps/kimi-code/test/tui/signal-handlers.test.ts +++ b/apps/kimi-code/test/tui/signal-handlers.test.ts @@ -1,3 +1,9 @@ +/** + * Scenario: KimiTUI process-signal and terminal-output failure handling. + * Contract: preserve exit codes and record dead-stream context before emergency exit. + * Boundaries: process listeners and process.exit are mocked; KimiTUI lifecycle code is real. + * Run: pnpm exec vitest run apps/kimi-code/test/tui/signal-handlers.test.ts + */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { KimiTUI, type KimiTUIStartupInput, type TUIState } from '#/tui/kimi-tui'; @@ -246,29 +252,86 @@ describe('KimiTUI signal handlers', () => { expect(exitSpy).toHaveBeenCalledWith(143); }); - it('stdout EIO error triggers emergency exit; ENOENT does not', () => { - const { driver } = makeDriver(); + it('records stdout EIO context before exiting with code 129', () => { + const { driver, tui } = makeDriver(); + driver.state.appState.sessionId = 'ses-dead-terminal'; + driver.state.appState.streamingPhase = 'thinking'; + tui.streamingUI.setTurnId('turn-28'); + tui.streamingUI.setStep(13); + const record = vi.fn(); + tui.onDeadTerminalError = record; const captured = captureHandlers(driver); - const eio = Object.assign(new Error('write EIO'), { code: 'EIO' }); + const eio = Object.assign(new Error('write EIO'), { + code: 'EIO', + errno: -5, + syscall: 'write', + }); captured.stdoutErrorHandler?.(eio); + + expect(record).toHaveBeenCalledWith({ + stream: 'stdout', + error: eio, + sessionId: 'ses-dead-terminal', + turnId: 'turn-28', + step: 13, + streamingPhase: 'thinking', + }); expect(exitSpy).toHaveBeenCalledWith(129); + expect(record.mock.invocationCallOrder[0]!).toBeLessThan(exitSpy.mock.invocationCallOrder[0]); + + captured.restore(); + driver.unregisterSignalHandlers(); + }); + + it('keeps running when stdout reports an unrelated error', () => { + const { driver, tui } = makeDriver(); + const record = vi.fn(); + tui.onDeadTerminalError = record; + const captured = captureHandlers(driver); - exitSpy.mockClear(); const enoent = Object.assign(new Error('not found'), { code: 'ENOENT' }); captured.stdoutErrorHandler?.(enoent); + + expect(record).not.toHaveBeenCalled(); expect(exitSpy).not.toHaveBeenCalled(); captured.restore(); driver.unregisterSignalHandlers(); }); - it('stderr EPIPE error triggers emergency exit', () => { - const { driver } = makeDriver(); + it('records stderr EPIPE context before exiting with code 129', () => { + const { driver, tui } = makeDriver(); + const record = vi.fn(); + tui.onDeadTerminalError = record; const captured = captureHandlers(driver); const epipe = Object.assign(new Error('write EPIPE'), { code: 'EPIPE' }); captured.stderrErrorHandler?.(epipe); + + expect(record).toHaveBeenCalledWith( + expect.objectContaining({ + stream: 'stderr', + error: epipe, + streamingPhase: 'idle', + }), + ); + expect(exitSpy).toHaveBeenCalledWith(129); + + captured.restore(); + driver.unregisterSignalHandlers(); + }); + + it('exits when dead-terminal recording throws', () => { + const { driver, tui } = makeDriver(); + tui.onDeadTerminalError = () => { + throw new Error('log unavailable'); + }; + const captured = captureHandlers(driver); + + const epipe = Object.assign(new Error('write EPIPE'), { code: 'EPIPE' }); + captured.stdoutErrorHandler?.(epipe); + expect(exitSpy).toHaveBeenCalledWith(129); captured.restore(); diff --git a/apps/kimi-code/test/utils/clipboard/clipboard-image.test.ts b/apps/kimi-code/test/utils/clipboard/clipboard-image.test.ts index 7e802db6ad..9b12294311 100644 --- a/apps/kimi-code/test/utils/clipboard/clipboard-image.test.ts +++ b/apps/kimi-code/test/utils/clipboard/clipboard-image.test.ts @@ -159,3 +159,76 @@ describe('readClipboardMedia', () => { } }); }); + +describe('readClipboardMedia on Linux/WSL', () => { + // WSLg bridges Windows clipboard images into the Wayland clipboard as + // image/bmp only, which the image pipeline cannot decode. + function wslgBmpOnlyClipboard(clipTmp: { path: string }) { + return vi.fn((command: string, args: string[]) => { + if (command === 'wl-paste' && args.includes('--list-types')) { + return { stdout: Buffer.from('image/bmp\n'), ok: true }; + } + if (command === 'wl-paste') { + return { stdout: Buffer.from([0x42, 0x4d, 0x00]), ok: true }; + } + if (command === 'xclip') return { stdout: Buffer.alloc(0), ok: false }; + if (command === 'wslpath') { + clipTmp.path = args[1] ?? ''; + return { stdout: Buffer.from('C:\\Temp\\kimi-wsl-clip-test.png\n'), ok: true }; + } + if (command === 'powershell.exe') { + writeFileSync(clipTmp.path, png(3, 2)); + return { stdout: Buffer.from('ok\n'), ok: true }; + } + return { stdout: Buffer.alloc(0), ok: false }; + }); + } + + it('skips undecodable image/bmp and falls through to the WSL PowerShell fallback', async () => { + const clipTmp = { path: '' }; + const runCommand = wslgBmpOnlyClipboard(clipTmp); + + const media = await readClipboardMedia({ + platform: 'linux', + env: { WSL_DISTRO_NAME: 'Ubuntu-24.04', WAYLAND_DISPLAY: 'wayland-0' }, + clipboard: null, + runCommand, + }); + + expect(media).toEqual({ kind: 'image', bytes: png(3, 2), mimeType: 'image/png' }); + + // The BMP payload must never be read, and the PowerShell fallback must + // receive the temp path embedded in the script (env vars do not cross + // the WSL boundary unless listed in WSLENV). + const calls = runCommand.mock.calls; + expect( + calls.some((c) => c[0] === 'wl-paste' && (c[1] as string[]).includes('--type')), + ).toBe(false); + const psCall = calls.find((c) => c[0] === 'powershell.exe'); + expect(psCall?.[1]?.join(' ')).toContain("$path = 'C:\\Temp\\kimi-wsl-clip-test.png'"); + }); + + it('returns null for a BMP-only clipboard when not on WSL', async () => { + const runCommand = vi.fn((command: string, args: string[]) => { + if (command === 'wl-paste' && args.includes('--list-types')) { + return { stdout: Buffer.from('image/bmp\n'), ok: true }; + } + if (command === 'wl-paste') { + return { stdout: Buffer.from([0x42, 0x4d, 0x00]), ok: true }; + } + return { stdout: Buffer.alloc(0), ok: false }; + }); + + const media = await readClipboardMedia({ + platform: 'linux', + env: { WAYLAND_DISPLAY: 'wayland-0' }, + clipboard: null, + runCommand, + }); + + expect(media).toBeNull(); + expect( + runCommand.mock.calls.some((c) => c[0] === 'wl-paste' && (c[1] as string[]).includes('--type')), + ).toBe(false); + }); +}); diff --git a/apps/vscode/src/KimiWebviewProvider.ts b/apps/vscode/src/KimiWebviewProvider.ts index 2fed3fa09d..efdf66baf1 100644 --- a/apps/vscode/src/KimiWebviewProvider.ts +++ b/apps/vscode/src/KimiWebviewProvider.ts @@ -1,3 +1,4 @@ +import { randomUUID } from "node:crypto"; import * as vscode from "vscode"; import type { KimiHarness } from "@moonshot-ai/kimi-code-sdk"; import { Events } from "../shared/bridge"; @@ -49,7 +50,7 @@ export class KimiWebviewProvider implements vscode.WebviewViewProvider { } resolveWebviewView(webviewView: vscode.WebviewView): void { - const webviewId = `sidebar_${crypto.randomUUID()}`; + const webviewId = `sidebar_${randomUUID()}`; this.setupWebview(webviewId, webviewView.webview); webviewView.onDidDispose(() => { @@ -59,7 +60,7 @@ export class KimiWebviewProvider implements vscode.WebviewViewProvider { } createPanel(): vscode.WebviewPanel { - const webviewId = `panel_${crypto.randomUUID()}`; + const webviewId = `panel_${randomUUID()}`; const panel = vscode.window.createWebviewPanel("kimiPanel", "Kimi Code", vscode.ViewColumn.One, { enableScripts: true, diff --git a/docs/en/reference/tools.md b/docs/en/reference/tools.md index 37972f5116..d6c9129376 100644 --- a/docs/en/reference/tools.md +++ b/docs/en/reference/tools.md @@ -109,7 +109,7 @@ Background task tools manage tasks started via `Bash`, `Agent`, or `AskUserQuest **`TaskList`** returns the list of background tasks. Optional parameters: `active_only` (defaults to true; lists only running tasks) and `limit` (defaults to 20; range 1–100). -**`TaskOutput`** returns the status and output of a task given its `task_id`. The inline preview includes at most the most recent 32 KB of content; the full log is saved to disk, and the tool also returns an `output_path` with a suggestion to use `Read` for paginated access. Optional `block` (defaults to false) and `timeout` (seconds to wait; defaults to 30; range 0–3600) parameters allow waiting for the task to complete before returning. +**`TaskOutput`** returns the status and output of a task given its `task_id`. The inline preview includes at most the most recent 32 KB of content; the full log is saved to disk, and the tool also returns an `output_path` with a suggestion to use `Read` for paginated access. For `Agent` subagent tasks, the output streams live: turns, tool calls, and thinking/assistant text appear as they happen, so this is how you watch a subagent work in real time. Optional `block` (defaults to false) and `timeout` (seconds to wait; defaults to 30; range 0–3600) parameters allow waiting for the task to complete before returning. **`TaskStop`** accepts a `task_id` and optional `reason` (defaults to `Stopped by TaskStop`). Safe to call on tasks that are already in a terminal state. diff --git a/docs/zh/reference/tools.md b/docs/zh/reference/tools.md index f993fa7bb6..d71264118a 100644 --- a/docs/zh/reference/tools.md +++ b/docs/zh/reference/tools.md @@ -109,7 +109,7 @@ Plan 模式是一种受约束的工作状态:进入后 `Write` 与 `Edit` 只 **`TaskList`** 返回后台任务列表。可选参数 `active_only`(默认 true,仅列出运行中的任务)和 `limit`(默认 20,取值范围 1–100)。 -**`TaskOutput`** 根据 `task_id` 返回任务状态与输出。内联预览最多包含最近 32 KB 的内容;完整日志保存在磁盘上,工具会一并返回 `output_path` 并提示通过 `Read` 分页读取。可选 `block`(默认 false)和 `timeout`(等待秒数,默认 30,取值范围 0–3600)参数可用于等待任务完成后再返回。 +**`TaskOutput`** 根据 `task_id` 返回任务状态与输出。内联预览最多包含最近 32 KB 的内容;完整日志保存在磁盘上,工具会一并返回 `output_path` 并提示通过 `Read` 分页读取。对于 `Agent` 子代理任务,输出是实时流式的:轮次、工具调用和思考/回复内容都会随执行即时写入,可以用它实时观察子代理的工作过程。可选 `block`(默认 false)和 `timeout`(等待秒数,默认 30,取值范围 0–3600)参数可用于等待任务完成后再返回。 **`TaskStop`** 接受 `task_id` 和可选的 `reason`(默认 `Stopped by TaskStop`)。对已处于终止状态的任务也能安全调用。 diff --git a/packages/acp-adapter/src/question.ts b/packages/acp-adapter/src/question.ts index 2270d8105f..46595113ee 100644 --- a/packages/acp-adapter/src/question.ts +++ b/packages/acp-adapter/src/question.ts @@ -4,58 +4,35 @@ import type { } from '@agentclientprotocol/sdk'; import type { QuestionAnswers, QuestionItem } from '@moonshot-ai/kimi-code-sdk'; -/** - * `optionId` namespace for the AskUserQuestion bridge. - * - * The wire-level `PermissionOption.optionId` is opaque to the client (it - * round-trips back via `RequestPermissionResponse.outcome.optionId`), so - * the adapter is free to pick any stable string. We embed the - * `questionIndex` in the prefix so multi-question support (when it - * arrives — Phase 13.1 still degrades to single-question) does not need - * a wire-format change: `q0_opt_*` / `q1_opt_*` are already - * non-conflicting. The skip option follows the same scheme so a single - * regex (`/^q(\d+)_(opt_(\d+)|skip)$/`) can parse any future surface. - */ function optOptionId(questionIndex: number, optionIndex: number): string { return `q${questionIndex}_opt_${optionIndex}`; } +function doneOptionId(questionIndex: number): string { + return `q${questionIndex}_done`; +} + function skipOptionId(questionIndex: number): string { return `q${questionIndex}_skip`; } -/** - * Map a tool-side {@link QuestionItem} into ACP - * {@link PermissionOption}[]. - * - * Layout: - * - One `allow_once` option per `question.options[i]` (label preserved - * verbatim — it is the same string we surface back to the SDK as a - * `QuestionAnswers` value, so any UI normalisation belongs on the - * tool side, not here). - * - One trailing `reject_once` "Skip" option so the user can dismiss - * the prompt without forcing an answer. The SDK's ask-user tool - * already understands dismissal (`packages/agent-core/src/tools/builtin/collaboration/ask-user.ts:126` - * emits `question_dismissed` and resolves with a null result); the - * Skip surface is the user-facing path into that branch. - * - * `questionIndex` is currently always `0` (Phase 13.1 degrades - * multi-question to single-question), but the namespace is wired in so - * future multi-question support is a pure handler change with no wire - * format break. - * - * Returned `readonly` because callers treat it as a constant lookup - * table — they do not mutate it. - */ export function questionItemToPermissionOptions( question: QuestionItem, questionIndex: number, + selectedOptions: ReadonlySet = new Set(), ): readonly PermissionOption[] { const options: PermissionOption[] = question.options.map((opt, i) => ({ optionId: optOptionId(questionIndex, i), - name: opt.label, + name: selectedOptions.has(i) ? `✓ ${opt.label}` : opt.label, kind: 'allow_once' as const, })); + if (question.multiSelect === true && selectedOptions.size > 0) { + options.push({ + optionId: doneOptionId(questionIndex), + name: 'Done', + kind: 'allow_once' as const, + }); + } options.push({ optionId: skipOptionId(questionIndex), name: 'Skip', @@ -64,36 +41,38 @@ export function questionItemToPermissionOptions( return options; } -/** - * Reverse-map an ACP {@link RequestPermissionResponse} into a tool-side - * {@link QuestionAnswers} payload, returning `null` when the user - * dismissed (skip, cancel) or selected an unknown option. - * - * Dismissal semantics align with the existing ask-user tool path: - * `null` causes the SDK to resolve the tool with the canonical - * "user dismissed" branch (mirrors `rpc.ts:567` — `requestQuestion` - * returning `null` is the dismissed signal). - * - * Defensive on out-of-bounds / unknown optionIds: returning `null` - * rather than throwing keeps the bridge robust against stale or custom - * options surfaced by the client. - */ -export function outcomeToQuestionAnswer( +export type QuestionPermissionOutcome = + | { readonly kind: 'option'; readonly optionIndex: number } + | { readonly kind: 'done' } + | { readonly kind: 'skip' } + | { readonly kind: 'cancelled' }; + +export function permissionResponseToQuestionOutcome( question: QuestionItem, + questionIndex: number, response: RequestPermissionResponse, -): QuestionAnswers | null { - if (response.outcome.outcome === 'cancelled') return null; +): QuestionPermissionOutcome { + if (response.outcome.outcome === 'cancelled') return { kind: 'cancelled' }; const optionId = response.outcome.optionId; - // Skip — explicit dismissal path; treat the same as `cancelled`. - if (optionId === skipOptionId(0)) return null; - // Selected option — parse the `q0_opt_` shape and look up the - // matching label. Reject anything that does not match the namespace - // (or whose index is out of bounds) defensively rather than crashing. - const match = /^q0_opt_(\d+)$/.exec(optionId); - if (!match) return null; + if (optionId === skipOptionId(questionIndex)) return { kind: 'skip' }; + if (question.multiSelect === true && optionId === doneOptionId(questionIndex)) { + return { kind: 'done' }; + } + const match = new RegExp(`^q${String(questionIndex)}_opt_(\\d+)$`).exec(optionId); + if (!match) return { kind: 'cancelled' }; const optionIndex = Number(match[1]); - if (!Number.isInteger(optionIndex) || optionIndex < 0) return null; - const selected = question.options[optionIndex]; - if (!selected) return null; + if (!Number.isInteger(optionIndex) || optionIndex < 0 || !question.options[optionIndex]) { + return { kind: 'cancelled' }; + } + return { kind: 'option', optionIndex }; +} + +export function outcomeToQuestionAnswer( + question: QuestionItem, + response: RequestPermissionResponse, +): QuestionAnswers | null { + const outcome = permissionResponseToQuestionOutcome(question, 0, response); + if (outcome.kind !== 'option') return null; + const selected = question.options[outcome.optionIndex]!; return { [question.question]: selected.label }; } diff --git a/packages/acp-adapter/src/session.ts b/packages/acp-adapter/src/session.ts index 747b44ea9c..64f33df5d3 100644 --- a/packages/acp-adapter/src/session.ts +++ b/packages/acp-adapter/src/session.ts @@ -21,7 +21,9 @@ import { type McpServerInfo, type PromptPart, type QuestionAnswers, + type QuestionItem, type QuestionRequest, + type QuestionResult, type Session, type SessionStatus, type SessionUsage, @@ -56,7 +58,10 @@ import { turnEndReasonToStopReason, } from './events-map'; import { acpModeToToggles, DEFAULT_MODE_ID, isAcpModeId, type AcpModeId } from './modes'; -import { outcomeToQuestionAnswer, questionItemToPermissionOptions } from './question'; +import { + permissionResponseToQuestionOutcome, + questionItemToPermissionOptions, +} from './question'; import { detectSlashIntent } from './slash'; /** @@ -71,6 +76,10 @@ export type TelemetryTrackFn = ( properties?: Record, ) => void; +function formatQuestionCount(count: number): string { + return `${String(count)} ${count === 1 ? 'question' : 'questions'}`; +} + /** * Adapter-side wrapper around a {@link Session} from the Kimi node SDK. * @@ -1347,86 +1356,138 @@ export class AcpSession { } } - /** - * Bridge an SDK {@link QuestionRequest} (the AskUserQuestion tool's - * reverse-RPC) through the same ACP - * `session/request_permission` surface used by approvals. - * - * ACP currently has no dedicated `session/request_question` method, so - * the adapter re-uses `requestPermission` and tags the options with a - * `q{n}_*` namespace so the round-trip is unambiguous. - * - * Degradation rules: - * - `req.questions.length > 1` → only the first question is asked; - * telemetry records the dropped count so we can observe how often - * multi-question prompts land in the wild. - * - `q.multiSelect === true` → still asked as single-select; the - * SDK's ask-user tool tolerates a single-key answer for a multi- - * select prompt so this is a graceful narrow rather than a hard - * fail. - * - * Error policy mirrors {@link handleApproval}: any RPC failure logs - * a warning and returns `null` so the SDK resolves the tool with the - * canonical "user dismissed" branch (`rpc.ts:567`). Returning `null` - * is strictly safer than fabricating an answer the user did not give. - */ - private async handleQuestion(req: QuestionRequest): Promise { + private async handleQuestion(req: QuestionRequest): Promise { const questions = req.questions; if (questions.length === 0) { - // Pathological input — log and dismiss. No telemetry: the SDK - // would never emit an empty `questions` payload in practice. log.warn('acp: handleQuestion received empty questions array', { sessionId: this.id, }); return null; } - if (questions.length > 1) { - log.warn('acp: handleQuestion degrading to first question only', { - sessionId: this.id, - dropped: questions.length - 1, - }); - this.emitTelemetry('question_degraded', { - reason: 'multi_question', - dropped: questions.length - 1, - }); - } - const q = questions[0]!; - if (q.multiSelect === true) { - this.emitTelemetry('question_degraded', { reason: 'multi_select' }); - } - const options = questionItemToPermissionOptions(q, 0); const rawToolCallId = req.toolCallId ?? 'ask-user'; const toolCallId = this.currentTurnId !== undefined ? acpToolCallId(this.currentTurnId, rawToolCallId) : rawToolCallId; + const answers: QuestionAnswers = {}; + const skippedQuestions: string[] = []; + const clientUnansweredQuestions: string[] = []; + let questionIndex = 0; try { + for (; questionIndex < questions.length; questionIndex++) { + const question = questions[questionIndex]!; + const result = await this.handleQuestionItem( + question, + questionIndex, + questions.length, + toolCallId, + ); + if (result.answer !== undefined) { + answers[question.question] = result.answer; + } else { + skippedQuestions.push(question.question); + } + if (result.cancelRemaining) { + skippedQuestions.push( + ...questions.slice(questionIndex + 1).map((item) => item.question), + ); + break; + } + } + } catch (error) { + clientUnansweredQuestions.push( + ...questions.slice(questionIndex).map((item) => item.question), + ); + log.warn('acp: requestPermission (question) failed; dismissing remaining questions', { + sessionId: this.id, + toolCallId: req.toolCallId, + error: error instanceof Error ? error.message : String(error), + }); + } + + const answered = Object.keys(answers).length; + if (answered === 0) { + this.emitTelemetry('question_dismissed'); + } else { + this.emitTelemetry('question_answered', { answered }); + } + const noteParts: string[] = []; + if (skippedQuestions.length > 0) { + noteParts.push( + `User skipped ${formatQuestionCount(skippedQuestions.length)}: ${skippedQuestions.map((question) => JSON.stringify(question)).join(', ')}.`, + ); + } + if (clientUnansweredQuestions.length > 0) { + noteParts.push( + `The client stopped before ${formatQuestionCount(clientUnansweredQuestions.length)} could be answered.`, + ); + } + if (noteParts.length === 0) return answers; + return { + answers, + note: noteParts.join(' '), + }; + } + + private async handleQuestionItem( + question: QuestionItem, + questionIndex: number, + questionCount: number, + toolCallId: string, + ): Promise<{ readonly answer?: string; readonly cancelRemaining: boolean }> { + const selectedOptions = new Set(); + while (true) { + const options = questionItemToPermissionOptions( + question, + questionIndex, + selectedOptions, + ); const response = await this.conn.requestPermission({ sessionId: this.id, options: [...options], toolCall: { toolCallId, - title: 'AskUserQuestion', - content: [{ type: 'content', content: { type: 'text', text: q.question } }], + title: + questionCount === 1 + ? 'AskUserQuestion' + : `AskUserQuestion (${String(questionIndex + 1)}/${String(questionCount)})`, + content: [ + { type: 'content', content: { type: 'text', text: question.question } }, + ], }, }); - const answer = outcomeToQuestionAnswer(q, response); - if (answer === null) { - // Dismissed via skip / cancel / unknown optionId — telemetry - // matches the ask-user tool's existing `question_dismissed` - // event so dashboards stay coherent. - this.emitTelemetry('question_dismissed'); + const outcome = permissionResponseToQuestionOutcome( + question, + questionIndex, + response, + ); + if (outcome.kind === 'cancelled') { + return { cancelRemaining: true }; + } + if (outcome.kind === 'skip') { + return { cancelRemaining: false }; + } + if (outcome.kind === 'done') { + if (selectedOptions.size === 0) { + return { cancelRemaining: true }; + } + const answer = question.options + .filter((_option, optionIndex) => selectedOptions.has(optionIndex)) + .map((option) => option.label) + .join(', '); + return { answer, cancelRemaining: false }; + } + if (question.multiSelect !== true) { + return { + answer: question.options[outcome.optionIndex]!.label, + cancelRemaining: false, + }; + } + if (selectedOptions.has(outcome.optionIndex)) { + selectedOptions.delete(outcome.optionIndex); } else { - this.emitTelemetry('question_answered', { answered: Object.keys(answer).length }); + selectedOptions.add(outcome.optionIndex); } - return answer; - } catch (err) { - log.warn('acp: requestPermission (question) failed; dismissing', { - sessionId: this.id, - toolCallId: req.toolCallId, - error: err instanceof Error ? err.message : String(err), - }); - return null; } } diff --git a/packages/acp-adapter/test/question.test.ts b/packages/acp-adapter/test/question.test.ts index 8b4d22dc74..8b9cd5ded7 100644 --- a/packages/acp-adapter/test/question.test.ts +++ b/packages/acp-adapter/test/question.test.ts @@ -5,7 +5,11 @@ import type { import type { QuestionItem } from '@moonshot-ai/kimi-code-sdk'; import { describe, expect, it } from 'vitest'; -import { outcomeToQuestionAnswer, questionItemToPermissionOptions } from '../src/question'; +import { + outcomeToQuestionAnswer, + permissionResponseToQuestionOutcome, + questionItemToPermissionOptions, +} from '../src/question'; const sampleQuestion: QuestionItem = { question: 'Pick a flavour', @@ -62,6 +66,18 @@ describe('questionItemToPermissionOptions', () => { kind: 'reject_once', }); }); + + it('marks selected multi-select options and exposes Done after the first choice', () => { + const multi: QuestionItem = { ...sampleQuestion, multiSelect: true }; + const opts = questionItemToPermissionOptions(multi, 2, new Set([0, 2])); + expect(opts.map((option) => [option.optionId, option.name])).toEqual([ + ['q2_opt_0', '✓ Vanilla'], + ['q2_opt_1', 'Chocolate'], + ['q2_opt_2', '✓ Mint chip'], + ['q2_done', 'Done'], + ['q2_skip', 'Skip'], + ]); + }); }); describe('outcomeToQuestionAnswer', () => { @@ -98,4 +114,23 @@ describe('outcomeToQuestionAnswer', () => { it('defensively maps an out-of-bounds index to null', () => { expect(outcomeToQuestionAnswer(sampleQuestion, selected('q0_opt_99'))).toBeNull(); }); + + it('parses question-indexed options, Done, Skip, and cancellation', () => { + const multi: QuestionItem = { ...sampleQuestion, multiSelect: true }; + expect(permissionResponseToQuestionOutcome(multi, 2, selected('q2_opt_1'))).toEqual({ + kind: 'option', + optionIndex: 1, + }); + expect(permissionResponseToQuestionOutcome(multi, 2, selected('q2_done'))).toEqual({ + kind: 'done', + }); + expect(permissionResponseToQuestionOutcome(multi, 2, selected('q2_skip'))).toEqual({ + kind: 'skip', + }); + expect( + permissionResponseToQuestionOutcome(multi, 2, { + outcome: { outcome: 'cancelled' }, + }), + ).toEqual({ kind: 'cancelled' }); + }); }); diff --git a/packages/acp-adapter/test/session-question-handler.test.ts b/packages/acp-adapter/test/session-question-handler.test.ts index 28536a308c..59fa0112f2 100644 --- a/packages/acp-adapter/test/session-question-handler.test.ts +++ b/packages/acp-adapter/test/session-question-handler.test.ts @@ -63,6 +63,7 @@ function makeQuestionSession(sessionId: string): { */ class CapturingConn { readonly permissionRequests: RequestPermissionRequest[] = []; + replies: Array = []; reply: RequestPermissionResponse = { outcome: { outcome: 'selected', optionId: 'q0_opt_0' }, }; @@ -73,7 +74,9 @@ class CapturingConn { if (this.shouldThrow) { throw new Error('client unreachable'); } - return this.reply; + const nextReply = this.replies.shift(); + if (nextReply instanceof Error) throw nextReply; + return nextReply ?? this.reply; } async sessionUpdate(): Promise { /* not exercised */ @@ -166,7 +169,7 @@ describe('AcpSession.handleQuestion', () => { expect(trackCalls).toEqual([{ event: 'question_answered', properties: { answered: 1 } }]); }); - it('skip: q0_skip resolves to null with question_dismissed telemetry', async () => { + it('skip: q0_skip resolves with a skipped-question note', async () => { const { conn, raw } = makeConn(); const handle = makeQuestionSession('s-q-skip'); raw.reply = { outcome: { outcome: 'selected', optionId: 'q0_skip' } }; @@ -174,11 +177,14 @@ describe('AcpSession.handleQuestion', () => { const answer = await handle.invokeHandler(makeReq()); - expect(answer).toBeNull(); + expect(answer).toEqual({ + answers: {}, + note: 'User skipped 1 question: "哪个口味?".', + }); expect(trackCalls).toEqual([{ event: 'question_dismissed', properties: undefined }]); }); - it('cancelled: outcome cancelled resolves to null with question_dismissed', async () => { + it('cancelled: reports the current and remaining questions as skipped', async () => { const { conn, raw } = makeConn(); const handle = makeQuestionSession('s-q-cancel'); raw.reply = { outcome: { outcome: 'cancelled' } }; @@ -186,14 +192,21 @@ describe('AcpSession.handleQuestion', () => { const answer = await handle.invokeHandler(makeReq()); - expect(answer).toBeNull(); + expect(answer).toEqual({ + answers: {}, + note: 'User skipped 1 question: "哪个口味?".', + }); expect(trackCalls).toEqual([{ event: 'question_dismissed', properties: undefined }]); }); - it('multi-question degradation: 3 questions → only first asked + question_degraded', async () => { + it('asks every question in order and returns all non-skipped answers', async () => { const { conn, raw } = makeConn(); const handle = makeQuestionSession('s-q-multi'); - raw.reply = { outcome: { outcome: 'selected', optionId: 'q0_opt_1' } }; + raw.replies = [ + { outcome: { outcome: 'selected', optionId: 'q0_opt_1' } }, + { outcome: { outcome: 'selected', optionId: 'q1_opt_0' } }, + { outcome: { outcome: 'selected', optionId: 'q2_skip' } }, + ]; new AcpSession(conn, handle.session, undefined, track); const extra1: QuestionItem = { question: 'Q2', options: [{ label: 'a' }] }; @@ -202,24 +215,36 @@ describe('AcpSession.handleQuestion', () => { makeReq({ questions: [sampleQuestion, extra1, extra2] }), ); - expect(answer).toEqual({ '哪个口味?': '巧克力' }); - expect(raw.permissionRequests).toHaveLength(1); - // Telemetry: degraded(multi_question) first, then answered. + expect(answer).toEqual({ + answers: { '哪个口味?': '巧克力', Q2: 'a' }, + note: 'User skipped 1 question: "Q3".', + }); + expect(raw.permissionRequests).toHaveLength(3); + expect(raw.permissionRequests.map((request) => request.toolCall.title)).toEqual([ + 'AskUserQuestion (1/3)', + 'AskUserQuestion (2/3)', + 'AskUserQuestion (3/3)', + ]); + expect(raw.permissionRequests.map((request) => request.options[0]?.optionId)).toEqual([ + 'q0_opt_0', + 'q1_opt_0', + 'q2_opt_0', + ]); expect(trackCalls).toEqual([ - { event: 'question_degraded', properties: { reason: 'multi_question', dropped: 2 } }, - { event: 'question_answered', properties: { answered: 1 } }, + { event: 'question_answered', properties: { answered: 2 } }, ]); - // log.warn fired with the dropped count. - expect(warnSpy).toHaveBeenCalledWith( - expect.stringContaining('degrading to first question only'), - expect.objectContaining({ dropped: 2 }), - ); + expect(warnSpy).not.toHaveBeenCalled(); }); - it('multiSelect degradation: still asks the question + question_degraded', async () => { + it('toggles multi-select choices until Done and returns the remaining selections', async () => { const { conn, raw } = makeConn(); const handle = makeQuestionSession('s-q-multisel'); - raw.reply = { outcome: { outcome: 'selected', optionId: 'q0_opt_0' } }; + raw.replies = [ + { outcome: { outcome: 'selected', optionId: 'q0_opt_0' } }, + { outcome: { outcome: 'selected', optionId: 'q0_opt_1' } }, + { outcome: { outcome: 'selected', optionId: 'q0_opt_0' } }, + { outcome: { outcome: 'selected', optionId: 'q0_done' } }, + ]; new AcpSession(conn, handle.session, undefined, track); const multi: QuestionItem = { @@ -232,15 +257,87 @@ describe('AcpSession.handleQuestion', () => { questions: [multi], }); - expect(answer).toEqual({ 'Pick any': 'a' }); - expect(raw.permissionRequests).toHaveLength(1); + expect(answer).toEqual({ 'Pick any': 'b' }); + expect(raw.permissionRequests).toHaveLength(4); + expect(raw.permissionRequests[1]?.options.map((option) => option.name)).toEqual([ + '✓ a', + 'b', + 'Done', + 'Skip', + ]); + expect(raw.permissionRequests[2]?.options.map((option) => option.name)).toEqual([ + '✓ a', + '✓ b', + 'Done', + 'Skip', + ]); + expect(raw.permissionRequests[3]?.options.map((option) => option.name)).toEqual([ + 'a', + '✓ b', + 'Done', + 'Skip', + ]); + expect(trackCalls).toEqual([ + { event: 'question_answered', properties: { answered: 1 } }, + ]); + }); + + it('continues to later questions after Skip', async () => { + const { conn, raw } = makeConn(); + const handle = makeQuestionSession('s-q-skip-continue'); + raw.replies = [ + { outcome: { outcome: 'selected', optionId: 'q0_skip' } }, + { outcome: { outcome: 'selected', optionId: 'q1_opt_0' } }, + ]; + new AcpSession(conn, handle.session, undefined, track); + + const answer = await handle.invokeHandler( + makeReq({ + questions: [ + sampleQuestion, + { question: 'Q2', options: [{ label: 'second answer' }] }, + ], + }), + ); + + expect(answer).toEqual({ + answers: { Q2: 'second answer' }, + note: 'User skipped 1 question: "哪个口味?".', + }); + expect(raw.permissionRequests).toHaveLength(2); expect(trackCalls).toEqual([ - { event: 'question_degraded', properties: { reason: 'multi_select' } }, { event: 'question_answered', properties: { answered: 1 } }, ]); }); - it('requestPermission throw → log.warn + null', async () => { + it('distinguishes a skipped question from a later client failure', async () => { + const { conn, raw } = makeConn(); + const handle = makeQuestionSession('s-q-skip-then-throw'); + raw.replies = [ + { outcome: { outcome: 'selected', optionId: 'q0_skip' } }, + new Error('client unreachable'), + ]; + new AcpSession(conn, handle.session, undefined, track); + + const answer = await handle.invokeHandler( + makeReq({ + questions: [ + sampleQuestion, + { question: 'Q2', options: [{ label: 'second answer' }] }, + ], + }), + ); + + expect(answer).toEqual({ + answers: {}, + note: + 'User skipped 1 question: "哪个口味?". ' + + 'The client stopped before 1 question could be answered.', + }); + expect(raw.permissionRequests).toHaveLength(2); + }); + + it('requestPermission throw logs a warning and returns a client-stopped note', async () => { const { conn, raw } = makeConn(); const handle = makeQuestionSession('s-q-throw'); raw.shouldThrow = true; @@ -248,14 +345,17 @@ describe('AcpSession.handleQuestion', () => { const answer = await handle.invokeHandler(makeReq()); - expect(answer).toBeNull(); + expect(answer).toEqual({ + answers: {}, + note: 'The client stopped before 1 question could be answered.', + }); expect(warnSpy).toHaveBeenCalledWith( expect.stringContaining('requestPermission (question) failed'), expect.objectContaining({ toolCallId: 'tc-ask-1' }), ); - // No question_answered / question_dismissed emitted on throw — the - // RPC failure is its own observability path (log.warn above). - expect(trackCalls).toEqual([]); + expect(trackCalls).toEqual([ + { event: 'question_dismissed', properties: undefined }, + ]); }); it('no track sink: handler still runs without emitting telemetry', async () => { diff --git a/packages/agent-core-v2/docs/state-manifest.d.ts b/packages/agent-core-v2/docs/state-manifest.d.ts index 90050ef3ba..f3829ee52d 100644 --- a/packages/agent-core-v2/docs/state-manifest.d.ts +++ b/packages/agent-core-v2/docs/state-manifest.d.ts @@ -1010,7 +1010,7 @@ export interface AgentStateSnapshot { 'llmRequester.lastConfigLogSignature': string | undefined; 'llmRequester.mediaDegradedTurns': Set; 'llmRequester.mediaStrippedTurns': Map; 'llmRequester.turnConfigs': Map/tmp`). `toShellPath` renders native paths for bash command + * lines; `fromShellPath` resolves model/shell-supplied paths for fs access, + * translating drive-letter forms lexically and other root-relative paths + * through `cygpath -w` next to the probed bash. Anything unconvertible + * passes through unchanged, and both directions are identity outside win32 + * bash. + * + * Vendored from `@moonshot-ai/kaos` `shell-path-bridge.ts` — kept as a pure + * helper with no DI dependencies. + */ + +import { execFileSync as nodeExecFileSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import * as nodePath from 'node:path'; + +import type { HostEnvironmentInfo } from './environmentProbe'; + +export interface ShellPathBridge { + toShellPath(nativePath: string): string; + fromShellPath(path: string): string; +} + +export type ShellPathBridgeEnv = Pick; + +export interface ShellPathBridgeDeps { + readonly execFileSync: (file: string, args: readonly string[]) => string; + readonly isFile: (path: string) => boolean; +} + +const CYGPATH_TIMEOUT_MS = 5_000; + +const DRIVE_COLON_RE = /^\/([a-zA-Z]):(?:[\\/]|$)/; +const CYGDRIVE_RE = /^\/cygdrive\/([a-zA-Z])(?:\/|$)/; +const DRIVE_RE = /^\/([a-zA-Z])(?:\/|$)/; + +const VIRTUAL_FS_PREFIXES: readonly string[] = ['/dev/', '/proc/', '/sys/']; + +const WIN32_DRIVE_ABSOLUTE_RE = /^[A-Za-z]:[\\/]/; + +function joinDrive(letter: string, rest: string): string { + const normalizedRest = rest.replaceAll('\\', '/'); + return normalizedRest === '' + ? `${letter.toUpperCase()}:/` + : `${letter.toUpperCase()}:${normalizedRest}`; +} + +export function translateShellDrivePath(path: string): string { + const colonMatch = DRIVE_COLON_RE.exec(path); + if (colonMatch !== null) { + return joinDrive(colonMatch[1]!, path.slice(3)); + } + const cygdriveMatch = CYGDRIVE_RE.exec(path); + if (cygdriveMatch !== null) { + return joinDrive(cygdriveMatch[1]!, path.slice(`/cygdrive/${cygdriveMatch[1]!}`.length)); + } + const driveMatch = DRIVE_RE.exec(path); + if (driveMatch !== null) { + return joinDrive(driveMatch[1]!, path.slice(2)); + } + return path; +} + +export function createShellPathBridge( + env: ShellPathBridgeEnv, + deps: ShellPathBridgeDeps, +): ShellPathBridge { + const enabled = env.osKind === 'Windows' && env.shellName === 'bash'; + + let cygpathExe: string | null | undefined; + const segmentCache = new Map(); + + function locateCygpath(): string | null { + if (cygpathExe !== undefined) return cygpathExe; + const shellDir = nodePath.win32.dirname(env.shellPath); + const candidates = [nodePath.win32.join(shellDir, 'cygpath.exe')]; + if (nodePath.win32.basename(shellDir).toLowerCase() === 'bin') { + candidates.push(nodePath.win32.join(shellDir, '..', 'usr', 'bin', 'cygpath.exe')); + } + cygpathExe = candidates.find((candidate) => deps.isFile(candidate)) ?? null; + return cygpathExe; + } + + function resolveRootSegment(firstSegment: string): string | null { + const cached = segmentCache.get(firstSegment); + if (cached !== undefined) return cached; + + const exe = locateCygpath(); + if (exe === null) return null; + let resolved: string; + try { + const output = deps.execFileSync(exe, ['-w', '-C', 'UTF8', '--', `/${firstSegment}`]); + const trimmed = output.replace(/\r?\n$/, ''); + if (!WIN32_DRIVE_ABSOLUTE_RE.test(trimmed) && !trimmed.startsWith('\\\\')) return null; + resolved = trimmed.replace(/[\\/]$/, ''); + } catch { + return null; + } + segmentCache.set(firstSegment, resolved); + return resolved; + } + + function fromShellPath(path: string): string { + if (!enabled) return path; + + if (path.startsWith('//')) return path; + + if (path.startsWith('/')) { + const normalized = nodePath.posix.normalize(path); + const lexical = translateShellDrivePath(normalized); + if (lexical !== normalized) return lexical; + if (normalized === '/') return normalized; + if (VIRTUAL_FS_PREFIXES.some((prefix) => normalized.startsWith(prefix))) return normalized; + const firstSegment = normalized.slice(1).split('/')[0]!; + const prefix = resolveRootSegment(firstSegment); + if (prefix === null) return normalized; + const remainder = normalized.slice(firstSegment.length + 1); + const joined = `${prefix}${remainder}`.replaceAll('\\', '/'); + return /^[A-Za-z]:$/.test(joined) ? `${joined}/` : joined; + } + + return path; + } + + function toShellPath(nativePath: string): string { + if (!enabled) return nativePath; + + if (nativePath.startsWith('\\\\')) { + return nativePath.replaceAll('\\', '/'); + } + + const driveMatch = /^([A-Za-z]):(?:[\\/]|$)/.exec(nativePath); + if (driveMatch !== null) { + const drive = driveMatch[1]!.toLowerCase(); + const rest = nativePath.slice(2).replaceAll('\\', '/'); + return `/${drive}${rest.startsWith('/') ? rest : `/${rest}`}`; + } + + return nativePath.replaceAll('\\', '/'); + } + + return { toShellPath, fromShellPath }; +} + +const bridgeCache = new WeakMap(); + +export function getShellPathBridge(env: ShellPathBridgeEnv): ShellPathBridge { + const cached = bridgeCache.get(env); + if (cached !== undefined) return cached; + const bridge = createShellPathBridge(env, { + execFileSync: (file, args) => + nodeExecFileSync(file, [...args], { + encoding: 'utf8', + timeout: CYGPATH_TIMEOUT_MS, + windowsHide: true, + }), + isFile: (path) => existsSync(path), + }); + bridgeCache.set(env, bridge); + return bridge; +} diff --git a/packages/agent-core-v2/src/_base/utils/abort.ts b/packages/agent-core-v2/src/_base/utils/abort.ts index 09b2860fb0..be74262747 100644 --- a/packages/agent-core-v2/src/_base/utils/abort.ts +++ b/packages/agent-core-v2/src/_base/utils/abort.ts @@ -31,15 +31,28 @@ export function isUserCancellation(value: unknown): value is UserCancellationErr } export function abortable(promise: Promise, signal: AbortSignal): Promise { - if (signal.aborted) return Promise.reject(abortReason(signal)); return new Promise((resolve, reject) => { const onAbort = () => { reject(abortReason(signal)); }; - signal.addEventListener('abort', onAbort, { once: true }); - promise.then(resolve, reject).finally(() => { + const cleanup = () => { signal.removeEventListener('abort', onAbort); - }); + }; + promise.then( + (value) => { + cleanup(); + resolve(value); + }, + (error: unknown) => { + cleanup(); + reject(error); + }, + ); + if (signal.aborted) { + onAbort(); + return; + } + signal.addEventListener('abort', onAbort, { once: true }); }); } diff --git a/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts b/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts index 057bebd882..32924ef110 100644 --- a/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts +++ b/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts @@ -8,8 +8,10 @@ * params, then drives a bounded request chain through the `ModelRequester` * resolved from `IModelCatalog`: one primary `requester.request(input, signal, * params)` attempt plus projection rebuilds for request structure or media - * compatibility; general retry policy remains in the loop's `stepRetry` - * plugin. Before each request the projected messages pass through `media`'s + * compatibility and a one-shot completion-budget clamp when the provider + * rejects `max_tokens` (the server-declared ceiling is remembered per model, + * so each model pays for at most one rejected-and-resent request); general + * retry policy remains in the loop's `stepRetry` plugin. Before each request the projected messages pass through `media`'s * video resolver, which rewrites every `kimi-file://` prompt-video reference * to a provider-acceptable part (uploaded `ms://`, inline base64, or a * `