From c453d70f6ddb4740e318e0c1fd62ded1fd7d8908 Mon Sep 17 00:00:00 2001 From: Ji Zhang Date: Wed, 8 Jul 2026 00:03:30 +0800 Subject: [PATCH 01/11] feat: add architecture.md --- README-en.md | 8 +++++++ README-zh_CN.md | 7 ++++++ README.md | 7 ++++++ docs/architecture.md | 49 +++++++++++++++++++++++++++++++++++++++++ docs/architecture_en.md | 49 +++++++++++++++++++++++++++++++++++++++++ 5 files changed, 120 insertions(+) create mode 100644 docs/architecture.md create mode 100644 docs/architecture_en.md diff --git a/README-en.md b/README-en.md index 453dd64c..e001940d 100644 --- a/README-en.md +++ b/README-en.md @@ -98,6 +98,14 @@ Skills are discovered from these locations, in priority order: - `deepseek-v4-flash` - Any other OpenAI-compatible model +## Architecture and Benchmarks + +In ["Better Models: Worse Tools"](https://lucumr.pocoo.org/2026/7/4/better-models-worse-tools/), Armin Ronacher argues that tool schemas are not "neutral": models (LLMs) inherit tool-use habits formed during training and reinforcement learning, so they may perform well in one mainstream harness but become unstable with a different tool shape. This is the architectural starting point for Deep Code: it is tuned specifically for DeepSeek, so the harness itself can stay aligned with DeepSeek's behavior. + +Deep Code's gains come from the combined effect of tool constraints, context management, Agent Skills, permission policy, and other architectural decisions. The [deepcode-qrcode-benchmark](https://github.com/qorzj/deepcode-qrcode-benchmark) project shows that on a real and challenging Python requirement, Deep Code + DeepSeek + `/plan` mode has an effectiveness advantage over Claude Code + DeepSeek. + +> See also: [Deep Code Architecture](docs/architecture_en.md) + ## FAQ ### Does Deep Code have a VSCode extension? diff --git a/README-zh_CN.md b/README-zh_CN.md index cde02314..591d1345 100644 --- a/README-zh_CN.md +++ b/README-zh_CN.md @@ -97,6 +97,13 @@ Skills 会按以下优先级扫描: - `deepseek-v4-flash` - 任何其他 OpenAI 兼容模型 +## 架构和基准测试 + +Armin Ronacher 在[《更好的模型,更糟的工具》](https://lucumr.pocoo.org/2026/7/4/better-models-worse-tools/)中指出,工具 schema 不是「中立的」:模型(LLM)会继承训练和强化学习中形成的工具使用习惯,因此可能在某个主流 harness 中表现很好,却在另一套工具形态下变得不稳定。这正是 Deep Code 的架构出发点:只为 DeepSeek 量身调优,从而让 harness 本身持续贴合 DeepSeek 的行为特点。 + +Deep Code 的收益来自于工具约束、上下文管理、Agent Skills 和权限策略等多项设计叠加后的结果。[deepcode-qrcode-benchmark](https://github.com/qorzj/deepcode-qrcode-benchmark) 项目展示了在一个真实且有难度的 Python 需求上,Deep Code + DeepSeek + `/plan` 模式相较 Claude Code + DeepSeek 的组合具有效果优势。 + +> 详见:[Deep Code 架构](docs/architecture.md) ## 常见问题 diff --git a/README.md b/README.md index 39cd12bd..33ac7da7 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,13 @@ Skills 会按以下优先级扫描: - `deepseek-v4-flash` - 任何其他 OpenAI 兼容模型 +## 架构和基准测试 + +Armin Ronacher 在[《更好的模型,更糟的工具》](https://lucumr.pocoo.org/2026/7/4/better-models-worse-tools/)中指出,工具 schema 不是「中立的」:模型(LLM)会继承训练和强化学习中形成的工具使用习惯,因此可能在某个主流 harness 中表现很好,却在另一套工具形态下变得不稳定。这正是 Deep Code 的架构出发点:只为 DeepSeek 量身调优,从而让 harness 本身持续贴合 DeepSeek 的行为特点。 + +Deep Code 的收益来自于工具约束、上下文管理、Agent Skills 和权限策略等多项设计叠加后的结果。[deepcode-qrcode-benchmark](https://github.com/qorzj/deepcode-qrcode-benchmark) 项目展示了在一个真实且有难度的 Python 需求上,Deep Code + DeepSeek + `/plan` 模式相较 Claude Code + DeepSeek 的组合具有效果优势。 + +> 详见:[Deep Code 架构](docs/architecture.md) ## 常见问题 diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 00000000..f28a5a10 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,49 @@ +# Deep Code 架构:围绕DeepSeek模型构建的harness + +Coding Agent的质量不是由模型单独决定的,而是模型(LLM)与其执行框架(harness)共同构成的系统决定的。 + +Deep Code的终极目标:在智能编码任务上,Deep Code 应当以比“Claude Code + DeepSeek”这套组合更低的成本,取得更好的效果。实现路径不是幻想用一个通用框架让所有模型都表现最佳,而是不断将框架适配最新的 DeepSeek 模型,让模型看到的工具形态、上下文布局、安全规则和恢复路径,都契合它实际的行为方式。 + +## 这个目标为什么可行 + +Armin Ronacher 的文章[《更好的模型,更糟的工具》](https://lucumr.pocoo.org/2026/7/4/better-models-worse-tools/)指出了一个值得重视的事实:工具 schema 并非「中立」。模型不会把工具模式当作纯粹的抽象约定来遵守,而是带着训练和强化学习过程中形成的使用习惯去接触它。如果某个厂商主要针对一种主流框架训练模型,那么模型可能会非常擅长那个框架的工具生态,却在面对形态不同的工具时意外地不可靠。能力更强的模型可能形成更强的习惯,而更强的习惯会让它更排斥陌生的工具。 + +这一观察正是 Deep Code 设计的核心依据。Claude Code 是一个为 Anthropic 模型优化的闭源框架,没有理由把 DeepSeek 当作头等对象来对待。而 Deep Code 的选择则是成为 DeepSeek 生态的一部分,它应该为 DeepSeek 量身调优,而不仅仅是“兼容”。 + +## 核心设计一:通过 snippet 修复工具调用 + +传统编辑工具往往要求模型提供文件路径以及大段的 `old_string` 和 `new_string`。这看起来直接,但在真实应用场景中,会有多种典型失败:模型可能编辑过时了的文件视图,匹配到错误的重复块,错误地带上行号,丢失缩进,过度替换,或者生成转义错误的 JSON。结果要么是工具调用失败,要么更糟,甚至产生一个看上去合理、实际错误的文件改动。 + +Deep Code 给出的方案是片段(snippet)系统。`read` 工具除了返回文件内容,还会在文本文件上维护一个会话本地的文件状态,并在元数据中返回 `snippet_id`。`edit` 工具随后将这个 `snippet_id` 作为必填参数。片段携带了文件路径、行范围、预览、版本和范围类型信息。 + +这重塑了编辑的约定:文件必须先被读过才能编辑,片段必须在当前会话中,文件自读取后未被改动,替换只在片段范围内搜索,非唯一匹配会返回候选片段而不是直接猜测,批量替换可以要求声明预期出现次数。 + +这是一种理解模型行为、而非放任模型的修复策略。它不强求模型在压力下保持完美,而是让正确操作更容易表达,同时让框架掌握足以发现歧义的局部信息。在接口校验上它保持严格,但在编码智能体常见的、可恢复的文本错误上又给予宽容,使智能体在意图清晰时可以继续向前推进。 + +内置工具有意保持少而精:`bash`、`read`、`write`、`edit`、`AskUserQuestion`、`UpdatePlan` 和 `WebSearch`,外部 MCP 工具则动态挂载。这是一个刻意的设计决定,它降低了模式的不确定性,让权限分析变得可行,也给了模型一套可预期、可重复的操作语言。 + +## 核心设计二:缓存感知的上下文管理 + +第二个核心设计是用上下文缓存控制成本。DeepSeek 的上下文缓存默认启用,当后续请求完全复用了已缓存的前缀单元,就能命中缓存,并在返回值中给出缓存命中与未命中的token数。这是一套尽力而为的系统,但它确实奖励那些稳定的重复前缀。 + +Deep Code 的会话架构正是围绕这一特性设计的,而且不需要用户刻意配合。系统提示、工具文档、默认技能、运行时上下文和项目说明,这些稳定内容都被放在易变的用户内容之前。会话消息以 JSONL 持久化,并能被一致地重放。工具调用与工具结果的配对在转换时会被修复,包括中断的工具调用,以保证发回给模型的对话始终保持结构有效。 + +## 核心设计三:以Agent Skills为核心的上下文工程 + +一个编码智能体不该把所有的知识都塞进上下文里,那样会污染上下文、推高成本,还会削弱指令的优先级。与此同时,很多任务又确实需要可复用的知识:代码审查流程、领域约定、框架特定模式等等。Agent Skills就是在这些知识真正需要时,按需加载的机制。 + +自动匹配同样借助了模型本身:系统将候选技能的名称和描述发给模型,由模型返回应匹配的技能。已加载的技能不会重复加载,技能也可以声明不参与隐式调用。这一设计让基础框架保持精干,同时又允许丰富的任务特定行为,也使得技能得以跨工具移植。 + +更深层的架构含义是,技能并非传统意义上的插件,而是被结构化的上下文。它让框架来决定何时把指令、示例、模板、脚本和参考文件引入对话。对于泛化能力强但并非完美的模型来说,这恰恰是最合适的抽象:让默认环境保持干净,在真正能产生帮助的时刻,再注入精准的先验知识。 + +## 核心设计四:基于副作用分类的权限系统 + +智能编码必然伴随真实的副作用:读写文件、运行 Shell 命令、访问网络、调用外部工具。一个只提供“全自动模式”的框架不安全,事事弹窗询问又太慢。Deep Code 的创新是引入基于副作用分类的作用域策略。 + +权限系统定义了具体的作用域,例如目录内外的读、写、删除,Git 日志的查询与修改,网络,MCP 等。`bash` 工具要求模型声明本次操作的副作用,文件工具则根据路径直接分类。 + +这不仅仅是安全功能,它本身就是智能体质量的一部分。权限给模型提供了一个可预测的操作边界,低风险工作可以快速推进,高风险动作则会停下来。它也让命令行为变得可审查:一条命令不只是待执行的文本,而是文本、所声明的副作用以及策略决策的组合。 + +## 基准测试 + +Deep Code 的优势并非来自某个单点妙招,而是源自一系列决策的叠加效应。[deepcode-qrcode-benchmark](https://github.com/qorzj/deepcode-qrcode-benchmark)项目展示了在一个真实且有难度的Python需求上,Deep Code+DeepSeek+`/plan`模式的组合总是能够胜过Claude Code+DeepSeek的组合。 diff --git a/docs/architecture_en.md b/docs/architecture_en.md new file mode 100644 index 00000000..9af59db2 --- /dev/null +++ b/docs/architecture_en.md @@ -0,0 +1,49 @@ +# Deep Code Architecture: A Harness Built Around the DeepSeek Model + +The quality of a Coding Agent is not determined by the model alone. It is determined by the system formed by the model (LLM) and its execution framework (harness). + +Deep Code's ultimate goal is this: on intelligent coding tasks, Deep Code should achieve better results than the "Claude Code + DeepSeek" combination, while costing less. The path to that goal is not to imagine that one generic framework can make every model perform at its best. Instead, it is to continuously adapt the framework to the latest DeepSeek models, so that the tool shapes, context layout, safety rules, and recovery paths seen by the model all match how it actually behaves. + +## Why This Goal Is Feasible + +Armin Ronacher's article ["Better Models: Worse Tools"](https://lucumr.pocoo.org/2026/7/4/better-models-worse-tools/) points out an important fact: tool schemas are not "neutral." Models do not treat tool schemas as pure abstract contracts to follow. They approach them with usage habits formed during training and reinforcement learning. If a vendor primarily trains a model against one mainstream framework, the model may become very good at that framework's tool ecosystem, yet unexpectedly unreliable when facing tools with a different shape. A more capable model may form stronger habits, and stronger habits can make it more resistant to unfamiliar tools. + +This observation is the core basis for Deep Code's design. Claude Code is a closed-source framework optimized for Anthropic models, and it has no reason to treat DeepSeek as a first-class target. Deep Code makes a different choice: it becomes part of the DeepSeek ecosystem. It should be tuned specifically for DeepSeek, not merely be "compatible" with it. + +## Core Design 1: Repairing Tool Calls Through Snippets + +Traditional editing tools often require the model to provide a file path plus large `old_string` and `new_string` payloads. That looks straightforward, but in real-world scenarios it has many typical failure modes: the model may edit a stale view of a file, match the wrong repeated block, accidentally include line numbers, lose indentation, over-replace text, or generate JSON with incorrect escaping. The result is either a failed tool call or, worse, a plausible-looking but actually incorrect file change. + +Deep Code's solution is the snippet system. In addition to returning file contents, the `read` tool maintains session-local file state for text files and returns a `snippet_id` in metadata. The `edit` tool then requires this `snippet_id` as a mandatory parameter. A snippet carries the file path, line range, preview, version, and scope type. + +This reshapes the editing contract: a file must be read before it can be edited; the snippet must exist in the current session; the file must not have changed since it was read; replacement is searched only within the snippet's scope; non-unique matches return candidate snippets instead of guessing; and bulk replacement can require declaring the expected number of occurrences. + +This is a repair strategy that understands model behavior rather than indulging it. It does not require the model to stay perfect under pressure. Instead, it makes the correct operation easier to express while giving the framework enough local information to detect ambiguity. It remains strict at the interface validation layer, but is tolerant of common, recoverable text mistakes made by coding agents, allowing the agent to keep moving forward when the intent is clear. + +The built-in tools are intentionally small and focused: `bash`, `read`, `write`, `edit`, `AskUserQuestion`, `UpdatePlan`, and `WebSearch`, while external MCP tools are mounted dynamically. This is a deliberate design decision. It reduces schema uncertainty, makes permission analysis feasible, and gives the model a predictable and repeatable action language. + +## Core Design 2: Cache-Aware Context Management + +The second core design is using context caching to control cost. DeepSeek's context cache is enabled by default. When a later request fully reuses a cached prefix unit, it can hit the cache, and the response reports the number of cache-hit and cache-miss tokens. This is a best-effort system, but it does reward stable repeated prefixes. + +Deep Code's session architecture is designed around this property, without requiring users to cooperate manually. Stable content such as the system prompt, tool documentation, default skills, runtime context, and project instructions is placed before volatile user content. Session messages are persisted as JSONL and can be replayed consistently. Tool-call and tool-result pairings are repaired during conversion, including interrupted tool calls, so the conversation sent back to the model always remains structurally valid. + +## Core Design 3: Context Engineering Centered on Agent Skills + +A coding agent should not stuff all knowledge into its context. Doing so pollutes the context, raises cost, and weakens instruction priority. At the same time, many tasks do require reusable knowledge: code review workflows, domain conventions, framework-specific patterns, and so on. Agent Skills are the mechanism for loading this knowledge on demand, exactly when it is needed. + +Automatic matching also uses the model itself: the system sends candidate skill names and descriptions to the model, and the model returns the skills that should match. Already-loaded skills are not loaded again, and skills can declare that they should not participate in implicit invocation. This design keeps the base framework lean while allowing rich task-specific behavior, and it also makes skills portable across tools. + +The deeper architectural meaning is that skills are not plugins in the traditional sense. They are structured context. They let the framework decide when to introduce instructions, examples, templates, scripts, and reference files into the conversation. For models with strong but imperfect generalization, this is exactly the right abstraction: keep the default environment clean, and inject precise prior knowledge only at the moment when it is truly helpful. + +## Core Design 4: A Permission System Based on Side-Effect Classification + +Intelligent coding inevitably involves real side effects: reading and writing files, running shell commands, accessing the network, and calling external tools. A framework that only provides a "fully automatic mode" is unsafe, while prompting for everything is too slow. Deep Code's innovation is to introduce a scope policy based on side-effect classification. + +The permission system defines concrete scopes, such as reading, writing, and deleting inside or outside the working directory; querying and mutating Git history; network access; MCP; and so on. The `bash` tool requires the model to declare the side effects of the current operation, while file tools are classified directly based on their paths. + +This is not merely a safety feature. It is itself part of agent quality. Permissions give the model a predictable operating boundary: low-risk work can proceed quickly, while high-risk actions stop for confirmation. It also makes command behavior auditable: a command is not just text waiting to be executed, but a combination of text, declared side effects, and a policy decision. + +## Benchmark + +Deep Code's advantage does not come from a single clever trick. It comes from the compounding effect of a series of decisions. The [deepcode-qrcode-benchmark](https://github.com/qorzj/deepcode-qrcode-benchmark) project shows that on a real and challenging Python requirement, the combination of Deep Code + DeepSeek + `/plan` mode can consistently outperform the combination of Claude Code + DeepSeek. From 82a3754c1957195889dd5393ab2f30a6451981d9 Mon Sep 17 00:00:00 2001 From: Ji Zhang Date: Wed, 8 Jul 2026 00:09:14 +0800 Subject: [PATCH 02/11] type: revise the article title in English --- README-zh_CN.md | 2 +- README.md | 2 +- docs/architecture.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README-zh_CN.md b/README-zh_CN.md index 591d1345..00ccda3b 100644 --- a/README-zh_CN.md +++ b/README-zh_CN.md @@ -99,7 +99,7 @@ Skills 会按以下优先级扫描: ## 架构和基准测试 -Armin Ronacher 在[《更好的模型,更糟的工具》](https://lucumr.pocoo.org/2026/7/4/better-models-worse-tools/)中指出,工具 schema 不是「中立的」:模型(LLM)会继承训练和强化学习中形成的工具使用习惯,因此可能在某个主流 harness 中表现很好,却在另一套工具形态下变得不稳定。这正是 Deep Code 的架构出发点:只为 DeepSeek 量身调优,从而让 harness 本身持续贴合 DeepSeek 的行为特点。 +Armin Ronacher 在[《Better Models: Worse Tools》](https://lucumr.pocoo.org/2026/7/4/better-models-worse-tools/)中指出,工具 schema 不是「中立的」:模型(LLM)会继承训练和强化学习中形成的工具使用习惯,因此可能在某个主流 harness 中表现很好,却在另一套工具形态下变得不稳定。这正是 Deep Code 的架构出发点:只为 DeepSeek 量身调优,从而让 harness 本身持续贴合 DeepSeek 的行为特点。 Deep Code 的收益来自于工具约束、上下文管理、Agent Skills 和权限策略等多项设计叠加后的结果。[deepcode-qrcode-benchmark](https://github.com/qorzj/deepcode-qrcode-benchmark) 项目展示了在一个真实且有难度的 Python 需求上,Deep Code + DeepSeek + `/plan` 模式相较 Claude Code + DeepSeek 的组合具有效果优势。 diff --git a/README.md b/README.md index 33ac7da7..834a73e7 100644 --- a/README.md +++ b/README.md @@ -99,7 +99,7 @@ Skills 会按以下优先级扫描: ## 架构和基准测试 -Armin Ronacher 在[《更好的模型,更糟的工具》](https://lucumr.pocoo.org/2026/7/4/better-models-worse-tools/)中指出,工具 schema 不是「中立的」:模型(LLM)会继承训练和强化学习中形成的工具使用习惯,因此可能在某个主流 harness 中表现很好,却在另一套工具形态下变得不稳定。这正是 Deep Code 的架构出发点:只为 DeepSeek 量身调优,从而让 harness 本身持续贴合 DeepSeek 的行为特点。 +Armin Ronacher 在[《Better Models: Worse Tools》](https://lucumr.pocoo.org/2026/7/4/better-models-worse-tools/)中指出,工具 schema 不是「中立的」:模型(LLM)会继承训练和强化学习中形成的工具使用习惯,因此可能在某个主流 harness 中表现很好,却在另一套工具形态下变得不稳定。这正是 Deep Code 的架构出发点:只为 DeepSeek 量身调优,从而让 harness 本身持续贴合 DeepSeek 的行为特点。 Deep Code 的收益来自于工具约束、上下文管理、Agent Skills 和权限策略等多项设计叠加后的结果。[deepcode-qrcode-benchmark](https://github.com/qorzj/deepcode-qrcode-benchmark) 项目展示了在一个真实且有难度的 Python 需求上,Deep Code + DeepSeek + `/plan` 模式相较 Claude Code + DeepSeek 的组合具有效果优势。 diff --git a/docs/architecture.md b/docs/architecture.md index f28a5a10..d1e0f59d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -6,7 +6,7 @@ Deep Code的终极目标:在智能编码任务上,Deep Code 应当以比“C ## 这个目标为什么可行 -Armin Ronacher 的文章[《更好的模型,更糟的工具》](https://lucumr.pocoo.org/2026/7/4/better-models-worse-tools/)指出了一个值得重视的事实:工具 schema 并非「中立」。模型不会把工具模式当作纯粹的抽象约定来遵守,而是带着训练和强化学习过程中形成的使用习惯去接触它。如果某个厂商主要针对一种主流框架训练模型,那么模型可能会非常擅长那个框架的工具生态,却在面对形态不同的工具时意外地不可靠。能力更强的模型可能形成更强的习惯,而更强的习惯会让它更排斥陌生的工具。 +Armin Ronacher 的文章[《Better Models: Worse Tools》](https://lucumr.pocoo.org/2026/7/4/better-models-worse-tools/)指出了一个值得重视的事实:工具 schema 并非「中立」。模型不会把工具模式当作纯粹的抽象约定来遵守,而是带着训练和强化学习过程中形成的使用习惯去接触它。如果某个厂商主要针对一种主流框架训练模型,那么模型可能会非常擅长那个框架的工具生态,却在面对形态不同的工具时意外地不可靠。能力更强的模型可能形成更强的习惯,而更强的习惯会让它更排斥陌生的工具。 这一观察正是 Deep Code 设计的核心依据。Claude Code 是一个为 Anthropic 模型优化的闭源框架,没有理由把 DeepSeek 当作头等对象来对待。而 Deep Code 的选择则是成为 DeepSeek 生态的一部分,它应该为 DeepSeek 量身调优,而不仅仅是“兼容”。 From 6f0f3ecac5f7ec023d8309246f317f28634e2b09 Mon Sep 17 00:00:00 2001 From: Ji Zhang Date: Wed, 15 Jul 2026 13:00:37 +0800 Subject: [PATCH 03/11] feat: improve Plan Mode functionality with associated commands and UI components --- packages/cli/src/cli-args.ts | 2 + .../cli/src/tests/prompt-input-keys.test.ts | 20 ++++ packages/cli/src/tests/slash-commands.test.ts | 8 ++ packages/cli/src/ui/core/slash-commands.ts | 7 ++ packages/cli/src/ui/index.ts | 5 + packages/cli/src/ui/views/App.tsx | 38 ++++++ .../src/ui/views/PlanImplementationPrompt.tsx | 75 ++++++++++++ packages/cli/src/ui/views/PromptInput.tsx | 24 +++- packages/core/src/index.ts | 1 + packages/core/src/prompt.ts | 10 ++ packages/core/src/session.ts | 36 +++++- packages/core/src/tests/prompt.test.ts | 7 ++ packages/core/src/tests/session.test.ts | 112 ++++++++---------- .../bundled/plan/SKILL.md => prompts/plan.md} | 5 - 14 files changed, 280 insertions(+), 70 deletions(-) create mode 100644 packages/cli/src/ui/views/PlanImplementationPrompt.tsx rename packages/core/templates/{skills/bundled/plan/SKILL.md => prompts/plan.md} (97%) diff --git a/packages/cli/src/cli-args.ts b/packages/cli/src/cli-args.ts index b86eda41..5a1d6f44 100644 --- a/packages/cli/src/cli-args.ts +++ b/packages/cli/src/cli-args.ts @@ -47,6 +47,7 @@ const EPILOG = [ "Inside the TUI:", " enter Send the prompt", " shift+enter Insert a newline", + " shift+tab Cycle Plan Mode for the next submitted prompt", " home/end Move within the current line", " alt+left/right Move by word", " ctrl+w Delete the previous word", @@ -56,6 +57,7 @@ const EPILOG = [ " / Open the skills/commands menu", " /skills List available skills", " /model Select model, thinking mode and effort control", + " /plan Switch the input to Plan Mode", " /new Start a fresh conversation", " /init Initialize an AGENTS.md file with instructions for LLM", " /resume Pick a previous conversation to continue", diff --git a/packages/cli/src/tests/prompt-input-keys.test.ts b/packages/cli/src/tests/prompt-input-keys.test.ts index 0c5773cf..156fe436 100644 --- a/packages/cli/src/tests/prompt-input-keys.test.ts +++ b/packages/cli/src/tests/prompt-input-keys.test.ts @@ -22,6 +22,8 @@ import { renderBufferWithCursor, buildInitPromptSubmission, buildPromptDraftFromSessionMessage, + extractProposedPlan, + getImplementationPrompt, disableTerminalExtendedKeys, enableTerminalExtendedKeys, EMPTY_BUFFER, @@ -140,6 +142,24 @@ test("parseTerminalInput recognizes shifted return sequences", () => { assert.equal(key.meta, false); }); +test("parseTerminalInput recognizes shift+tab for Plan Mode cycling", () => { + const { input, key } = parseTerminalInput("\u001B[Z"); + assert.equal(input, ""); + assert.equal(key.tab, true); + assert.equal(key.shift, true); +}); + +test("extractProposedPlan only returns a complete non-empty plan", () => { + assert.equal(extractProposedPlan("\nPlan\n"), "Plan"); + assert.equal(extractProposedPlan("Plan"), null); + assert.equal(extractProposedPlan("\n"), null); +}); + +test("getImplementationPrompt uses Chinese only above five full-width punctuation marks", () => { + assert.equal(getImplementationPrompt(",、;。;"), "Implement the plan."); + assert.equal(getImplementationPrompt(",、;。;。"), "实现此方案。"); +}); + test("prompt return key action submits on plain enter", () => { const { key } = parseTerminalInput("\r"); assert.equal(getPromptReturnKeyAction(key), "submit"); diff --git a/packages/cli/src/tests/slash-commands.test.ts b/packages/cli/src/tests/slash-commands.test.ts index 420e5a48..94ce95a4 100644 --- a/packages/cli/src/tests/slash-commands.test.ts +++ b/packages/cli/src/tests/slash-commands.test.ts @@ -22,6 +22,7 @@ test("buildSlashCommands prefixes skills before built-ins", () => { assert.deepEqual(builtinNames, [ "skills", "model", + "plan", "new", "init", "resume", @@ -98,6 +99,13 @@ test("findExactSlashCommand returns built-in /model", () => { assert.equal(item?.kind, "model"); }); +test("findExactSlashCommand returns built-in /plan", () => { + const items = buildSlashCommands(skills); + const item = findExactSlashCommand(items, "/plan"); + assert.ok(item); + assert.equal(item?.kind, "plan"); +}); + test("findExactSlashCommand returns built-in /raw", () => { const items = buildSlashCommands(skills); const item = findExactSlashCommand(items, "/raw"); diff --git a/packages/cli/src/ui/core/slash-commands.ts b/packages/cli/src/ui/core/slash-commands.ts index ba5ae6ec..38646611 100644 --- a/packages/cli/src/ui/core/slash-commands.ts +++ b/packages/cli/src/ui/core/slash-commands.ts @@ -4,6 +4,7 @@ export type SlashCommandKind = | "skill" | "skills" | "model" + | "plan" | "new" | "init" | "resume" @@ -35,6 +36,12 @@ export const BUILTIN_SLASH_COMMANDS: SlashCommandItem[] = [ label: "/model", description: "Select model, thinking mode and effort control", }, + { + kind: "plan", + name: "plan", + label: "/plan", + description: "Switch the input to Plan Mode", + }, { kind: "new", name: "new", diff --git a/packages/cli/src/ui/index.ts b/packages/cli/src/ui/index.ts index 65415464..b248e2f5 100644 --- a/packages/cli/src/ui/index.ts +++ b/packages/cli/src/ui/index.ts @@ -15,6 +15,11 @@ export { } from "./hooks/cursor"; export { default as AppContainer } from "./views/AppContainer"; export { AskUserQuestionPrompt } from "./views/AskUserQuestionPrompt"; +export { + PlanImplementationPrompt, + extractProposedPlan, + getImplementationPrompt, +} from "./views/PlanImplementationPrompt"; export { MessageView } from "./components"; export { parseDiffPreview } from "./components/MessageView/utils"; export { diff --git a/packages/cli/src/ui/views/App.tsx b/packages/cli/src/ui/views/App.tsx index 3b2886cd..eb30dd96 100644 --- a/packages/cli/src/ui/views/App.tsx +++ b/packages/cli/src/ui/views/App.tsx @@ -20,6 +20,7 @@ import { formatAskUserQuestionAnswers, } from "../core/ask-user-question"; import { PermissionPrompt, type PermissionPromptResult } from "./PermissionPrompt"; +import { PlanImplementationPrompt, extractProposedPlan, getImplementationPrompt } from "./PlanImplementationPrompt"; import { buildExitSummaryText, buildResumeHintText } from "../exit-summary"; import { RawMode, useRawModeContext } from "../contexts"; import { renderMessageToStdout } from "../components/MessageView/utils"; @@ -133,6 +134,8 @@ function App({ projectRoot, initialPrompt, resumeSessionId, onRestart }: AppProp const [nowTick, setNowTick] = useState(0); const [mcpStatuses, setMcpStatuses] = useState>([]); const [showProcessStdout, setShowProcessStdout] = useState(false); + const [planMode, setPlanMode] = useState(false); + const [pendingPlanImplementation, setPendingPlanImplementation] = useState(null); rawModeRef.current = mode; messagesRef.current = messages; @@ -253,6 +256,8 @@ function App({ projectRoot, initialPrompt, resumeSessionId, onRestart }: AppProp setActiveStatus(null); setActiveAskPermissions(undefined); setPendingPermissionReply(null); + setPlanMode(false); + setPendingPlanImplementation(null); setDismissedQuestionIds(new Set()); await resetStaticView([]); await refreshSkills(); @@ -369,6 +374,7 @@ function App({ projectRoot, initialPrompt, resumeSessionId, onRestart }: AppProp submission.selectedSkills && submission.selectedSkills.length > 0 ? submission.selectedSkills : undefined, permissions: submission.permissions, alwaysAllows: submission.alwaysAllows, + planMode: submission.planMode ?? planMode, }; const activeSessionId = sessionManager.getActiveSessionId(); const permissionReply = @@ -404,6 +410,12 @@ function App({ projectRoot, initialPrompt, resumeSessionId, onRestart }: AppProp } await refreshSkills(); refreshSessionsList(); + const completedSession = sessionManager.getSession(sessionManager.getActiveSessionId() ?? ""); + const proposedPlan = + prompt.planMode && completedSession?.status === "completed" + ? extractProposedPlan(completedSession.assistantReply) + : null; + setPendingPlanImplementation(proposedPlan); } catch (error) { const message = error instanceof Error ? error.message : String(error); setErrorLine(message); @@ -425,6 +437,7 @@ function App({ projectRoot, initialPrompt, resumeSessionId, onRestart }: AppProp refreshSessionsList, navigateToSubView, resetToWelcome, + planMode, ] ); @@ -496,6 +509,25 @@ function App({ projectRoot, initialPrompt, resumeSessionId, onRestart }: AppProp [handlePrompt] ); + const handlePlanImplementationChoice = useCallback( + (choice: "implement" | "stay" | "default") => { + const proposedPlan = pendingPlanImplementation; + setPendingPlanImplementation(null); + if (choice === "stay") { + return; + } + setPlanMode(false); + if (choice === "implement" && proposedPlan) { + handleSubmit({ + text: getImplementationPrompt(proposedPlan), + imageUrls: [], + planMode: false, + }); + } + }, + [handleSubmit, pendingPlanImplementation] + ); + const handleExitShortcut = useCallback(() => { handleExit({ showCommand: false, showSummary: false }); }, [handleExit]); @@ -517,6 +549,8 @@ function App({ projectRoot, initialPrompt, resumeSessionId, onRestart }: AppProp setRunningProcesses(session?.processes ?? null); setActiveStatus(session?.status ?? null); setActiveAskPermissions(session?.askPermissions); + setPlanMode(session?.planMode === true); + setPendingPlanImplementation(null); if (pendingPermissionReply && pendingPermissionReply.sessionId !== sessionId) { setPendingPermissionReply(null); } @@ -965,6 +999,8 @@ function App({ projectRoot, initialPrompt, resumeSessionId, onRestart }: AppProp onSubmit={handlePermissionResult} onCancel={handlePermissionCancel} /> + ) : pendingPlanImplementation && !busy ? ( + ) : isExiting ? null : ( )} diff --git a/packages/cli/src/ui/views/PlanImplementationPrompt.tsx b/packages/cli/src/ui/views/PlanImplementationPrompt.tsx new file mode 100644 index 00000000..8eb65a56 --- /dev/null +++ b/packages/cli/src/ui/views/PlanImplementationPrompt.tsx @@ -0,0 +1,75 @@ +import React, { useEffect, useState } from "react"; +import { Box, Text } from "ink"; +import { useTerminalInput } from "../hooks"; + +type PlanImplementationChoice = "implement" | "stay" | "default"; + +type Props = { + onSelect: (choice: PlanImplementationChoice) => void; +}; + +const CHOICES: Array<{ value: PlanImplementationChoice; label: string }> = [ + { value: "implement", label: "implement this plan" }, + { value: "stay", label: "stay in Plan mode" }, + { value: "default", label: "switch to Default mode" }, +]; + +/** Return only a complete proposed plan, so historical or partial tags cannot trigger the chooser. */ +export function extractProposedPlan(reply: string | null): string | null { + if (!reply) { + return null; + } + const match = reply.match(/\s*([\s\S]*?\S[\s\S]*?)\s*<\/proposed_plan>/); + return match?.[1] ?? null; +} + +export function getImplementationPrompt(plan: string): string { + const fullWidthPunctuationCount = (plan.match(/[,、;。]/g) ?? []).length; + return fullWidthPunctuationCount > 5 ? "实现此方案。" : "Implement the plan."; +} + +export function PlanImplementationPrompt({ onSelect }: Props): React.ReactElement { + const [cursor, setCursor] = useState(0); + + useEffect(() => { + setCursor(0); + }, []); + + useTerminalInput((input, key) => { + if (key.upArrow) { + setCursor((value) => Math.max(0, value - 1)); + return; + } + if (key.downArrow) { + setCursor((value) => Math.min(CHOICES.length - 1, value + 1)); + return; + } + if (input && /^[1-3]$/.test(input)) { + onSelect(CHOICES[Number(input) - 1]!.value); + return; + } + if (key.return) { + onSelect(CHOICES[cursor]!.value); + } + }); + + return ( + + + Plan ready + + Choose what to do next: + + {CHOICES.map((choice, index) => ( + + {index === cursor ? "> " : " "} + {index + 1}. {choice.label} + + ))} + + + 1-3 select · ↑/↓ move · Enter select + + + ); +} diff --git a/packages/cli/src/ui/views/PromptInput.tsx b/packages/cli/src/ui/views/PromptInput.tsx index 3f548def..01a22a62 100644 --- a/packages/cli/src/ui/views/PromptInput.tsx +++ b/packages/cli/src/ui/views/PromptInput.tsx @@ -72,6 +72,7 @@ export type PromptSubmission = { selectedSkills?: SkillInfo[]; permissions?: UserToolPermission[]; alwaysAllows?: PermissionScope[]; + planMode?: boolean; command?: "new" | "resume" | "continue" | "undo" | "mcp" | "exit"; }; @@ -96,9 +97,11 @@ type Props = { promptDraft?: PromptDraft | null; statusLineSegments?: StatusSegment[]; statusLineSeparator?: string; + planMode: boolean; onSubmit: (submission: PromptSubmission) => void; onModelConfigChange: (selection: ModelConfigSelection) => string | Promise; onRawModeChange?: (mode: string) => void; + onPlanModeChange: (enabled: boolean) => void; onInterrupt: () => void; onToggleProcessStdout?: () => void; onExitShortcut?: () => void; @@ -129,12 +132,14 @@ export const PromptInput = React.memo(function PromptInput({ promptDraft, statusLineSegments, statusLineSeparator, + planMode, onSubmit, onModelConfigChange, onInterrupt, onToggleProcessStdout, onExitShortcut, onRawModeChange, + onPlanModeChange, }: Props): React.ReactElement { const { stdout } = useStdout(); const inputTextRef = useRef(null); @@ -226,9 +231,10 @@ export const PromptInput = React.memo(function PromptInput({ screenWidth, cursorLayoutKey ?? "default", imageUrls.length, + planMode ? "plan-mode" : "default-mode", selectedSkills.map((skill) => skill.name).join("\u001F"), ].join("\u001E"), - [cursorLayoutKey, imageUrls.length, screenWidth, selectedSkills] + [cursorLayoutKey, imageUrls.length, planMode, screenWidth, selectedSkills] ); useTerminalFocusReporting(stdout, !disabled); useTerminalExtendedKeys(stdout, !disabled); @@ -429,6 +435,11 @@ export const PromptInput = React.memo(function PromptInput({ const returnAction = getPromptReturnKeyAction(key); const isPlainReturn = returnAction === "submit"; + if (key.shift && key.tab) { + onPlanModeChange(!planMode); + return; + } + if (showFileMentionMenu) { if (key.upArrow || key.downArrow || key.tab || returnAction === "submit") { return; @@ -678,6 +689,11 @@ export const PromptInput = React.memo(function PromptInput({ setShowModelDropdown(true); return; } + if (item.kind === "plan") { + clearSlashToken(); + onPlanModeChange(true); + return; + } if (item.kind === "raw") { clearSlashToken(); setOpenRawModelDropdown(true); @@ -744,6 +760,7 @@ export const PromptInput = React.memo(function PromptInput({ text: expandPasteMarkers(buffer.text, pastesRef.current), imageUrls, selectedSkills, + planMode, }); resetPromptInput(); } @@ -781,6 +798,11 @@ export const PromptInput = React.memo(function PromptInput({ (use /skills to edit) ) : null} + {planMode ? ( + + 💡 Plan mode (shift+tab to cycle) + + ) : null} {/* Input */} renderSkillDocumentBlock(skill)); return `Use the skill documents below to assist the user:\n${blocks.join("\n\n")}`; diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index 4483c67f..b4630ba8 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -14,6 +14,7 @@ import { getCompactPrompt, getDefaultSkillPrompt, getExtensionRoot, + getPlanModePrompt, getRuntimeContext, getSystemPrompt, getTools, @@ -66,7 +67,8 @@ const PROJECT_CODE_HASH_LENGTH = 16; const BACKGROUND_FAILURE_LOG_TAIL_CHARS = 4000; const DEFAULT_COMPACT_PROMPT_TOKEN_THRESHOLD = 128 * 1024; const DEEPSEEK_V4_COMPACT_PROMPT_TOKEN_THRESHOLD = 512 * 1024; -const PLAN_MODE_STATUS_MESSAGE = "/plan\n └ Set Plan Mode on. Awaiting ."; +const PLAN_MODE_ON_STATUS_MESSAGE = " └ Set Plan Mode on. Awaiting ."; +const PLAN_MODE_OFF_STATUS_MESSAGE = " └ Set Plan Mode off."; type ChatCompletionDebugOptions = { enabled?: boolean; @@ -232,6 +234,7 @@ export type SessionEntry = { updateTime: string; processes: Map | null; // {pid: process info} askPermissions?: AskPermissionRequest[]; + planMode?: boolean; }; export type SessionsIndex = { @@ -282,6 +285,7 @@ export type UserPromptContent = { skills?: SkillInfo[]; permissions?: UserToolPermission[]; alwaysAllows?: PermissionScope[]; + planMode?: boolean; }; export type SkillInfo = { @@ -1046,9 +1050,6 @@ ${agentInstructions} } for (const skill of skills) { - if (skill.name === "plan") { - this.appendSessionMessage(sessionId, this.buildSystemMessage(sessionId, PLAN_MODE_STATUS_MESSAGE)); - } if (skill.isLoaded) { continue; } @@ -1118,6 +1119,7 @@ ${agentInstructions} createTime: now, updateTime: now, processes: null, + planMode: Boolean(userPrompt.planMode), }; index.entries.push(entry); const sortedEntries = index.entries.slice().sort((a, b) => { @@ -1163,6 +1165,8 @@ ${agentInstructions} this.appendSessionMessage(sessionId, instructionsMessage); } + this.appendPlanModeTransitionMessages(sessionId, false, Boolean(userPrompt.planMode)); + this.recordUserPromptCheckpoint(sessionId); const userMessage = this.buildUserMessage(sessionId, userPrompt); this.appendSessionMessage(sessionId, userMessage); @@ -1196,11 +1200,14 @@ ${agentInstructions} inheritedPermissions: this.getResolvedSettings().permissions, }); const now = new Date().toISOString(); + const previousPlanMode = Boolean(this.getSession(sessionId)?.planMode); + const nextPlanMode = Boolean(userPrompt.planMode); const updated = this.updateSessionEntry(sessionId, (entry) => ({ ...entry, status: "pending", failReason: null, askPermissions: undefined, + planMode: nextPlanMode, updateTime: now, })); @@ -1209,6 +1216,8 @@ ${agentInstructions} return; } + this.appendPlanModeTransitionMessages(sessionId, previousPlanMode, nextPlanMode); + if (hasUserPermissionReplies(userPrompt) && this.hasTrailingPendingToolCalls(sessionId)) { this.activeSessionId = sessionId; await this.activateSession(sessionId, controller, userPrompt); @@ -2090,6 +2099,23 @@ ${agentInstructions} }; } + private appendPlanModeTransitionMessages(sessionId: string, wasEnabled: boolean, isEnabled: boolean): void { + if (wasEnabled === isEnabled) { + return; + } + + if (isEnabled) { + const prompt = getPlanModePrompt(); + if (prompt) { + this.appendSessionMessage(sessionId, this.buildSystemMessage(sessionId, prompt)); + } + this.appendSessionMessage(sessionId, this.buildSystemMessage(sessionId, PLAN_MODE_ON_STATUS_MESSAGE)); + return; + } + + this.appendSessionMessage(sessionId, this.buildSystemMessage(sessionId, PLAN_MODE_OFF_STATUS_MESSAGE)); + } + private renderInitCommandPrompt(): string { const templatePath = path.join(getExtensionRoot(), "templates", "prompts", "init_command.md.ejs"); const template = fs.readFileSync(templatePath, "utf8"); @@ -2346,6 +2372,7 @@ ${agentInstructions} skills: prompt.skills ? prompt.skills.map((skill) => ({ ...skill })) : undefined, permissions: prompt.permissions ? prompt.permissions.map((permission) => ({ ...permission })) : undefined, alwaysAllows: prompt.alwaysAllows ? [...prompt.alwaysAllows] : undefined, + planMode: prompt.planMode, }; } @@ -2752,6 +2779,7 @@ ${agentInstructions} updateTime: typeof value.updateTime === "string" ? value.updateTime : new Date().toISOString(), processes: this.deserializeProcesses(value.processes), askPermissions: normalizeAskPermissions(value.askPermissions), + planMode: value.planMode === true, }; } diff --git a/packages/core/src/tests/prompt.test.ts b/packages/core/src/tests/prompt.test.ts index 6b474c1e..32ce49e3 100644 --- a/packages/core/src/tests/prompt.test.ts +++ b/packages/core/src/tests/prompt.test.ts @@ -7,6 +7,7 @@ import { fileURLToPath } from "url"; import { buildSkillDocumentsPrompt, getDefaultSkillPrompt, + getPlanModePrompt, getRuntimeContext, getSystemPrompt, getTools, @@ -94,6 +95,12 @@ test("getDefaultSkillPrompt skips disabled default skills", () => { assert.equal(prompt, ""); }); +test("getPlanModePrompt loads the dedicated Plan Mode template", () => { + const prompt = getPlanModePrompt(); + assert.equal(prompt.includes("# Plan Mode (Conversational)"), true); + assert.equal(prompt.includes(""), true); +}); + test("buildSkillDocumentsPrompt excludes SKILL.md frontmatter metadata", () => { const prompt = buildSkillDocumentsPrompt([ { diff --git a/packages/core/src/tests/session.test.ts b/packages/core/src/tests/session.test.ts index 57b981bb..7fab6575 100644 --- a/packages/core/src/tests/session.test.ts +++ b/packages/core/src/tests/session.test.ts @@ -6,14 +6,15 @@ import * as os from "os"; import * as path from "path"; import { GitFileHistory } from "../common/file-history"; import { clearSessionState } from "../common/state"; -import { getProjectCode, SessionManager, type SessionMessage, type SkillInfo } from "../session"; +import { getProjectCode, SessionManager, type SessionMessage } from "../session"; const originalFetch = globalThis.fetch; const originalConsoleWarn = console.warn; const originalHome = process.env.HOME; const originalUserProfile = process.env.USERPROFILE; const tempDirs: string[] = []; -const PLAN_MODE_STATUS_MESSAGE = "/plan\n └ Set Plan Mode on. Awaiting ."; +const PLAN_MODE_ON_STATUS_MESSAGE = " └ Set Plan Mode on. Awaiting ."; +const PLAN_MODE_OFF_STATUS_MESSAGE = " └ Set Plan Mode off."; /** Set homedir in a cross-platform way (HOME on Unix, USERPROFILE on Windows). */ function setHomeDir(dir: string): void { @@ -523,68 +524,69 @@ test("SessionManager resolves bundled skill prompts", () => { assert.match(prompt, /# Skill Writer/); }); -test("SessionManager appends plan mode status whenever the plan skill is selected", async () => { - const workspace = createTempDir("deepcode-plan-skill-workspace-"); - const home = createTempDir("deepcode-plan-skill-home-"); +test("SessionManager persists Plan Mode and appends prompts only on mode transitions", async () => { + const workspace = createTempDir("deepcode-plan-matched-workspace-"); + const home = createTempDir("deepcode-plan-matched-home-"); setHomeDir(home); - const manager = createSessionManager(workspace, "machine-id-plan-skill"); - const planSkill = await getPlanSkill(manager); - - const sessionId = await manager.createSession({ text: "", skills: [planSkill] }); + const manager = createMockedClientSessionManager(workspace, [ + createChatResponse("planned", { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }), + createChatResponse("still planning", { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }), + createChatResponse("implementing", { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }), + ]); + const sessionId = await manager.createSession({ text: "Plan this change", planMode: true }); let messages = manager.listSessionMessages(sessionId); - assert.equal(countPlanModeStatusMessages(messages), 1); - assert.equal(countLoadedSkillMessages(messages, "plan"), 1); + assert.equal(manager.getSession(sessionId)?.planMode, true); + assert.equal(messages.filter((message) => message.content === PLAN_MODE_ON_STATUS_MESSAGE).length, 1); + assert.equal( + messages.some((message) => message.content?.includes("# Plan Mode (Conversational)")), + true + ); + assert.equal(messages.find((message) => message.role === "user")?.meta?.userPrompt?.planMode, true); + + await manager.replySession(sessionId, { text: "Refine it", planMode: true }); + messages = manager.listSessionMessages(sessionId); + assert.equal(messages.filter((message) => message.content === PLAN_MODE_ON_STATUS_MESSAGE).length, 1); - await manager.replySession(sessionId, { text: "", skills: [planSkill] }); + await manager.replySession(sessionId, { text: "Implement it", planMode: false }); messages = manager.listSessionMessages(sessionId); - assert.equal(countPlanModeStatusMessages(messages), 2); - assert.equal(countLoadedSkillMessages(messages, "plan"), 1); + assert.equal(manager.getSession(sessionId)?.planMode, false); + assert.equal(messages.filter((message) => message.content === PLAN_MODE_OFF_STATUS_MESSAGE).length, 1); }); -test("SessionManager appends plan mode status when the plan skill is auto-matched", async () => { - const workspace = createTempDir("deepcode-plan-matched-workspace-"); - const home = createTempDir("deepcode-plan-matched-home-"); +test("SessionManager excludes the former bundled plan skill and defaults legacy sessions to Default mode", async () => { + const workspace = createTempDir("deepcode-plan-legacy-workspace-"); + const home = createTempDir("deepcode-plan-legacy-home-"); setHomeDir(home); - const client = { + const manager = createSessionManager(workspace, "machine-id-plan-legacy"); + assert.equal( + (await manager.listSkills()).some((skill) => skill.name === "plan"), + false + ); + + const sessionId = await manager.createSession({ text: "Default mode" }); + const index = (manager as any).loadSessionsIndex(); + delete index.entries.find((entry: { id: string }) => entry.id === sessionId).planMode; + (manager as any).saveSessionsIndex(index); + assert.equal(manager.getSession(sessionId)?.planMode, false); + + const autoMatchManager = createMockedClientSessionManagerWithClient(workspace, { chat: { completions: { - create: async (request: any) => { - if (isSkillMatchingRequest(request)) { - return createSkillMatchingResponse(["plan"]); - } - return createChatResponse("planned", { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }); - }, + create: async (request: any) => + isSkillMatchingRequest(request) + ? createSkillMatchingResponse(["plan"]) + : createChatResponse("default reply", { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }), }, }, - }; - const manager = createMockedClientSessionManagerWithClient(workspace, client); - - const sessionId = await manager.createSession({ text: "Plan Mode for this change" }); - const messages = manager.listSessionMessages(sessionId); - assert.equal(countPlanModeStatusMessages(messages), 1); - assert.equal(countLoadedSkillMessages(messages, "plan"), 1); -}); - -test("SessionManager appends plan mode status for deferred permission prompts", async () => { - const workspace = createTempDir("deepcode-plan-deferred-workspace-"); - const home = createTempDir("deepcode-plan-deferred-home-"); - setHomeDir(home); - - const manager = createSessionManager(workspace, "machine-id-plan-deferred"); - const sessionId = await manager.createSession({ text: "" }); - const planSkill = await getPlanSkill(manager); - - await (manager as any).appendDeferredPermissionPrompt( - sessionId, - { text: "", skills: [planSkill] }, - new AbortController() + }); + const autoMatchSessionId = await autoMatchManager.createSession({ text: "Plan this feature" }); + const autoMatchMessages = autoMatchManager.listSessionMessages(autoMatchSessionId); + assert.equal( + autoMatchMessages.some((message) => message.meta?.skill?.name === "plan"), + false ); - - const messages = manager.listSessionMessages(sessionId); - assert.equal(countPlanModeStatusMessages(messages), 1); - assert.equal(countLoadedSkillMessages(messages, "plan"), 1); }); test("SessionManager excludes disabled skills by resolved skill name", async () => { @@ -3572,16 +3574,6 @@ function createSessionManager(projectRoot: string, machineId: string): SessionMa }); } -async function getPlanSkill(manager: SessionManager): Promise { - const planSkill = (await manager.listSkills()).find((skill) => skill.name === "plan"); - assert.ok(planSkill); - return planSkill; -} - -function countPlanModeStatusMessages(messages: SessionMessage[]): number { - return messages.filter((message) => message.role === "system" && message.content === PLAN_MODE_STATUS_MESSAGE).length; -} - function countLoadedSkillMessages(messages: SessionMessage[], skillName: string): number { return messages.filter((message) => message.role === "system" && message.meta?.skill?.name === skillName).length; } diff --git a/packages/core/templates/skills/bundled/plan/SKILL.md b/packages/core/templates/prompts/plan.md similarity index 97% rename from packages/core/templates/skills/bundled/plan/SKILL.md rename to packages/core/templates/prompts/plan.md index 41c43013..a4db8748 100644 --- a/packages/core/templates/skills/bundled/plan/SKILL.md +++ b/packages/core/templates/prompts/plan.md @@ -1,8 +1,3 @@ ---- -name: plan -description: Plan tasks through a strict non-mutating collaboration workflow before implementation. Use ONLY when the user asks for Plan Mode, planning only or non-mutating exploration. ---- - # Plan Mode (Conversational) You work in 3 phases, and you should _chat your way_ to a great plan before finalizing it. A great plan is very detailed—intent- and implementation-wise—so that it can be handed to another engineer or agent to be implemented right away. It must be **decision complete**, where the implementer does not need to make any decisions. From afcbc500627bbe917196c0e3c50db9aa8cf9830d Mon Sep 17 00:00:00 2001 From: Ji Zhang Date: Wed, 15 Jul 2026 13:29:24 +0800 Subject: [PATCH 04/11] feat: add permission restricting in Plan Mode --- .../cli/src/tests/prompt-input-keys.test.ts | 5 + packages/cli/src/ui/index.ts | 1 + .../src/ui/views/PlanImplementationPrompt.tsx | 27 ++++-- packages/core/src/common/permissions.ts | 32 +++++- packages/core/src/session.ts | 8 ++ packages/core/src/tests/permissions.test.ts | 97 +++++++++++++++++++ packages/core/src/tests/session.test.ts | 48 +++++++++ 7 files changed, 209 insertions(+), 9 deletions(-) diff --git a/packages/cli/src/tests/prompt-input-keys.test.ts b/packages/cli/src/tests/prompt-input-keys.test.ts index 156fe436..2b3e3791 100644 --- a/packages/cli/src/tests/prompt-input-keys.test.ts +++ b/packages/cli/src/tests/prompt-input-keys.test.ts @@ -24,6 +24,7 @@ import { buildPromptDraftFromSessionMessage, extractProposedPlan, getImplementationPrompt, + getPlanImplementationChoice, disableTerminalExtendedKeys, enableTerminalExtendedKeys, EMPTY_BUFFER, @@ -160,6 +161,10 @@ test("getImplementationPrompt uses Chinese only above five full-width punctuatio assert.equal(getImplementationPrompt(",、;。;。"), "实现此方案。"); }); +test("getPlanImplementationChoice treats escape as staying in Plan Mode", () => { + assert.equal(getPlanImplementationChoice("", { escape: true, return: false }, 0), "stay"); +}); + test("prompt return key action submits on plain enter", () => { const { key } = parseTerminalInput("\r"); assert.equal(getPromptReturnKeyAction(key), "submit"); diff --git a/packages/cli/src/ui/index.ts b/packages/cli/src/ui/index.ts index b248e2f5..53f281af 100644 --- a/packages/cli/src/ui/index.ts +++ b/packages/cli/src/ui/index.ts @@ -19,6 +19,7 @@ export { PlanImplementationPrompt, extractProposedPlan, getImplementationPrompt, + getPlanImplementationChoice, } from "./views/PlanImplementationPrompt"; export { MessageView } from "./components"; export { parseDiffPreview } from "./components/MessageView/utils"; diff --git a/packages/cli/src/ui/views/PlanImplementationPrompt.tsx b/packages/cli/src/ui/views/PlanImplementationPrompt.tsx index 8eb65a56..e97b361a 100644 --- a/packages/cli/src/ui/views/PlanImplementationPrompt.tsx +++ b/packages/cli/src/ui/views/PlanImplementationPrompt.tsx @@ -1,6 +1,7 @@ import React, { useEffect, useState } from "react"; import { Box, Text } from "ink"; import { useTerminalInput } from "../hooks"; +import type { InputKey } from "../hooks"; type PlanImplementationChoice = "implement" | "stay" | "default"; @@ -28,6 +29,20 @@ export function getImplementationPrompt(plan: string): string { return fullWidthPunctuationCount > 5 ? "实现此方案。" : "Implement the plan."; } +export function getPlanImplementationChoice( + input: string, + key: Pick, + cursor: number +): PlanImplementationChoice | null { + if (key.escape) { + return "stay"; + } + if (input && /^[1-3]$/.test(input)) { + return CHOICES[Number(input) - 1]!.value; + } + return key.return ? CHOICES[cursor]!.value : null; +} + export function PlanImplementationPrompt({ onSelect }: Props): React.ReactElement { const [cursor, setCursor] = useState(0); @@ -36,6 +51,11 @@ export function PlanImplementationPrompt({ onSelect }: Props): React.ReactElemen }, []); useTerminalInput((input, key) => { + const choice = getPlanImplementationChoice(input, key, cursor); + if (choice) { + onSelect(choice); + return; + } if (key.upArrow) { setCursor((value) => Math.max(0, value - 1)); return; @@ -44,13 +64,6 @@ export function PlanImplementationPrompt({ onSelect }: Props): React.ReactElemen setCursor((value) => Math.min(CHOICES.length - 1, value + 1)); return; } - if (input && /^[1-3]$/.test(input)) { - onSelect(CHOICES[Number(input) - 1]!.value); - return; - } - if (key.return) { - onSelect(CHOICES[cursor]!.value); - } }); return ( diff --git a/packages/core/src/common/permissions.ts b/packages/core/src/common/permissions.ts index f50d485c..e7190b20 100644 --- a/packages/core/src/common/permissions.ts +++ b/packages/core/src/common/permissions.ts @@ -60,6 +60,7 @@ export type ComputeToolCallPermissionsOptions = { projectRoot: string; toolCalls: unknown[]; settings?: Required; + forceAskScopes?: readonly PermissionScope[]; readPermissionExemptPaths?: string[]; resolveSnippetPath?: (sessionId: string, snippetId: string) => string | null | undefined; }; @@ -163,10 +164,18 @@ export function computeToolCallPermissions(options: ComputeToolCallPermissionsOp readPermissionExemptPaths: options.readPermissionExemptPaths, resolveSnippetPath: options.resolveSnippetPath, }); - const permission = evaluatePermissionScopes(request.scopes, options.settings); + const evaluatedPermission = evaluatePermissionScopes(request.scopes, options.settings); + const forcedAskScopes = + evaluatedPermission === "deny" + ? [] + : getAllowedForcedAskScopes(request.scopes, options.settings, options.forceAskScopes); + const permission = forcedAskScopes.length > 0 ? "ask" : evaluatedPermission; permissions.push({ toolCallId: toolCall.id, permission }); if (permission === "ask") { - const askScopes = getPermissionScopesRequiringAsk(request.scopes, options.settings); + const askScopes = mergeAskScopes( + getPermissionScopesRequiringAsk(request.scopes, options.settings), + forcedAskScopes + ); askPermissions.push({ toolCallId: toolCall.id, scopes: askScopes.length > 0 ? askScopes : request.scopes, @@ -180,6 +189,25 @@ export function computeToolCallPermissions(options: ComputeToolCallPermissionsOp return { permissions, askPermissions }; } +function getAllowedForcedAskScopes( + scopes: AskPermissionScope[], + settings: Required | undefined, + forceAskScopes: readonly PermissionScope[] | undefined +): PermissionScope[] { + if (!forceAskScopes?.length) { + return []; + } + + return scopes.filter( + (scope): scope is PermissionScope => + scope !== "unknown" && forceAskScopes.includes(scope) && evaluatePermissionScopes([scope], settings) === "allow" + ); +} + +function mergeAskScopes(existing: AskPermissionScope[], forced: PermissionScope[]): AskPermissionScope[] { + return [...existing, ...forced.filter((scope) => !existing.includes(scope))]; +} + export function describeToolPermissionRequest(options: { sessionId: string; projectRoot: string; diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index b4630ba8..2101b8b6 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -69,6 +69,13 @@ const DEFAULT_COMPACT_PROMPT_TOKEN_THRESHOLD = 128 * 1024; const DEEPSEEK_V4_COMPACT_PROMPT_TOKEN_THRESHOLD = 512 * 1024; const PLAN_MODE_ON_STATUS_MESSAGE = " └ Set Plan Mode on. Awaiting ."; const PLAN_MODE_OFF_STATUS_MESSAGE = " └ Set Plan Mode off."; +const PLAN_MODE_FORCE_ASK_SCOPES = [ + "write-in-cwd", + "write-out-cwd", + "delete-in-cwd", + "delete-out-cwd", + "mutate-git-log", +] as const satisfies readonly PermissionScope[]; type ChatCompletionDebugOptions = { enabled?: boolean; @@ -1416,6 +1423,7 @@ ${agentInstructions} projectRoot: this.projectRoot, toolCalls, settings: this.getResolvedSettings().permissions, + forceAskScopes: this.getSession(sessionId)?.planMode ? PLAN_MODE_FORCE_ASK_SCOPES : undefined, readPermissionExemptPaths: this.getSkillScanRoots().map((entry) => entry.root), resolveSnippetPath: (id, snippetId) => getSnippet(id, snippetId)?.filePath, }) diff --git a/packages/core/src/tests/permissions.test.ts b/packages/core/src/tests/permissions.test.ts index fd3b676a..1a3e2697 100644 --- a/packages/core/src/tests/permissions.test.ts +++ b/packages/core/src/tests/permissions.test.ts @@ -170,6 +170,103 @@ test("computeToolCallPermissions only asks for scopes not already allowed", () = ); }); +test("computeToolCallPermissions temporarily upgrades allowed forced scopes to ask", () => { + const projectRoot = createTempDir("deepcode-permissions-force-ask-workspace-"); + const forcedScopes: PermissionScope[] = [ + "write-in-cwd", + "write-out-cwd", + "delete-in-cwd", + "delete-out-cwd", + "mutate-git-log", + ]; + const plan = computeToolCallPermissions({ + sessionId: "session-1", + projectRoot, + forceAskScopes: forcedScopes, + settings: { + allow: ["write-in-cwd", "write-out-cwd", "delete-out-cwd", "mutate-git-log"] as PermissionScope[], + deny: ["delete-in-cwd"] as PermissionScope[], + ask: [] as PermissionScope[], + defaultMode: "allowAll" as const, + }, + toolCalls: [ + { + id: "call-write-in", + type: "function", + function: { name: "write", arguments: JSON.stringify({ file_path: path.join(projectRoot, "file.txt") }) }, + }, + { + id: "call-write-out", + type: "function", + function: { name: "write", arguments: JSON.stringify({ file_path: "/tmp/file.txt" }) }, + }, + { + id: "call-delete-out", + type: "function", + function: { + name: "bash", + arguments: JSON.stringify({ + command: "rm /tmp/file.txt", + sideEffects: ["delete-out-cwd"], + }), + }, + }, + { + id: "call-mutate-git", + type: "function", + function: { + name: "bash", + arguments: JSON.stringify({ command: "git commit --allow-empty -m test", sideEffects: ["mutate-git-log"] }), + }, + }, + { + id: "call-delete-in", + type: "function", + function: { + name: "bash", + arguments: JSON.stringify({ command: "rm file.txt", sideEffects: ["delete-in-cwd"] }), + }, + }, + ], + }); + + assert.deepEqual(plan.permissions, [ + { toolCallId: "call-write-in", permission: "ask" }, + { toolCallId: "call-write-out", permission: "ask" }, + { toolCallId: "call-delete-out", permission: "ask" }, + { toolCallId: "call-mutate-git", permission: "ask" }, + { toolCallId: "call-delete-in", permission: "deny" }, + ]); + assert.deepEqual( + plan.askPermissions.map((item) => ({ id: item.toolCallId, scopes: item.scopes })), + [ + { id: "call-write-in", scopes: ["write-in-cwd"] }, + { id: "call-write-out", scopes: ["write-out-cwd"] }, + { id: "call-delete-out", scopes: ["delete-out-cwd"] }, + { id: "call-mutate-git", scopes: ["mutate-git-log"] }, + ] + ); + + const defaultPlan = computeToolCallPermissions({ + sessionId: "session-1", + projectRoot, + settings: { + allow: ["write-in-cwd"] as PermissionScope[], + deny: [] as PermissionScope[], + ask: [] as PermissionScope[], + defaultMode: "allowAll" as const, + }, + toolCalls: [ + { + id: "call-default", + type: "function", + function: { name: "write", arguments: JSON.stringify({ file_path: path.join(projectRoot, "file.txt") }) }, + }, + ], + }); + assert.deepEqual(defaultPlan.permissions, [{ toolCallId: "call-default", permission: "allow" }]); +}); + test("computeToolCallPermissions allows read tool calls under skill scan paths", () => { const projectRoot = createTempDir("deepcode-permissions-skill-read-workspace-"); const home = createTempDir("deepcode-permissions-skill-read-home-"); diff --git a/packages/core/src/tests/session.test.ts b/packages/core/src/tests/session.test.ts index 7fab6575..d7d0b6fa 100644 --- a/packages/core/src/tests/session.test.ts +++ b/packages/core/src/tests/session.test.ts @@ -2294,6 +2294,54 @@ test("activateSession pauses for permission when a tool call requires ask", asyn ); }); +test("activateSession temporarily asks before allowed writes in Plan Mode", async () => { + const workspace = createTempDir("deepcode-plan-permission-workspace-"); + const home = createTempDir("deepcode-plan-permission-home-"); + setHomeDir(home); + + const manager = createPermissionSessionManager( + workspace, + [ + { + choices: [ + { + message: { + content: "", + tool_calls: [ + { + id: "call-write", + type: "function", + function: { + name: "write", + arguments: JSON.stringify({ file_path: path.join(workspace, "plan.txt"), content: "planned" }), + }, + }, + ], + }, + }, + ], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + }, + ], + { + allow: ["write-in-cwd"], + deny: [], + ask: [], + defaultMode: "allowAll", + } + ); + + const sessionId = await manager.createSession({ text: "Plan this change", planMode: true }); + const session = manager.getSession(sessionId); + const assistant = manager + .listSessionMessages(sessionId) + .find((message) => message.role === "assistant" && (message.messageParams as any)?.tool_calls); + + assert.equal(session?.status, "ask_permission"); + assert.deepEqual(session?.askPermissions?.[0]?.scopes, ["write-in-cwd"]); + assert.deepEqual(assistant?.meta?.permissions, [{ toolCallId: "call-write", permission: "ask" }]); +}); + test("SessionManager preserves permission_denied status when sessions are reloaded", async () => { const workspace = createTempDir("deepcode-permission-denied-workspace-"); const home = createTempDir("deepcode-permission-denied-home-"); From 6d2c9737bc5ccb32bb3ed8cb6a720c4a35d1b8b7 Mon Sep 17 00:00:00 2001 From: Ji Zhang Date: Wed, 15 Jul 2026 13:33:17 +0800 Subject: [PATCH 05/11] feat: update Plan Mode UI --- packages/cli/src/ui/views/PromptInput.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/ui/views/PromptInput.tsx b/packages/cli/src/ui/views/PromptInput.tsx index 01a22a62..799dec7a 100644 --- a/packages/cli/src/ui/views/PromptInput.tsx +++ b/packages/cli/src/ui/views/PromptInput.tsx @@ -800,7 +800,8 @@ export const PromptInput = React.memo(function PromptInput({ ) : null} {planMode ? ( - 💡 Plan mode (shift+tab to cycle) + 💡 Plan mode + (shift+tab to cycle) ) : null} {/* Input */} From 27c3115cba3ae3ed2f37a03bb899cd749597869a Mon Sep 17 00:00:00 2001 From: Ji Zhang Date: Wed, 15 Jul 2026 15:00:33 +0800 Subject: [PATCH 06/11] feat: update the read tool to remove the `pages` param, and no longer output PDF content in base64 --- packages/core/src/prompt.ts | 6 +- packages/core/src/tests/prompt.test.ts | 6 ++ packages/core/src/tests/tool-handlers.test.ts | 18 +++++ packages/core/src/tools/read-handler.ts | 78 +------------------ packages/core/templates/tools/read.md.ejs | 5 -- 5 files changed, 26 insertions(+), 87 deletions(-) diff --git a/packages/core/src/prompt.ts b/packages/core/src/prompt.ts index e209248b..62171457 100644 --- a/packages/core/src/prompt.ts +++ b/packages/core/src/prompt.ts @@ -589,7 +589,7 @@ export function getTools(_options: PromptToolOptions = {}, externalTools: ToolDe type: "function", function: { name: "read", - description: "Read files from the filesystem (text, images, PDFs, notebooks).", + description: "Read files from the filesystem (text, images, notebooks).", parameters: { type: "object", properties: { @@ -605,10 +605,6 @@ export function getTools(_options: PromptToolOptions = {}, externalTools: ToolDe type: "number", description: "Number of lines to read", }, - pages: { - type: "string", - description: 'Page range for PDF files (e.g., "1-5", "3", "10-20"). Only applicable to PDF files.', - }, }, required: ["file_path"], additionalProperties: false, diff --git a/packages/core/src/tests/prompt.test.ts b/packages/core/src/tests/prompt.test.ts index 32ce49e3..173230cb 100644 --- a/packages/core/src/tests/prompt.test.ts +++ b/packages/core/src/tests/prompt.test.ts @@ -55,6 +55,12 @@ test("getTools requires bash sideEffects permission scopes", () => { assert.equal(runInBackground.type, "boolean"); }); +test("getTools does not expose the unused PDF pages parameter", () => { + const tool = getTools().find((candidate) => candidate.function.name === "read"); + assert.ok(tool); + assert.equal("pages" in tool.function.parameters.properties, false); +}); + test("getSystemPrompt always includes WebSearch docs", () => { const prompt = getSystemPrompt("/tmp/project"); assert.equal(prompt.includes("## WebSearch"), true); diff --git a/packages/core/src/tests/tool-handlers.test.ts b/packages/core/src/tests/tool-handlers.test.ts index 735c0274..d6483f7a 100644 --- a/packages/core/src/tests/tool-handlers.test.ts +++ b/packages/core/src/tests/tool-handlers.test.ts @@ -998,6 +998,24 @@ test("Read returns an acknowledgement for images and attaches the image as a fol ); }); +test("Read reports PDFs as binary without attaching their contents", async () => { + const workspace = createTempWorkspace(); + const filePath = path.join(workspace, "document.pdf"); + fs.writeFileSync(filePath, "%PDF-1.7\n" + "x".repeat(1024 * 1024)); + + const readResult = await handleReadTool({ file_path: filePath }, createContext("pdf-read", workspace)); + + assert.equal(readResult.ok, true); + assert.equal(readResult.output, "WARNING: File is binary."); + assert.deepEqual(readResult.metadata, { + mime: "application/pdf", + encoding: "base64", + bytes: 1024 * 1024 + "%PDF-1.7\n".length, + pageCount: 0, + }); + assert.equal(readResult.followUpMessages, undefined); +}); + function createContext( sessionId: string, projectRoot: string, diff --git a/packages/core/src/tools/read-handler.ts b/packages/core/src/tools/read-handler.ts index 3771a7e0..94be54b4 100644 --- a/packages/core/src/tools/read-handler.ts +++ b/packages/core/src/tools/read-handler.ts @@ -13,8 +13,6 @@ import { const DEFAULT_LINE_LIMIT = 2000; const MAX_LINE_LENGTH = 2000; -const PDF_LARGE_PAGE_THRESHOLD = 10; -const PDF_MAX_PAGE_RANGE = 20; const LINE_NUMBER_WIDTH = 6; const DEFAULT_GITIGNORE = [ "node_modules/", @@ -41,12 +39,6 @@ const DEFAULT_GITIGNORE = [ "target/", ]; -type PageRange = { - start: number; - end: number; - count: number; -}; - type TextReadResult = { content: string; output: string; @@ -159,36 +151,8 @@ export async function handleReadTool( } if (ext === ".pdf") { - const pagesParam = typeof args.pages === "string" ? args.pages.trim() : ""; const buffer = fs.readFileSync(filePath); const pageCount = countPdfPages(buffer); - const pageRange = pagesParam ? parsePageRange(pagesParam) : null; - - if (!pageRange && pageCount !== null && pageCount > PDF_LARGE_PAGE_THRESHOLD) { - return { - ok: false, - name: "read", - error: `PDF has ${pageCount} pages; provide "pages" to read a range.`, - }; - } - - if (pageRange && pageRange.count > PDF_MAX_PAGE_RANGE) { - return { - ok: false, - name: "read", - error: `PDF page range exceeds ${PDF_MAX_PAGE_RANGE} pages.`, - }; - } - - if (pageRange && pageCount !== null && pageRange.end > pageCount) { - return { - ok: false, - name: "read", - error: `PDF page range exceeds total page count (${pageCount}).`, - }; - } - - const base64 = buffer.toString("base64"); markFileRead(context.sessionId, filePath, { content: "", timestamp: Math.floor(stat.mtimeMs), @@ -197,13 +161,12 @@ export async function handleReadTool( return { ok: true, name: "read", - output: `data:application/pdf;base64,${base64}`, + output: "WARNING: File is binary.", metadata: { mime: "application/pdf", encoding: "base64", bytes: buffer.length, pageCount, - pages: pageRange ? `${pageRange.start}-${pageRange.end}` : null, }, }; } @@ -520,45 +483,6 @@ function countPdfPages(buffer: Buffer): number | null { } } -function parsePageRange(input: string): PageRange { - const trimmed = input.trim(); - if (!trimmed) { - throw new Error("pages must be a non-empty string."); - } - if (trimmed.includes(",")) { - throw new Error('pages must be a single range like "1-5" or "3".'); - } - - const parts = trimmed.split("-").map((part) => part.trim()); - if (parts.length === 1) { - const value = parsePositiveInt(parts[0], "pages"); - return { start: value, end: value, count: 1 }; - } - - if (parts.length === 2) { - const start = parsePositiveInt(parts[0], "pages"); - const end = parsePositiveInt(parts[1], "pages"); - if (end < start) { - throw new Error("pages range end must be >= start."); - } - return { start, end, count: end - start + 1 }; - } - - throw new Error('pages must be a single range like "1-5" or "3".'); -} - -function parsePositiveInt(value: string, label: string): number { - const numeric = Number(value); - if (!Number.isFinite(numeric)) { - throw new Error(`${label} must be a number.`); - } - const integer = Math.trunc(numeric); - if (integer < 1) { - throw new Error(`${label} must be >= 1.`); - } - return integer; -} - function readNotebook(filePath: string): string { const raw = fs.readFileSync(filePath, "utf8"); if (!raw) { diff --git a/packages/core/templates/tools/read.md.ejs b/packages/core/templates/tools/read.md.ejs index 9f79ac9b..71eaab2e 100644 --- a/packages/core/templates/tools/read.md.ejs +++ b/packages/core/templates/tools/read.md.ejs @@ -15,7 +15,6 @@ Usage: <%_ } else { _%> - This tool can inspect image files, but the current model is not multimodal, so image reads are not presented visually to the model. <%_ } _%> -- This tool can read PDF files (.pdf). For large PDFs (more than 10 pages), you MUST provide the pages parameter to read specific page ranges (e.g., pages: "1-5"). Reading a large PDF without the pages parameter will fail. Maximum 20 pages per request. - This tool can read Jupyter notebooks (.ipynb files) and returns all cells with their outputs, combining code, text, and visualizations. - This tool can only read files, not directories. To read a directory, use an ls command via the Bash tool. - You can call multiple tools in a single response. It is always better to speculatively read multiple potentially useful files in parallel. @@ -38,10 +37,6 @@ Usage: "limit": { "description": "The number of lines to read. Only provide if the file is too large to read at once.", "type": "number" - }, - "pages": { - "description": "Page range for PDF files (e.g., \"1-5\", \"3\", \"10-20\"). Only applicable to PDF files. Maximum 20 pages per request.", - "type": "string" } }, "required": [ From efad356ec474070532cc997903f3411e7bbf7a0f Mon Sep 17 00:00:00 2001 From: Ji Zhang Date: Fri, 17 Jul 2026 10:55:11 +0800 Subject: [PATCH 07/11] feat: add LLM error handling with detailed logging and sanitization --- packages/core/src/common/error-logger.ts | 22 +-- packages/core/src/common/llm-error.ts | 172 ++++++++++++++++++++++ packages/core/src/index.ts | 2 + packages/core/src/session.ts | 15 +- packages/core/src/tests/llm-error.test.ts | 42 ++++++ 5 files changed, 232 insertions(+), 21 deletions(-) create mode 100644 packages/core/src/common/llm-error.ts create mode 100644 packages/core/src/tests/llm-error.test.ts diff --git a/packages/core/src/common/error-logger.ts b/packages/core/src/common/error-logger.ts index 52d469fc..694a331a 100644 --- a/packages/core/src/common/error-logger.ts +++ b/packages/core/src/common/error-logger.ts @@ -1,6 +1,7 @@ import * as fs from "fs"; import * as path from "path"; import * as os from "os"; +import type { LlmErrorDetails } from "./llm-error"; const LOG_DIR = path.join(os.homedir(), ".deepcode", "logs"); const ERROR_LOG_PATH = path.join(LOG_DIR, "error.log"); @@ -78,11 +79,7 @@ export type ApiErrorLogEntry = { sessionId?: string; model?: string; baseURL?: string; - error: { - name: string; - message: string; - stack?: string; - }; + error: LlmErrorDetails; request: Record; response?: unknown; }; @@ -101,11 +98,7 @@ export function logApiError(entry: ApiErrorLogEntry): void { sessionId: entry.sessionId, model: entry.model, baseURL: entry.baseURL, - error: { - name: entry.error.name, - message: maskSensitive(entry.error.message), - stack: entry.error.stack ? maskSensitive(entry.error.stack) : undefined, - }, + error: sanitizeError(entry.error), request: sanitizeRequestPayload(entry.request), }; @@ -127,3 +120,12 @@ export function logApiError(entry: ApiErrorLogEntry): void { // Silently ignore logging failures to avoid disrupting the main flow } } + +function sanitizeError(error: LlmErrorDetails): LlmErrorDetails { + return { + ...error, + message: maskSensitive(error.message), + stack: error.stack ? maskSensitive(error.stack) : undefined, + causes: error.causes?.map(sanitizeError), + }; +} diff --git a/packages/core/src/common/llm-error.ts b/packages/core/src/common/llm-error.ts new file mode 100644 index 00000000..95f32558 --- /dev/null +++ b/packages/core/src/common/llm-error.ts @@ -0,0 +1,172 @@ +const MAX_MESSAGE_LENGTH = 500; +const MAX_CAUSE_DEPTH = 5; + +type ErrorRecord = Record; + +export type LlmErrorDetails = { + name: string; + message: string; + stack?: string; + status?: number; + code?: string; + type?: string; + param?: string; + requestId?: string; + traceId?: string; + causes?: LlmErrorDetails[]; +}; + +/** + * Produce a concise, credential-safe explanation from OpenAI-compatible API + * errors and their underlying network causes. + */ +export function describeLlmError(error: unknown): string { + const details = getLlmErrorDetails(error); + const parts: string[] = []; + + if (details.status !== undefined) { + const message = getProviderMessage(error) ?? details.message; + parts.push(`HTTP ${details.status}: ${message}`); + if (details.code) { + parts.push(`code: ${details.code}`); + } + if (details.type) { + parts.push(`type: ${details.type}`); + } + if (details.param) { + parts.push(`param: ${details.param}`); + } + if (details.requestId) { + parts.push(`request ID: ${details.requestId}`); + } + if (details.traceId) { + parts.push(`trace ID: ${details.traceId}`); + } + return formatErrorParts(parts); + } + + const causeMessage = findUsefulCauseMessage(details.causes ?? []); + if (causeMessage && isGenericConnectionMessage(details.message)) { + return `Connection error: ${causeMessage}`; + } + if (causeMessage && causeMessage !== details.message) { + return `${details.message} (cause: ${causeMessage})`; + } + return details.message; +} + +/** + * Extract serializable diagnostics for local logs without retaining the + * original error object, which can contain circular references. + */ +export function getLlmErrorDetails(error: unknown): LlmErrorDetails { + return getErrorDetails(error, 0, new Set()); +} + +function getErrorDetails(error: unknown, depth: number, seen: Set): LlmErrorDetails { + const record = isRecord(error) ? error : null; + const name = safeText(record?.name) ?? (error instanceof Error ? error.name : "UnknownError"); + const message = safeText(record?.message) ?? safeText(error) ?? "Unknown error"; + const details: LlmErrorDetails = { name, message }; + + const status = record?.status; + if (typeof status === "number" && Number.isFinite(status)) { + details.status = status; + } + for (const key of ["code", "type", "param"] as const) { + const value = safeText(record?.[key]); + if (value) { + details[key] = value; + } + } + const requestId = safeText(record?.requestID) ?? getHeader(record?.headers, "x-request-id"); + if (requestId) { + details.requestId = requestId; + } + const traceId = getHeader(record?.headers, "x-ds-trace-id"); + if (traceId) { + details.traceId = traceId; + } + const stack = safeText(record?.stack, MAX_MESSAGE_LENGTH * 4); + if (stack) { + details.stack = stack; + } + + if (record && depth < MAX_CAUSE_DEPTH && !seen.has(record)) { + seen.add(record); + if (record.cause !== undefined) { + details.causes = [getErrorDetails(record.cause, depth + 1, seen)]; + } + } + return details; +} + +function getProviderMessage(error: unknown): string | undefined { + if (!isRecord(error) || !isRecord(error.error)) { + return undefined; + } + return safeText(error.error.message); +} + +function getHeader(headers: unknown, name: string): string | undefined { + if (headers && typeof (headers as { get?: unknown }).get === "function") { + return safeText((headers as { get(name: string): unknown }).get(name)); + } + if (isRecord(headers)) { + const entry = Object.entries(headers).find(([key]) => key.toLowerCase() === name); + return entry ? safeText(entry[1]) : undefined; + } + return undefined; +} + +function findUsefulCauseMessage(causes: LlmErrorDetails[]): string | undefined { + for (const cause of causes) { + if (!isGenericConnectionMessage(cause.message)) { + return cause.message; + } + const nested = findUsefulCauseMessage(cause.causes ?? []); + if (nested) { + return nested; + } + } + return undefined; +} + +function isGenericConnectionMessage(message: string): boolean { + const normalized = message.toLowerCase().replace(/\s+/g, " ").trim(); + return ( + normalized === "connection error" || + normalized === "connection error." || + normalized === "fetch failed" || + normalized === "request timed out." || + normalized === "request timed out" + ); +} + +function formatErrorParts(parts: string[]): string { + const [first, ...metadata] = parts; + return metadata.length > 0 ? `${first} [${metadata.join(", ")}]` : (first ?? "Unknown error"); +} + +function safeText(value: unknown, maxLength = MAX_MESSAGE_LENGTH): string | undefined { + if (typeof value !== "string" && typeof value !== "number") { + return undefined; + } + const normalized = maskSensitive(String(value)).replace(/\s+/g, " ").trim(); + if (!normalized) { + return undefined; + } + return normalized.length > maxLength ? `${normalized.slice(0, maxLength)}...` : normalized; +} + +function isRecord(value: unknown): value is ErrorRecord { + return Boolean(value) && typeof value === "object"; +} + +function maskSensitive(text: string): string { + return text + .replace(/(Authorization\s*[:=]\s*(?:Bearer\s+)?)[^\s,;]+/gi, "$1***MASKED***") + .replace(/([?&](?:api[_-]?key|access[_-]?token|token)=)[^&\s]+/gi, "$1***MASKED***") + .replace(/(["']?(?:api[_-]?key|access[_-]?token|secret)["']?\s*[:=]\s*["']?)[^",}\s]+/gi, "$1***MASKED***") + .replace(/\bsk-[A-Za-z0-9_-]{8,}\b/g, "***MASKED***"); +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 33adf6f2..9749ff4b 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -105,6 +105,8 @@ export { DEEPSEEK_V4_MODELS, supportsMultimodal, defaultsToThinkingMode } from " export { findGitBashPath, resolveShellPath, setShellIfWindows } from "./common/shell-utils"; export { logApiError } from "./common/error-logger"; export { logOpenAIChatCompletionDebug } from "./common/debug-logger"; +export { describeLlmError, getLlmErrorDetails } from "./common/llm-error"; +export type { LlmErrorDetails } from "./common/llm-error"; export { clampBashTimeoutMs, DEFAULT_BASH_TIMEOUT_MS, diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index 2101b8b6..3add448e 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -32,6 +32,7 @@ import { McpManager } from "./mcp/mcp-manager"; import type { McpServerConfig, PermissionScope, PermissionSettings } from "./settings"; import { logApiError } from "./common/error-logger"; import { logOpenAIChatCompletionDebug, normalizeDebugError } from "./common/debug-logger"; +import { describeLlmError, getLlmErrorDetails } from "./common/llm-error"; import { killProcessTree } from "./common/process-tree"; import { GitFileHistory, type FileHistoryCheckpointResult } from "./common/file-history"; import { clearSessionState, getSnippet, rebuildSessionStateFromHistory } from "./common/state"; @@ -537,11 +538,7 @@ export class SessionManager { requestId, sessionId, model: typeof request.model === "string" ? request.model : undefined, - error: { - name: error instanceof Error ? error.name : "UnknownError", - message: error instanceof Error ? error.message : String(error), - stack: error instanceof Error ? error.stack : undefined, - }, + error: getLlmErrorDetails(error), request: streamRequest, }); this.emitLlmStreamProgress(requestId, startedAt, estimatedTokens, "end", sessionId); @@ -671,11 +668,7 @@ export class SessionManager { requestId, sessionId, model: typeof request.model === "string" ? request.model : undefined, - error: { - name: error instanceof Error ? error.name : "UnknownError", - message: error instanceof Error ? error.message : String(error), - stack: error instanceof Error ? error.stack : undefined, - }, + error: getLlmErrorDetails(error), request: streamRequest, }); throw error; @@ -1509,7 +1502,7 @@ ${agentInstructions} false ); } catch (error) { - const errMessage = error instanceof Error ? error.message : String(error); + const errMessage = describeLlmError(error); const aborted = this.isAbortLikeError(error) || sessionController.signal.aborted; this.updateSessionEntry(sessionId, (entry) => ({ ...entry, diff --git a/packages/core/src/tests/llm-error.test.ts b/packages/core/src/tests/llm-error.test.ts new file mode 100644 index 00000000..a5a3e1ca --- /dev/null +++ b/packages/core/src/tests/llm-error.test.ts @@ -0,0 +1,42 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { describeLlmError, getLlmErrorDetails } from "../common/llm-error"; + +test("describeLlmError shows provider business errors with trace metadata", () => { + const error = Object.assign(new Error("402 Insufficient Balance"), { + status: 402, + error: { + message: "Insufficient Balance", + }, + code: "invalid_request_error", + type: "unknown_error", + headers: new Headers({ + "x-request-id": "request-123", + "x-ds-trace-id": "trace-456", + }), + }); + + assert.equal( + describeLlmError(error), + "HTTP 402: Insufficient Balance [code: invalid_request_error, type: unknown_error, request ID: request-123, trace ID: trace-456]" + ); +}); + +test("describeLlmError unwraps underlying network causes", () => { + const cause = new Error("getaddrinfo ENOTFOUND api.deepseek.com"); + const error = Object.assign(new Error("Connection error."), { cause }); + + assert.equal(describeLlmError(error), "Connection error: getaddrinfo ENOTFOUND api.deepseek.com"); +}); + +test("LLM error details stop at circular causes and redact credentials", () => { + const first = Object.assign(new Error("Connection error."), { cause: undefined as unknown }); + const second = new Error("fetch failed: https://example.test?api_key=sk-secret-value"); + (first as Error & { cause: unknown }).cause = second; + (second as Error & { cause: unknown }).cause = first; + + const details = getLlmErrorDetails(first); + assert.equal(details.causes?.[0]?.message, "fetch failed: https://example.test?api_key=***MASKED***"); + assert.equal(details.causes?.[0]?.causes?.[0]?.message, "Connection error."); + assert.equal(details.causes?.[0]?.causes?.[0]?.causes, undefined); +}); From e9e0b16d1d940edfe3acc19846ade4a2c4843e20 Mon Sep 17 00:00:00 2001 From: Ji Zhang Date: Fri, 17 Jul 2026 14:22:22 +0800 Subject: [PATCH 08/11] feat: add plan-mode.md and update AGENTS.md --- .deepcode/AGENTS.md | 14 +- docs/plan-mode.md | 130 ++++++++++++++ docs/plan-mode_en.md | 130 ++++++++++++++ .../research/2026-07-plan-mode-vs-non-plan.md | 159 ++++++++++++++++++ .../2026-07-plan-mode-vs-non-plan_en.md | 159 ++++++++++++++++++ 5 files changed, 588 insertions(+), 4 deletions(-) create mode 100644 docs/plan-mode.md create mode 100644 docs/plan-mode_en.md create mode 100644 docs/research/2026-07-plan-mode-vs-non-plan.md create mode 100644 docs/research/2026-07-plan-mode-vs-non-plan_en.md diff --git a/.deepcode/AGENTS.md b/.deepcode/AGENTS.md index 7471cc15..186f078b 100644 --- a/.deepcode/AGENTS.md +++ b/.deepcode/AGENTS.md @@ -25,6 +25,7 @@ packages/ │ ├── ui/hooks/ # Custom hooks (cursor, history navigation, paste handling, terminal input, statusline) │ ├── ui/contexts/ # React contexts (AppContext, RawModeContext) │ ├── ui/statusline/ # Pluggable statusline providers (command, module) +│ ├── ui/utils/ # Shared UI utilities (writing, formatting) │ └── tests/ # UI-focused tests with run-tests.mjs runner ├── vscode-ide-companion/ # VSCode extension companion │ └── src/ # extension.ts, provider.ts, utils.ts @@ -55,6 +56,9 @@ All commands run from the repo root. | `npm run start` | Runs the locally built CLI (`scripts/start.js`) | | `npm run build-and-start` | Builds then starts the CLI | | `npm run clean` | Removes generated files and dist directories | +| `npm run release:version` | Bumps version across all packages | +| `npm run prepare:package` | Prepares the CLI package for distribution | +| `npm run prepare:vscode` | Prepares the VSCode extension for distribution | To run a **single test file** within a package: ``` @@ -111,17 +115,19 @@ Run the CLI locally for manual testing: `node packages/cli/dist/cli.js` (after ` ## Architecture Overview -The CLI (`@vegamo/deepcode-cli`) renders a terminal UI using [Ink](https://github.com/vadimdemedes/ink) (React for terminals). `SessionManager` (in `@vegamo/deepcode-core`) drives the LLM interaction loop: it builds system prompts, sends user messages with optional skills/images, streams responses, executes tool calls via `ToolExecutor`, and compacts context when token thresholds are exceeded (512K for DeepSeek V4 models, 128K for others). OpenAI client connectivity is managed by `createOpenAIClient()` with a 180-second keep-alive timeout. +The CLI (`@vegamo/deepcode-cli`) renders a terminal UI using [Ink](https://github.com/vadimdemedes/ink) (React for terminals). `SessionManager` (in `@vegamo/deepcode-core`) drives the LLM interaction loop: it builds system prompts, sends user messages with optional skills/images, streams responses, executes tool calls via `ToolExecutor`, and compacts context when token thresholds are exceeded (512K for DeepSeek V4 models, 128K for others). OpenAI client connectivity is managed by `createOpenAIClient()` with a 180-second keep-alive timeout. API errors are normalized through `describeLlmError()` in `packages/core/src/common/llm-error.ts`, which produces credential-safe, structured error details. -Seven built-in tools are available to the LLM: `bash`, `read`, `write`, `edit`, `AskUserQuestion`, `UpdatePlan`, and `WebSearch`. Tool definitions are registered in `packages/core/src/tools/executor.ts` and described to the LLM via `packages/core/src/prompt.ts`. +Seven built-in tools are available to the LLM: `bash`, `read`, `write`, `edit`, `AskUserQuestion`, `UpdatePlan`, and `WebSearch`. The `read` tool returns a `snippet_id` that must be passed to subsequent `edit` calls, ensuring edits always operate on a known, session-local file snapshot. Tool definitions are registered in `packages/core/src/tools/executor.ts` and described to the LLM via `packages/core/src/prompt.ts`. A **permission system** (`packages/core/src/common/permissions.ts`) controls tool execution scopes (read/write/delete/network/git-log, etc.) with configurable allow/deny/ask decisions. A **file history system** (`packages/core/src/common/file-history.ts`) provides undo/checkpoint support via lightweight Git branches. -**Slash commands**: `/skills`, `/model`, `/new`, `/init`, `/resume`, `/continue`, `/undo`, `/mcp`, `/raw`, `/exit`, plus dynamic `/skill-name` for each loaded skill. +**Slash commands**: `/skills`, `/model`, `/plan`, `/new`, `/init`, `/resume`, `/continue`, `/undo`, `/mcp`, `/raw`, `/exit`, plus dynamic `/skill-name` for each loaded skill. -**Key UI features**: `@` file mentions in the prompt input, `Ctrl+O` to view live process stdout, `Ctrl+V` to paste images, `Ctrl+X` to clear images, Shift+Enter for newlines, pluggable statusline, MCP server status display, undo selector, and permission prompts. +**Plan Mode** (`/plan` or `Shift+Tab`): Restricts the agent to read-only operations on the first turn and requires it to produce a task plan via `` for user approval before any file writes, deletions, or git mutations. When enabled, write/delete/mutate-git-log permissions are force-asked regardless of user settings. + +**Key UI features**: `@` file mentions in the prompt input, `Ctrl+O` to view live process stdout, `Ctrl+V` to paste images, `Ctrl+X` to clear images, Shift+Enter for newlines, `Shift+Tab` to toggle Plan Mode, pluggable statusline, MCP server status display, undo selector, and permission prompts. **CLI flags**: `-p ` / `--prompt` to auto-submit a prompt on launch, `-r [sessionId]` / `--resume [sessionId]` to resume a session or show the session picker, `-v` / `--version`, `-h` / `--help`. diff --git a/docs/plan-mode.md b/docs/plan-mode.md new file mode 100644 index 00000000..c1ece359 --- /dev/null +++ b/docs/plan-mode.md @@ -0,0 +1,130 @@ +# Plan Mode + +Plan Mode(规划模式)是 Deep Code 内置的一种协作模式。在 Plan Mode 下,AI 助手会与你共同制定详细的实施方案,但**不会执行任何会修改代码的操作**。先规划、再实现,让复杂任务的每一步都有据可查。 + +## 为什么需要 Plan Mode + +直接让 AI 改代码适合简单任务,但面对复杂的跨文件重构、新功能开发或架构调整时,常见的问题是: + +- AI 在缺乏全貌的情况下就开始改代码,导致方向偏离 +- 缺少共识就动手,后续来回修正成本高 +- 修改与规划混在一起,难以追溯决策依据 + +Plan Mode 把"想清楚"和"动手做"拆成两个阶段,先在规划阶段充分探索、提问、达成共识,形成一份决策完备的方案再进入实现。 + +## 启用 Plan Mode + +有两种方式可以进入 Plan Mode: + +| 方式 | 操作 | +| ---- | ---- | +| 快捷键 | 在输入框中按 `Shift+Tab`,可随时在 Plan Mode 和 Default Mode 之间切换 | +| 斜杠命令 | 输入 `/plan`,或输入 `/` 打开命令菜单后选择 `/plan` | + +进入 Plan Mode 后,输入框右上角会显示黄色 `💡 Plan mode` 提示,表明当前处于规划模式。 + +退出 Plan Mode 同样使用 `Shift+Tab` 切换,或在输出方案后选择 "switch to Default mode"。 + +## Plan Mode 下的行为 + +### AI 的三个规划阶段 + +进入 Plan Mode 后,AI 助手会遵循一个三阶段的协作流程: + +1. **环境摸底(Phase 1)**:先通过阅读文件、搜索代码、检查配置等方式理解项目现状,尽可能通过探索消除不确定性,而不是直接提问。 +2. **意图对齐(Phase 2)**:围绕你的目标和偏好提问,明确成功标准、范围边界、约束条件和关键取舍。 +3. **方案推敲(Phase 3)**:在意图稳定后,讨论具体实现路径,包括接口设计、数据流、边界情况、测试策略等,直到方案"决策完备"。 + +在每个阶段,AI 都会通过 `AskUserQuestion` 工具与你交互,提供具体的选项供你选择。 + +### 严格限制:禁止修改操作 + +Plan Mode 的核心规则是**只规划,不动手**。例如以下操作是**被允许**的: + +- 读取和搜索文件、配置、类型定义、文档 +- 静态分析和代码探索 +- 运行不影响仓库追踪文件的干运行命令(如 `--dry-run`) +- 运行测试、构建等可能写入缓存或构建产物(如 `target/`、`.cache/`)但不修改仓库追踪文件的命令 + +以下操作则**不被允许**: + +- 编辑或写入文件 +- 运行会修改文件的格式化或 lint 工具 +- 应用补丁、迁移或代码生成 +- 其他目的为实现方案而非完善方案的副作用操作 + +### 权限强制限制 + +即使在 `settings.json` 中配置了对写入、删除等操作自动放行,Plan Mode 也会**强制将这些权限升级为"询问"**(ask)。具体来说,以下权限范围在 Plan Mode 下始终需要确认: + +| 权限范围 | 说明 | +| -------- | ---- | +| `write-in-cwd` | 在工作区内创建或覆写文件 | +| `write-out-cwd` | 在工作区外创建或覆写文件 | +| `delete-in-cwd` | 删除工作区内的文件 | +| `delete-out-cwd` | 删除工作区外的文件 | +| `mutate-git-log` | 修改 Git 历史 | + +这一设计确保即使权限配置较为宽松,Plan Mode 也不会意外修改你的代码。 + +## 输出方案 + +当 AI 认为方案已经决策完备,它就会在回复一个 `` 块: + +```xml + +方案内容(使用 Markdown 格式) + +``` + +方案通常包含以下部分: + +- **标题**:方案名称 +- **Summary**:简要概述 +- **Key Changes / Implementation Changes**:关键变更,以行为描述为主(而非文件清单) +- **Test Plan**:测试用例和场景 +- **Assumptions**:做出的假设和选择的默认方案 + +方案输出后,你不需要输入任何额外内容,Deep Code 会自动弹出选择界面: + +| 选项 | 作用 | +| ---- | ---- | +| **1. implement this plan** | 退出 Plan Mode,自动发送实现指令,让 AI 开始按方案写代码 | +| **2. stay in Plan mode** | 保持在 Plan Mode,继续修改或完善方案 | +| **3. switch to Default mode** | 退出 Plan Mode,回到默认模式(不自动开始实现) | + +你可以用数字键 `1-3` 直接选择,也可以用 `↑/↓` 移动光标后按 `Enter` 确认。按 `Esc` 等同于选择 "stay in Plan mode"。 + +## Plan Mode 与 UpdatePlan 工具的区别 + +Plan Mode 是一种**对话协作模式**,最终产物是 `` 块中的完整方案。 + +`UpdatePlan` 是 Deep Code 的**任务进度清单工具**(对应 `UpdatePlan` 工具),用于在执行过程中展示步骤进度。它不会进入或退出 Plan Mode,也不是最终规划产物。 + +两者可以配合使用:在实现阶段,AI 可能会使用 `UpdatePlan` 来跟踪复杂任务的执行进度,但方案本身已在 Plan Mode 中确定。 + +## 典型使用场景 + +### 1. 新功能开发 + +``` +给用户列表加上分页和搜索功能。 +``` + +在 Plan Mode 下,AI 会先了解现有代码结构、路由、数据模型,然后与你讨论分页方式(游标还是偏移)、搜索范围、API 变更等问题,最后输出一份完整方案。 + +### 2. 代码重构 + +``` +把 UserService 中混杂的业务逻辑拆分到独立的 service 中。 +``` + +AI 会先分析 `UserService` 的依赖关系和调用方,给出拆分方案、迁移步骤和兼容性考虑。 + +### 3. 架构评审 + +``` +分析当前项目的认证流程,给出安全改进建议。 +``` + +AI 会遍历认证相关代码,识别潜在风险,并以方案形式给出具体的改进步骤。 diff --git a/docs/plan-mode_en.md b/docs/plan-mode_en.md new file mode 100644 index 00000000..5b53c801 --- /dev/null +++ b/docs/plan-mode_en.md @@ -0,0 +1,130 @@ +# Plan Mode + +Plan Mode is a built-in collaboration mode in Deep Code. When Plan Mode is active, the AI assistant works with you to create a detailed implementation plan, but **does not perform any code-modifying actions**. Plan first, implement later—giving every step of a complex task a clear rationale. + +## Why Plan Mode + +Asking the AI to "just do it" works well for simple tasks, but with complex cross-file refactors, feature development, or architectural changes, common pitfalls include: + +- The AI starts editing without a full picture, leading to wrong directions +- No shared understanding before acting, causing expensive back-and-forth corrections +- Implementation and planning mixed together, making it hard to trace decisions + +Plan Mode separates "thinking" from "doing." First, explore, ask questions, and reach agreement during the planning phase. Then, once the plan is decision-complete, move on to implementation. + +## Enabling Plan Mode + +You can enter Plan Mode in two ways: + +| Method | Action | +| ------ | ------ | +| Keyboard shortcut | Press `Shift+Tab` in the input box to toggle between Plan Mode and Default Mode | +| Slash command | Type `/plan`, or type `/` to open the command menu and select `/plan` | + +Once active, a yellow `💡 Plan mode` indicator appears in the top-right corner of the input area. + +To leave Plan Mode, toggle again with `Shift+Tab`, or select "switch to Default mode" after reviewing a proposed plan. + +## Behavior in Plan Mode + +### Three Planning Phases + +In Plan Mode, the AI follows a three-phase collaboration workflow: + +1. **Ground in the environment (Phase 1)** — Understand the current project state by reading files, searching code, and checking configuration. Resolve unknowns through exploration before asking questions. +2. **Intent chat (Phase 2)** — Ask about goals and preferences to clarify success criteria, scope boundaries, constraints, and key tradeoffs. +3. **Implementation chat (Phase 3)** — Once intent is stable, discuss the concrete implementation approach: API design, data flow, edge cases, testing strategy, and so on, until the spec is "decision complete." + +In each phase, the AI interacts with you through the `AskUserQuestion` tool, offering concrete options to choose from. + +### Strict Rules: No Mutations + +Plan Mode's core rule: **plan only, don't touch code**. The following actions are **allowed**: + +- Reading and searching files, configs, type definitions, and docs +- Static analysis and code exploration +- Running dry-run commands that don't modify repo-tracked files +- Running tests or builds that may write to caches or build artifacts (e.g. `target/`, `.cache/`) without altering tracked files + +The following actions are **not allowed**: + +- Editing or writing files +- Running formatters or linters that rewrite files +- Applying patches, migrations, or code generation +- Side-effectful commands whose purpose is to execute the plan rather than refine it + +### Permission Restrictions + +Even if your `settings.json` is configured to auto-allow write, delete, or similar operations, Plan Mode **forcibly escalates these to "ask."** Specifically, the following permission scopes always require confirmation in Plan Mode: + +| Permission scope | Description | +| ---------------- | ----------- | +| `write-in-cwd` | Create or overwrite files within the workspace | +| `write-out-cwd` | Create or overwrite files outside the workspace | +| `delete-in-cwd` | Delete files within the workspace | +| `delete-out-cwd` | Delete files outside the workspace | +| `mutate-git-log` | Modify Git history | + +This ensures that Plan Mode won't accidentally modify your code, even with permissive settings. + +## The Proposed Plan + +When the AI considers the plan decision-complete—meaning an implementer needs to make no further decisions—it outputs a `` block in the response: + +```xml + +Plan content (in Markdown) + +``` + +A plan typically includes: + +- **Title**: The plan name +- **Summary**: A brief overview +- **Key Changes / Implementation Changes**: Critical changes described at the behavior level (not a file-by-file inventory) +- **Test Plan**: Test cases and scenarios +- **Assumptions**: Assumptions made and defaults chosen + +After the plan is output, Deep Code automatically shows a choice dialog—no extra input needed: + +| Option | Effect | +| ------ | ------ | +| **1. implement this plan** | Leave Plan Mode and automatically send an implementation prompt so the AI starts coding | +| **2. stay in Plan mode** | Stay in Plan Mode to continue refining the plan | +| **3. switch to Default mode** | Leave Plan Mode and return to Default mode without starting implementation | + +You can press `1-3` to select directly, or use `↑/↓` to move the cursor and `Enter` to confirm. Pressing `Esc` is equivalent to choosing "stay in Plan mode." + +## Plan Mode vs. UpdatePlan Tool + +Plan Mode is a **collaborative conversation mode** whose final artifact is the complete plan inside a `` block. + +`UpdatePlan` is Deep Code's **progress checklist tool** that shows step-by-step progress during execution. It does not enter or exit Plan Mode and is not the final planning artifact. + +The two can work together: during implementation, the AI may use `UpdatePlan` to track progress on complex tasks, but the plan itself is already settled in Plan Mode. + +## Common Use Cases + +### 1. New Feature Development + +``` +Add pagination and search to the user list. +``` + +In Plan Mode, the AI first understands the existing code structure, routes, and data models, then discusses pagination style (cursor vs. offset), search scope, API changes, etc., before producing a complete plan. + +### 2. Code Refactoring + +``` +Split the tangled business logic in UserService into separate services. +``` + +The AI analyzes `UserService` dependencies and call sites, then proposes a split plan with migration steps and compatibility considerations. + +### 3. Architecture Review + +``` +Analyze the current authentication flow and suggest security improvements. +``` + +The AI walks through auth-related code, identifies potential risks, and delivers concrete improvement steps as a plan. diff --git a/docs/research/2026-07-plan-mode-vs-non-plan.md b/docs/research/2026-07-plan-mode-vs-non-plan.md new file mode 100644 index 00000000..27556b2a --- /dev/null +++ b/docs/research/2026-07-plan-mode-vs-non-plan.md @@ -0,0 +1,159 @@ +# Plan Mode 对比实验:同一任务下的质量、效率与成本分析 + +> **模型**: deepseek-v4-pro +> **实验日期**: 2026-07-17 +> **会话记录**: Plan Mode (`5fa1934d`), Non-Plan (`ce7157e1`) + +--- + +## 摘要 + +我们设计了一组受控对比实验,在完全相同的任务上同时启动两个 Deep Code 会话,其中一个开启 Plan Mode(`/plan`),另一个未开启,以评估 Deep Code 的 Plan Mode 在**结果质量、执行效率、Token 消耗与真实费用**四个维度的影响。两个会话同时启动并使用 `deepseek-v4-pro` 模型,从而排除了算力波动带来的干扰。 + +核心发现:Plan Mode 以 **26% 的成本增幅** 和 **32% 的时间增幅**,换取了 **从完全不可用到 75% 可用的质量跃迁**;同时其推理密度(Reasoning Ratio)几乎是非 Plan 模式的 **1.5 倍**,表明 Plan Mode 有效引导了模型的深度思考行为。 + +--- + +## 1. 实验设计 + +### 1.1 任务描述 + +要求 Agent 解决一个真实且有难度的 Python 任务。 + +完整任务描述与测试数据见 GitHub:[qorzj/deepcode-qrcode-benchmark](https://github.com/qorzj/deepcode-qrcode-benchmark) + +### 1.2 控制变量 + +| 变量 | Plan Mode 组 | 对照组 | +|------|-------------|--------| +| 模型 | `deepseek-v4-pro` | `deepseek-v4-pro` | +| 思考模式 | 开启 | 开启 | +| 推理强度 | max | max | +| 启动时间 | 2026-07-17 03:07:49 UTC | 2026-07-17 03:07:45 UTC | +| 初始 Prompt | 相同 | 相同 | +| 工作目录 | 相同项目副本 | 相同项目副本 | +| Plan Mode | ✅ 开启 | ❌ 未开启 | + +两组同时启动,有效规避了 DeepSeek 官方模型在不同时段因负载和算力变化导致的性能差异。 + +--- + +## 2. 结果质量 + +### 2.1 解码结果对比 + +| 测试图片 | Plan Mode | Non-Plan | +|----------|-----------|----------| +| `1.png` | ❌ 乱码 | ❌ 纯数字串 | +| `2.png` | ✅ 正确 | ❌ 纯数字串 | +| `3.png` | ✅ 正确 | ❌ 纯数字串 | +| `4.jpg` | ✅ 正确 | ❌ 字母数字混合乱码 | +| **整体通过率** | **75% (3/4)** | **0% (0/4)** | + +### 2.2 定性分析 + +**Non-Plan 组(对照组)** 的问题模式非常典型:Agent 匆忙进入编码阶段,实现了完整的算法框架(约 34 KB 代码,涵盖 GF(256)、RS 纠错、Finder 检测等所有模块),但由于缺乏前期规划,在模块采样网格的对齐精度上存在系统性偏差。结果是:4 张图片均能"跑通"(不报错),但全部输出为无意义的数字序列,Agent 甚至没有意识到结果是错误的。 + +**Plan Mode 组** 在初期花费约 2 分钟生成了详细的实施计划,明确列出了每个子模块的职责、依赖关系和测试策略。这一前期投入使得 Agent 在整个实现过程中保持了清晰的方向感。最终的 3/4 成功率证明了计划驱动的方法论优势。唯一的失败案例(`1.png`),Agent 在结束时判断是由 finder pattern 检测的中心定位存在约半个模块的偏移导致的。 + +--- + +## 3. 墙钟时间 + +| 指标 | Plan Mode | Non-Plan | 增幅 | +|------|-----------|----------|------| +| 墙钟时间 | **41.9 min** | **31.7 min** | **+32%** | + +Non-Plan 模式下 Agent 无需等待用户审批,直接进入实现,因此在总耗时上领先。这 10 分钟的差距主要来自两个方面:(a) Plan Mode 前期约 2 分钟的计划生成时间;(b) Plan Mode 在遇到问题时倾向于更审慎地分析而非快速试错,导致每一轮推理时间略长。 + +值得注意的是,Plan Mode 的 **"产出/时间" 比实际更高**:Non-Plan 花费 31.7 分钟产出了一个不可用的方案,而 Plan Mode 的 41.9 分钟产出了 75% 可用的方案。从有效产出角度,前者完全无法与后者相比。 + +--- + +## 4. LLM 请求与 Token 消耗 + +### 4.1 请求规模 + +| 指标 | Plan Mode | Non-Plan | 增幅 | +|------|-----------|----------|------| +| LLM 请求数 (Reqs) | 155 | 122 | +27% | +| 工具调用数 | 158 | 132 | +20% | +| 平均每次请求耗时 | 16.2 s | 15.6 s | +4% | + +Plan Mode 产生了更多的 LLM 请求,并非因为"效率更低",而是因为其**实施了更多的验证步骤**——在每一阶段完成后主动运行测试、检查中间结果。Non-Plan 组则倾向于"猛写代码然后一次性测试"。 + +### 4.2 Token 消耗 + +| 指标 | Plan Mode | Non-Plan | 增幅 | +|------|-----------|----------|------| +| 输入 Token(总计) | 22,105,976 | 16,624,843 | +33% | +| 输出 Token(总计) | 173,907 | 140,742 | +24% | +| 缓存命中 Token | 22,004,992 | 16,540,800 | +33% | +| 缓存未命中 Token | 100,984 | 84,043 | +20% | +| 推理 Token | 84,743 | 44,839 | +89% | +| 活跃上下文 (Active Tokens) | 251,937 | 218,876 | +15% | + +### 4.3 效率指标 + +| 指标 | Plan Mode | Non-Plan | 说明 | +|------|-----------|----------|------| +| 缓存命中率 | 99.54% | 99.49% | 两者近乎相同 | +| 每次请求输入 Token | 142,619 | 136,269 | Plan Mode 略高(+5%) | +| 每次请求输出 Token | 1,122 | 1,154 | Non-Plan 略高(-3%) | +| 每次请求推理 Token | 547 | 368 | Plan Mode 高出 49% | +| **推理占比 (Reasoning Ratio)** | **48.7%** | **31.9%** | **Plan Mode 高出 16.8个百分点** | + +最引人注目的差异在**推理占比**:Plan Mode 下的 Agent 将其 48.7% 的输出 Token 用于推理思考,而非直接生成代码或文本。这意味着在 Plan Mode 中,模型花费了更多的"脑力"来分析问题、评估方案和验证假设,而非仅仅"动手写"。这种审慎的思考模式直接关联到更好的结果质量。 + +--- + +## 5. 真实费用 + +根据 DeepSeek 官方定价(2026年7月): + +| 计费项 | 单价(每百万 Token) | +|--------|---------------------| +| 输入(缓存命中) | ¥0.025 | +| 输入(缓存未命中) | ¥3.00 | +| 输出 | ¥6.00 | + +### 费用明细 + +| 费用项 | Plan Mode | Non-Plan | 差额 | +|--------|-----------|----------|------| +| 输入 - 缓存命中 | ¥0.55 | ¥0.41 | +¥0.14 | +| 输入 - 缓存未命中 | ¥0.30 | ¥0.25 | +¥0.05 | +| 输出 | ¥1.04 | ¥0.84 | +¥0.20 | +| **合计** | **¥1.90** | **¥1.51** | **+¥0.39 (+26%)** | + +Plan Mode 的总费用为 ¥1.90,相比 Non-Plan 的 ¥1.51 增加了 26%(约0.4元钱)。考虑到: +- Non-Plan 产出的是**完全不可用**的方案(¥1.51 打水漂) +- Plan Mode 产出的是 **75% 可用**的方案 + +边际成本 ¥0.39 换取的增量价值是巨大的。 + +--- + +## 6. 结论 + +### 6.1 Plan Mode 为什么有效? + +从数据中可归纳出三个机制: + +1. **强制前置规划(Forced Upfront Planning)**:Plan Mode 要求 Agent 在写代码之前先输出 ``,这相当于将模型的推理能力前置到"理解问题"阶段。数据佐证:Plan Mode 的推理 Token 占比为 48.7%,远超 Non-Plan 的 31.9%。 + +2. **分阶段验证(Incremental Validation)**:Plan Mode 的 Agent 倾向于在每个阶段完成后运行测试,而非一次性写完再测。证据:Plan Mode 的工具调用中,测试类命令(`bash python test_*.py`)的比例明显更高。这种小步快跑的策略降低了错误积累的风险。 + +3. **上下文聚焦(Context Focus)**:Plan Mode 中生成的计划充当了"锚点",帮助模型在长会话中保持目标一致性。Non-Plan 组在后期出现了明显的"目标漂移"——Agent 忘记了任务的核心是"正确解码",而陷入了"让代码能跑"的局部最优。 + +### 6.2 适用场景判断 + +Plan Mode 并非银弹。基于本次实验数据,我们建议: + +- **强烈推荐 Plan Mode**:高难度算法实现、架构设计、多模块协同开发、需要端到端验证的任务 +- **可考虑跳过 Plan Mode**:简单脚本、一次性数据处理、fix typo / update config 等低风险变更 +- **边际收益递减线**:当任务复杂度低到 Agent 可以在 3 轮以内完成时,Plan Mode 的前置规划成本可能超过其收益 + +--- + +*实验数据可在 Deep Code 会话记录中复验。Plan Mode 会话 ID: `5fa1934d-d738-4f07-aaae-8e0b80f7b2ae`;对照组会话 ID: `ce7157e1-bf12-404b-9e35-72027a4a55ed`。* diff --git a/docs/research/2026-07-plan-mode-vs-non-plan_en.md b/docs/research/2026-07-plan-mode-vs-non-plan_en.md new file mode 100644 index 00000000..84290bde --- /dev/null +++ b/docs/research/2026-07-plan-mode-vs-non-plan_en.md @@ -0,0 +1,159 @@ +# Plan Mode Benchmark: Quality, Efficiency & Cost Analysis on an Identical Task + +> **Model**: deepseek-v4-pro +> **Date**: 2026-07-17 +> **Sessions**: Plan Mode (`5fa1934d`), Non-Plan (`ce7157e1`) + +--- + +## Abstract + +We conducted a controlled A/B experiment to evaluate the impact of Deep Code's Plan Mode on four dimensions: **result quality, wall-clock time, token consumption, and real cost**. Two sessions were launched simultaneously on the same task using the same model (`deepseek-v4-pro`) — one with Plan Mode enabled, one without. This simultaneous setup eliminates the confounding effect of API-side compute fluctuations across time windows. + +**Core finding**: Plan Mode traded a **26% cost increase** and **32% time increase** for a **quality leap from 0% to 75% correctness**. Additionally, its reasoning density (Reasoning Ratio) was nearly **1.5×** that of the non-Plan session, indicating that Plan Mode effectively steers the model toward deeper analytical behavior. + +--- + +## 1. Experimental Design + +### 1.1 Task Description + +The agent was asked to solve a real-world, non-trivial Python problem. + +> Full task description and test data on GitHub: [qorzj/deepcode-qrcode-benchmark](https://github.com/qorzj/deepcode-qrcode-benchmark) + +### 1.2 Controlled Variables + +| Variable | Plan Mode Group | Control Group | +|----------|----------------|---------------| +| Model | `deepseek-v4-pro` | `deepseek-v4-pro` | +| Thinking Mode | Enabled | Enabled | +| Reasoning Effort | max | max | +| Start Time | 2026-07-17 03:07:49 UTC | 2026-07-17 03:07:45 UTC | +| Initial Prompt | Identical | Identical | +| Working Directory | Identical project copy | Identical project copy | +| Plan Mode | ✅ Enabled | ❌ Disabled | + +Both sessions launched simultaneously, effectively controlling for performance variance caused by DeepSeek official model load and compute fluctuations across different time periods. + +--- + +## 2. Result Quality + +### 2.1 Decoding Results + +| Test Image | Plan Mode | Non-Plan | +|-----------|-----------|----------| +| `1.png` | ❌ Garbled | ❌ Pure digits | +| `2.png` | ✅ Correct | ❌ Pure digits | +| `3.png` | ✅ Correct | ❌ Pure digits | +| `4.jpg` | ✅ Correct | ❌ Mixed alphanumeric garbage | +| **Overall Pass Rate** | **75% (3/4)** | **0% (0/4)** | + +### 2.2 Qualitative Analysis + +**Non-Plan (control)** exhibited a classic failure pattern: the agent rushed into coding, implementing a complete algorithmic framework (~34 KB of code covering GF(256), RS error correction, finder detection, and every other sub-module), but due to the absence of upfront planning, it suffered from a systematic misalignment in the module sampling grid. The result: all 4 images "ran" without errors, yet every output was a meaningless digit sequence — the agent never realized its results were wrong. + +**Plan Mode** spent its first ~2 minutes generating a detailed implementation plan that explicitly identified each sub-module's responsibilities, interdependencies, and testing strategy. This upfront investment gave the agent clear directional awareness throughout the implementation. The 3/4 success rate demonstrates the advantage of a plan-driven methodology. The sole failure (`1.png`), the agent concluded at the end, was caused by a ~0.5 module offset in finder pattern center localization. + +--- + +## 3. Wall-Clock Time + +| Metric | Plan Mode | Non-Plan | Delta | +|--------|-----------|----------|-------| +| Wall-Clock Time | **41.9 min** | **31.7 min** | **+32%** | + +Without Plan Mode, the agent requires no user approval and dives directly into implementation, leading in total elapsed time. The ~10 minute gap stems from two sources: (a) Plan Mode's ~2 minutes of upfront planning; (b) Plan Mode's tendency to analyze problems more carefully rather than trial-and-error quickly, resulting in slightly longer per-round reasoning times. + +Notably, Plan Mode's **output-per-time ratio is actually higher**: Non-Plan spent 31.7 minutes producing an unusable solution, while Plan Mode's 41.9 minutes yielded a 75%-correct solution — in terms of effective output, the former simply cannot compare to the latter. + +--- + +## 4. LLM Requests & Token Consumption + +### 4.1 Request Volume + +| Metric | Plan Mode | Non-Plan | Delta | +|--------|-----------|----------|-------| +| LLM Requests (Reqs) | 155 | 122 | +27% | +| Tool Calls | 158 | 132 | +20% | +| Avg. Time per Request | 16.2 s | 15.6 s | +4% | + +Plan Mode generated more LLM requests not because it was "less efficient," but because it **performed more validation steps** — proactively running tests and checking intermediate results after each stage. The Non-Plan session favored "write everything, then test once." + +### 4.2 Token Consumption + +| Metric | Plan Mode | Non-Plan | Delta | +|--------|-----------|----------|-------| +| Input Tokens (Total) | 22,105,976 | 16,624,843 | +33% | +| Output Tokens (Total) | 173,907 | 140,742 | +24% | +| Cached Hit Tokens | 22,004,992 | 16,540,800 | +33% | +| Cache Miss Tokens | 100,984 | 84,043 | +20% | +| Reasoning Tokens | 84,743 | 44,839 | +89% | +| Active Context (Active Tokens) | 251,937 | 218,876 | +15% | + +### 4.3 Efficiency Metrics + +| Metric | Plan Mode | Non-Plan | Notes | +|--------|-----------|----------|-------| +| Cache Hit Rate | 99.54% | 99.49% | Nearly identical | +| Input Tokens / Request | 142,619 | 136,269 | Plan Mode +5% | +| Output Tokens / Request | 1,122 | 1,154 | Non-Plan +3% | +| Reasoning Tokens / Request | 547 | 368 | Plan Mode +49% | +| **Reasoning Ratio** | **48.7%** | **31.9%** | **Plan Mode +16.8pp** | + +The most striking difference lies in the **Reasoning Ratio**: Plan Mode's agent devoted 48.7% of its output tokens to chain-of-thought reasoning rather than direct code generation or text output. This means that under Plan Mode, the model spent significantly more "brainpower" analyzing problems, evaluating approaches, and verifying assumptions — not merely "doing." This deliberative thinking pattern directly correlates with superior result quality. + +--- + +## 5. Real Cost + +Per DeepSeek official pricing (July 2026): + +| Line Item | Unit Price (per 1M tokens) | +|-----------|---------------------------| +| Input (cache hit) | ¥0.025 | +| Input (cache miss) | ¥3.00 | +| Output | ¥6.00 | + +### Cost Breakdown + +| Line Item | Plan Mode | Non-Plan | Delta | +|-----------|-----------|----------|-------| +| Input — cache hit | ¥0.55 | ¥0.41 | +¥0.14 | +| Input — cache miss | ¥0.30 | ¥0.25 | +¥0.05 | +| Output | ¥1.04 | ¥0.84 | +¥0.20 | +| **Total** | **¥1.90** | **¥1.51** | **+¥0.39 (+26%)** | + +Plan Mode's total cost was ¥1.90, a 26% increase over Non-Plan's ¥1.51 (about ¥0.39, or roughly $0.05 USD). Considering that: +- Non-Plan produced a **completely unusable** solution (¥1.51 wasted) +- Plan Mode produced a **75%-correct** solution + +The marginal cost of ¥0.39 unlocks enormous incremental value — a textbook case of "spending peanuts to buy steak." + +--- + +## 6. Conclusion + +### 6.1 Why Does Plan Mode Work? + +Three mechanisms emerge from the data: + +1. **Forced Upfront Planning**: Plan Mode requires the agent to output a `` before writing code, essentially front-loading the model's reasoning capacity into the problem-understanding phase. Evidence: Plan Mode's Reasoning Ratio of 48.7% far exceeds Non-Plan's 31.9%. + +2. **Incremental Validation**: Plan Mode agents tend to run tests after completing each stage rather than writing everything in one go. Evidence: Plan Mode's tool call mix shows a notably higher proportion of test-execution commands (`bash python test_*.py`). This "small-step, fast-feedback" strategy reduces the risk of compounding errors. + +3. **Context Anchoring**: The plan generated during Plan Mode serves as a cognitive anchor, helping the model maintain goal consistency throughout long sessions. The Non-Plan session exhibited noticeable "goal drift" in later stages — the agent forgot that the core objective was "correct decoding" and fell into a local optimum of "make the code run." + +### 6.2 When to Use Plan Mode + +Plan Mode is not a silver bullet. Based on this experimental data, we recommend: + +- **Strongly recommended**: High-complexity algorithm implementation, architecture design, multi-module collaborative development, tasks requiring end-to-end verification +- **Can skip**: Simple scripts, one-off data processing, low-risk changes (typo fixes, config updates) +- **Diminishing returns threshold**: When task complexity is low enough that the agent can complete it within 3 turns, Plan Mode's upfront planning cost likely exceeds its benefits + +--- + +*Experimental data is reproducible via Deep Code session transcripts. Plan Mode session ID: `5fa1934d-d738-4f07-aaae-8e0b80f7b2ae`; Control session ID: `ce7157e1-bf12-404b-9e35-72027a4a55ed`.* From f58586d81760dd4eb759ae6c9a78cbd3d27a1ba9 Mon Sep 17 00:00:00 2001 From: Ji Zhang Date: Fri, 17 Jul 2026 14:52:40 +0800 Subject: [PATCH 09/11] chore: update package versions to 0.1.34 for cli and core --- package-lock.json | 4 ++-- packages/cli/package.json | 2 +- packages/core/package.json | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index 5c894763..bf5ffd30 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7705,7 +7705,7 @@ }, "packages/cli": { "name": "@vegamo/deepcode-cli", - "version": "0.1.33", + "version": "0.1.34", "license": "MIT", "dependencies": { "@vegamo/deepcode-core": "file:../core", @@ -7751,7 +7751,7 @@ }, "packages/core": { "name": "@vegamo/deepcode-core", - "version": "0.1.33", + "version": "0.1.34", "license": "MIT", "dependencies": { "chalk": "^5.6.2", diff --git a/packages/cli/package.json b/packages/cli/package.json index c860e690..98bb90c7 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@vegamo/deepcode-cli", - "version": "0.1.33", + "version": "0.1.34", "description": "Deep Code CLI - Vibe coding for the deepseek-v4 model in your terminal", "license": "MIT", "type": "module", diff --git a/packages/core/package.json b/packages/core/package.json index bac0f126..37209705 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@vegamo/deepcode-core", - "version": "0.1.33", + "version": "0.1.34", "description": "Deep Code core library — LLM session management, tool execution, and shared utilities", "license": "MIT", "type": "module", From 618655078cbd23892dbb81b46727359482c505db Mon Sep 17 00:00:00 2001 From: Ji Zhang Date: Fri, 17 Jul 2026 14:52:48 +0800 Subject: [PATCH 10/11] 0.0.1 --- package-lock.json | 6 ++++-- package.json | 3 ++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index bf5ffd30..efe8ad74 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,7 +26,8 @@ "tsx": "^4.21.0", "typescript": "^6.0.3", "typescript-eslint": "^8.59.2" - } + }, + "version": "0.0.1" }, "node_modules/@alcalzone/ansi-tokenize": { "version": "0.3.0", @@ -7801,5 +7802,6 @@ "vscode": "^1.85.0" } } - } + }, + "version": "0.0.1" } diff --git a/package.json b/package.json index d4f502b4..b2f77080 100644 --- a/package.json +++ b/package.json @@ -48,5 +48,6 @@ "tsx": "^4.21.0", "typescript": "^6.0.3", "typescript-eslint": "^8.59.2" - } + }, + "version": "0.0.1" } From c5050871b3f69ba35d9c206485c9ca3d9fc73747 Mon Sep 17 00:00:00 2001 From: Ji Zhang Date: Fri, 17 Jul 2026 15:07:17 +0800 Subject: [PATCH 11/11] chore(release): v0.1.34 --- package-lock.json | 8 ++++---- package.json | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index efe8ad74..8da30426 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,10 +1,12 @@ { "name": "@vegamo/deepcode", + "version": "0.1.34", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@vegamo/deepcode", + "version": "0.1.34", "license": "MIT", "workspaces": [ "packages/*" @@ -26,8 +28,7 @@ "tsx": "^4.21.0", "typescript": "^6.0.3", "typescript-eslint": "^8.59.2" - }, - "version": "0.0.1" + } }, "node_modules/@alcalzone/ansi-tokenize": { "version": "0.3.0", @@ -7802,6 +7803,5 @@ "vscode": "^1.85.0" } } - }, - "version": "0.0.1" + } } diff --git a/package.json b/package.json index b2f77080..4a0e580e 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,6 @@ { "name": "@vegamo/deepcode", + "version": "0.1.34", "description": "Deep Code — CLI, core library, and VSCode companion", "license": "MIT", "packageManager": "npm@10.9.4", @@ -48,6 +49,5 @@ "tsx": "^4.21.0", "typescript": "^6.0.3", "typescript-eslint": "^8.59.2" - }, - "version": "0.0.1" + } }