diff --git a/CHANGELOG.md b/CHANGELOG.md index df47f2076..88f03b19c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Only write entries that are worth mentioning to users. ## Unreleased +- CLI: Add `--plan` flag and `default_plan_mode` config option — start new sessions in plan mode via `kimi --plan` or by setting `default_plan_mode = true` in `~/.kimi/config.toml`; resumed sessions preserve their existing plan mode state ## 1.29.0 (2026-04-01) - Core: Support hierarchical `AGENTS.md` loading — the CLI now discovers and merges `AGENTS.md` files from the git project root down to the working directory, including `.kimi/AGENTS.md` at each level; deeper files take priority under a 32 KiB budget cap, ensuring the most specific instructions are never truncated diff --git a/docs/en/configuration/config-files.md b/docs/en/configuration/config-files.md index 1af12af3e..966a09b88 100644 --- a/docs/en/configuration/config-files.md +++ b/docs/en/configuration/config-files.md @@ -27,6 +27,7 @@ The configuration file contains the following top-level configuration items: | `default_model` | `string` | Default model name, must be a model defined in `models` | | `default_thinking` | `boolean` | Whether to enable thinking mode by default (defaults to `false`) | | `default_yolo` | `boolean` | Whether to enable YOLO (auto-approve) mode by default (defaults to `false`) | +| `default_plan_mode` | `boolean` | Whether to start new sessions in plan mode by default (defaults to `false`); resumed sessions preserve their existing state | | `default_editor` | `string` | Default external editor command (e.g. `"vim"`, `"code --wait"`), auto-detects when empty | | `theme` | `string` | Terminal color theme, either `"dark"` or `"light"` (defaults to `"dark"`) | | `providers` | `table` | API provider configuration | @@ -42,6 +43,7 @@ The configuration file contains the following top-level configuration items: default_model = "kimi-for-coding" default_thinking = false default_yolo = false +default_plan_mode = false default_editor = "" theme = "dark" diff --git a/docs/en/configuration/overrides.md b/docs/en/configuration/overrides.md index 03ea6bda1..96d3f0e78 100644 --- a/docs/en/configuration/overrides.md +++ b/docs/en/configuration/overrides.md @@ -36,9 +36,12 @@ The model specified by `--model` must be defined in the configuration file's `mo | `--thinking` | Enable thinking mode | | `--no-thinking` | Disable thinking mode | | `--yolo, --yes, -y` | Auto-approve all operations | +| `--plan` | Start in plan mode | `--thinking` / `--no-thinking` overrides the thinking state saved from the last session. If not specified, uses the last session's state. +`--plan` enables plan mode for new sessions; when resuming an existing session, it forces plan mode on. You can also set `default_plan_mode = true` in the config file to start new sessions in plan mode by default. + ## Environment variable overrides Environment variables can override provider and model settings without modifying the configuration file. This is particularly useful in the following scenarios: diff --git a/docs/en/guides/interaction.md b/docs/en/guides/interaction.md index 50e4ed201..01dd8f51f 100644 --- a/docs/en/guides/interaction.md +++ b/docs/en/guides/interaction.md @@ -33,12 +33,15 @@ In plan mode, the AI can only use read-only tools (`Glob`, `Grep`, `ReadFile`) t ### Entering plan mode -There are three ways to enter plan mode: +There are four ways to enter plan mode: +- **CLI flag**: Use `kimi --plan` to start a new session directly in plan mode - **Keyboard shortcut**: Press `Shift-Tab` to toggle plan mode - **Slash command**: Enter `/plan` or `/plan on` - **AI-initiated**: When facing complex tasks, the AI may request to enter plan mode via the `EnterPlanMode` tool — you can accept or decline +You can also set `default_plan_mode = true` in the config file to start every new session in plan mode by default. See [Configuration files](../configuration/config-files.md). + When plan mode is active, the prompt changes to `📋` and a blue `plan` badge appears in the status bar. ### Reviewing plans diff --git a/docs/en/reference/kimi-command.md b/docs/en/reference/kimi-command.md index 3432f3759..ba7cdd8e6 100644 --- a/docs/en/reference/kimi-command.md +++ b/docs/en/reference/kimi-command.md @@ -126,6 +126,16 @@ Default loads `~/.kimi/mcp.json` (if exists). See [Model Context Protocol](../cu In YOLO mode, all file modifications and shell commands are automatically executed. Use with caution. ::: +## Plan mode + +| Option | Description | +|--------|-------------| +| `--plan` | Start a new session in plan mode | + +When started with `--plan`, the AI can only use read-only tools to explore the codebase and write an implementation plan. When resuming an existing session, `--plan` forces plan mode on; resuming without `--plan` preserves the session's existing state. + +You can also set `default_plan_mode = true` in the config file to start new sessions in plan mode by default. See [Configuration files](../configuration/config-files.md). + ## Thinking mode | Option | Description | diff --git a/docs/en/release-notes/changelog.md b/docs/en/release-notes/changelog.md index 5919a1ef1..d1365444b 100644 --- a/docs/en/release-notes/changelog.md +++ b/docs/en/release-notes/changelog.md @@ -4,6 +4,7 @@ This page documents the changes in each Kimi Code CLI release. ## Unreleased +- CLI: Add `--plan` flag and `default_plan_mode` config option — start new sessions in plan mode via `kimi --plan` or by setting `default_plan_mode = true` in `~/.kimi/config.toml`; resumed sessions preserve their existing plan mode state ## 1.29.0 (2026-04-01) - Core: Support hierarchical `AGENTS.md` loading — the CLI now discovers and merges `AGENTS.md` files from the git project root down to the working directory, including `.kimi/AGENTS.md` at each level; deeper files take priority under a 32 KiB budget cap, ensuring the most specific instructions are never truncated diff --git a/docs/zh/configuration/config-files.md b/docs/zh/configuration/config-files.md index 91c0b7c71..cb3496696 100644 --- a/docs/zh/configuration/config-files.md +++ b/docs/zh/configuration/config-files.md @@ -27,6 +27,7 @@ kimi --config '{"default_model": "kimi-for-coding", "providers": {...}, "models" | `default_model` | `string` | 默认使用的模型名称,必须是 `models` 中定义的模型 | | `default_thinking` | `boolean` | 默认是否开启 Thinking 模式(默认为 `false`) | | `default_yolo` | `boolean` | 默认是否开启 YOLO(自动审批)模式(默认为 `false`) | +| `default_plan_mode` | `boolean` | 默认是否以计划模式启动新会话(默认为 `false`);恢复的会话保留其原有状态 | | `default_editor` | `string` | 默认外部编辑器命令(如 `"vim"`、`"code --wait"`),为空时自动检测 | | `theme` | `string` | 终端配色主题,可选 `"dark"` 或 `"light"`(默认为 `"dark"`) | | `providers` | `table` | API 供应商配置 | @@ -42,6 +43,7 @@ kimi --config '{"default_model": "kimi-for-coding", "providers": {...}, "models" default_model = "kimi-for-coding" default_thinking = false default_yolo = false +default_plan_mode = false default_editor = "" theme = "dark" diff --git a/docs/zh/configuration/overrides.md b/docs/zh/configuration/overrides.md index a4dfdaa78..b6d498120 100644 --- a/docs/zh/configuration/overrides.md +++ b/docs/zh/configuration/overrides.md @@ -36,9 +36,12 @@ Kimi Code CLI 的配置可以通过多种方式设置,不同来源的配置按 | `--thinking` | 启用 thinking 模式 | | `--no-thinking` | 禁用 thinking 模式 | | `--yolo, --yes, -y` | 自动批准所有操作 | +| `--plan` | 以计划模式启动 | `--thinking` / `--no-thinking` 会覆盖上次会话保存的 thinking 状态。如果不指定,使用上次会话的状态。 +`--plan` 对新会话启用计划模式;恢复已有会话时强制开启计划模式。也可以在配置文件中设置 `default_plan_mode = true` 让新会话默认进入计划模式。 + ## 环境变量覆盖 环境变量可以在不修改配置文件的情况下覆盖供应商和模型设置。这在以下场景特别有用: diff --git a/docs/zh/guides/interaction.md b/docs/zh/guides/interaction.md index 07c4dd2d5..cedff60ef 100644 --- a/docs/zh/guides/interaction.md +++ b/docs/zh/guides/interaction.md @@ -33,12 +33,15 @@ Plan 模式是一种只读的规划模式,让 AI 在动手编码之前先制 ### 进入 Plan 模式 -有三种方式进入 Plan 模式: +有四种方式进入 Plan 模式: +- **启动参数**:使用 `kimi --plan` 直接以 Plan 模式启动新会话 - **快捷键**:按 `Shift-Tab` 切换 Plan 模式的开关 - **斜杠命令**:输入 `/plan` 或 `/plan on` - **AI 主动触发**:面对复杂任务时,AI 可能会通过 `EnterPlanMode` 工具请求进入 Plan 模式,你可以选择同意或拒绝 +你也可以在配置文件中设置 `default_plan_mode = true`,让每次新建会话都默认进入 Plan 模式。详见 [配置文件](../configuration/config-files.md)。 + 进入 Plan 模式后,提示符会变为 `📋`,底部状态栏会显示蓝色的 `plan` 标识。 ### 审批方案 diff --git a/docs/zh/reference/kimi-command.md b/docs/zh/reference/kimi-command.md index ac43207ae..468d53a9f 100644 --- a/docs/zh/reference/kimi-command.md +++ b/docs/zh/reference/kimi-command.md @@ -126,6 +126,16 @@ kimi [OPTIONS] COMMAND [ARGS] YOLO 模式下,所有文件修改和 Shell 命令都会自动执行,请谨慎使用。 ::: +## 计划模式 + +| 选项 | 说明 | +|------|------| +| `--plan` | 以计划模式启动新会话 | + +使用 `--plan` 启动时,AI 只能使用只读工具探索代码库并编写实现计划。恢复已有会话时,`--plan` 会强制开启计划模式;不带 `--plan` 恢复的会话保留其原有状态。 + +也可以在配置文件中设置 `default_plan_mode = true`,每次启动新会话时默认进入计划模式。详见 [配置文件](../configuration/config-files.md)。 + ## Thinking 模式 | 选项 | 说明 | diff --git a/docs/zh/release-notes/changelog.md b/docs/zh/release-notes/changelog.md index aca27bfa0..675b12df5 100644 --- a/docs/zh/release-notes/changelog.md +++ b/docs/zh/release-notes/changelog.md @@ -4,6 +4,7 @@ ## 未发布 +- CLI:新增 `--plan` 启动参数和 `default_plan_mode` 配置项——通过 `kimi --plan` 或在 `~/.kimi/config.toml` 中设置 `default_plan_mode = true` 可让新会话直接进入计划模式;恢复的会话保留其原有的计划模式状态 ## 1.29.0 (2026-04-01) - Core:支持层级化 `AGENTS.md` 加载——CLI 现在会从 git 项目根目录到工作目录逐层发现并合并 `AGENTS.md` 文件,包括每层目录中的 `.kimi/AGENTS.md`;在 32 KiB 预算上限下,更深层目录的文件优先保留,确保最具体的指令不会被截断 diff --git a/src/kimi_cli/acp/server.py b/src/kimi_cli/acp/server.py index 31852d036..8461d2286 100644 --- a/src/kimi_cli/acp/server.py +++ b/src/kimi_cli/acp/server.py @@ -224,6 +224,7 @@ async def _setup_session( cli_instance = await KimiCLI.create( session, mcp_configs=[mcp_config], + resumed=True, # _setup_session loads existing sessions ) config = cli_instance.soul.runtime.config acp_kaos = ACPKaos(self.conn, session.id, self.client_capabilities) diff --git a/src/kimi_cli/app.py b/src/kimi_cli/app.py index ad2e2747a..9d93529d6 100644 --- a/src/kimi_cli/app.py +++ b/src/kimi_cli/app.py @@ -81,6 +81,8 @@ async def create( thinking: bool | None = None, # Run mode yolo: bool = False, + plan_mode: bool = False, + resumed: bool = False, # Extensions agent_file: Path | None = None, mcp_configs: list[MCPConfig] | list[dict[str, Any]] | None = None, @@ -171,6 +173,10 @@ async def create( # determine yolo mode yolo = yolo if yolo else config.default_yolo + # determine plan mode (only for new sessions, not restored) + if not resumed: + plan_mode = plan_mode if plan_mode else config.default_plan_mode + llm = create_llm( provider, model, @@ -236,6 +242,13 @@ async def create( soul = KimiSoul(agent, context=context) + # Activate plan mode if requested (for new sessions or --plan flag) + if plan_mode and not soul.plan_mode: + await soul.set_plan_mode_from_manual(True) + elif plan_mode and soul.plan_mode: + # Already in plan mode from restored session, trigger activation reminder + soul.schedule_plan_activation_reminder() + # Create and inject hook engine from kimi_cli.hooks.engine import HookEngine diff --git a/src/kimi_cli/cli/__init__.py b/src/kimi_cli/cli/__init__.py index 0fb044220..8c2081626 100644 --- a/src/kimi_cli/cli/__init__.py +++ b/src/kimi_cli/cli/__init__.py @@ -183,6 +183,13 @@ def kimi( help="Automatically approve all actions. Default: no.", ), ] = False, + plan: Annotated[ + bool, + typer.Option( + "--plan", + help="Start in plan mode. Default: no.", + ), + ] = False, prompt: Annotated[ str | None, typer.Option( @@ -510,6 +517,9 @@ async def _run(session_id: str | None) -> tuple[Session, int]: try: startup_progress.update("Preparing session...") + # Track if we're resuming an existing session (vs creating new) + resumed = False + if session_id is not None: session = await Session.find(work_dir, session_id) if session is None: @@ -518,7 +528,9 @@ async def _run(session_id: str | None) -> tuple[Session, int]: session_id=session_id, ) session = await Session.create(work_dir, session_id) - logger.info("Switching to session: {session_id}", session_id=session.id) + else: + resumed = True # Session was actually found + logger.info("Switching to session: {session_id}", session_id=session.id) elif continue_: session = await Session.continue_(work_dir) if session is None: @@ -526,6 +538,7 @@ async def _run(session_id: str | None) -> tuple[Session, int]: "No previous session found for the working directory", param_hint="--continue", ) + resumed = True # Continuing previous session logger.info("Continuing previous session: {session_id}", session_id=session.id) else: session = await Session.create(work_dir) @@ -571,6 +584,8 @@ async def _run(session_id: str | None) -> tuple[Session, int]: model_name=model_name, thinking=thinking, yolo=yolo or (ui == "print"), # print mode implies yolo + plan_mode=plan, + resumed=resumed, agent_file=agent_file, mcp_configs=mcp_configs, skills_dirs=skills_dirs, diff --git a/src/kimi_cli/config.py b/src/kimi_cli/config.py index 50e8a50f4..affdab17f 100644 --- a/src/kimi_cli/config.py +++ b/src/kimi_cli/config.py @@ -188,6 +188,7 @@ class Config(BaseModel): default_model: str = Field(default="", description="Default model to use") default_thinking: bool = Field(default=False, description="Default thinking mode") default_yolo: bool = Field(default=False, description="Default yolo (auto-approve) mode") + default_plan_mode: bool = Field(default=False, description="Default plan mode for new sessions") default_editor: str = Field( default="", description="Default external editor command (e.g. 'vim', 'code --wait')", diff --git a/src/kimi_cli/soul/kimisoul.py b/src/kimi_cli/soul/kimisoul.py index 7e79b5d9e..0a0c4f50b 100644 --- a/src/kimi_cli/soul/kimisoul.py +++ b/src/kimi_cli/soul/kimisoul.py @@ -343,6 +343,16 @@ async def set_plan_mode_from_manual(self, enabled: bool) -> bool: """ return self._set_plan_mode(enabled, source="manual") + def schedule_plan_activation_reminder(self) -> None: + """Schedule a plan-mode activation reminder for the next turn. + + Use this when plan mode is already active (e.g. restored session with + ``--plan`` flag) and ``_set_plan_mode`` would early-return because the + state hasn't actually changed. + """ + if self._plan_mode: + self._pending_plan_activation_injection = True + def consume_pending_plan_activation_injection(self) -> bool: """Consume the next-step activation reminder scheduled by a manual toggle.""" if not self._plan_mode or not self._pending_plan_activation_injection: diff --git a/src/kimi_cli/web/runner/worker.py b/src/kimi_cli/web/runner/worker.py index 77610e8ac..2686fd9d9 100644 --- a/src/kimi_cli/web/runner/worker.py +++ b/src/kimi_cli/web/runner/worker.py @@ -45,16 +45,20 @@ async def run_worker(session_id: UUID) -> None: path=default_mcp_file, ) + # Detect whether this is a resumed session (has prior state on disk) + # vs a brand-new session that should honor config.default_plan_mode. + resumed = (session.dir / "state.json").exists() + # Create KimiCLI instance with MCP configuration try: - kimi_cli = await KimiCLI.create(session, mcp_configs=mcp_configs or None) + kimi_cli = await KimiCLI.create(session, mcp_configs=mcp_configs or None, resumed=resumed) except MCPConfigError as exc: logger.warning( "Invalid MCP config in {path}: {error}. Starting without MCP.", path=default_mcp_file, error=exc, ) - kimi_cli = await KimiCLI.create(session, mcp_configs=None) + kimi_cli = await KimiCLI.create(session, mcp_configs=None, resumed=resumed) # Run in wire stdio mode await kimi_cli.run_wire_stdio() diff --git a/tests/core/test_config.py b/tests/core/test_config.py index 7ff2901b2..0ad800baf 100644 --- a/tests/core/test_config.py +++ b/tests/core/test_config.py @@ -24,6 +24,7 @@ def test_default_config_dump(): "default_model": "", "default_thinking": False, "default_yolo": False, + "default_plan_mode": False, "default_editor": "", "theme": "dark", "models": {}, diff --git a/tests/core/test_plan_flag.py b/tests/core/test_plan_flag.py new file mode 100644 index 000000000..2f71afbb9 --- /dev/null +++ b/tests/core/test_plan_flag.py @@ -0,0 +1,360 @@ +"""Tests for --plan flag and default_plan_mode config in KimiCLI.create().""" + +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch +from uuid import uuid4 + +import pytest + +import kimi_cli.app as app_module +from kimi_cli.app import KimiCLI + +# --------------------------------------------------------------------------- +# Helpers — lightweight fakes for KimiCLI.create() dependencies +# --------------------------------------------------------------------------- + + +def _patch_create_deps(monkeypatch, *, session_plan_mode: bool = False): + """Patch heavy dependencies so KimiCLI.create() runs without I/O. + + Returns a FakeSoul class whose instances record plan-mode interactions. + """ + + class FakeSoul: + """Tracks plan-mode calls made by KimiCLI.create().""" + + instances: list[FakeSoul] = [] + + def __init__(self, agent, context): + self.plan_mode = session_plan_mode + self._set_plan_mode_calls: list[tuple[bool, str]] = [] + self._schedule_reminder_called = False + FakeSoul.instances.append(self) + + async def set_plan_mode_from_manual(self, enabled: bool) -> bool: + self._set_plan_mode_calls.append((enabled, "manual")) + self.plan_mode = enabled + return enabled + + def schedule_plan_activation_reminder(self) -> None: + self._schedule_reminder_called = True + + def set_hook_engine(self, engine): + pass + + # Reset class-level tracker + FakeSoul.instances = [] + + fake_context = SimpleNamespace(system_prompt=None) + fake_context.restore = AsyncMock() + fake_context.write_system_prompt = AsyncMock() + + runtime_create_calls: list[dict] = [] + + async def fake_runtime_create(config, _oauth, _llm, session, yolo, **kwargs): + runtime_create_calls.append({"yolo": yolo}) + return SimpleNamespace( + session=session, + config=config, + llm=None, + notifications=SimpleNamespace(recover=lambda: None), + background_tasks=SimpleNamespace(reconcile=lambda: None), + ) + + monkeypatch.setattr(app_module, "load_config", lambda conf: conf) + monkeypatch.setattr(app_module, "augment_provider_with_env_vars", lambda p, m: {}) + monkeypatch.setattr(app_module, "create_llm", lambda *a, **kw: None) + monkeypatch.setattr(app_module.Runtime, "create", fake_runtime_create) + monkeypatch.setattr( + app_module, + "load_agent", + AsyncMock(return_value=SimpleNamespace(name="test", system_prompt="sp")), + ) + monkeypatch.setattr(app_module, "Context", lambda _path: fake_context) + monkeypatch.setattr(app_module, "KimiSoul", FakeSoul) + + return FakeSoul, runtime_create_calls + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + + +class TestPlanFlagNewSession: + """New sessions (resumed=False).""" + + @pytest.mark.asyncio + async def test_plan_flag_activates_plan_mode(self, session, config, monkeypatch): + """--plan flag on a new session should call set_plan_mode_from_manual(True).""" + FakeSoul, _ = _patch_create_deps(monkeypatch) + + await KimiCLI.create(session, config=config, plan_mode=True, resumed=False) + + soul = FakeSoul.instances[0] + assert soul._set_plan_mode_calls == [(True, "manual")] + assert not soul._schedule_reminder_called + + @pytest.mark.asyncio + async def test_config_default_plan_mode_activates(self, session, config, monkeypatch): + """default_plan_mode=True in config should activate plan mode for new sessions.""" + config.default_plan_mode = True + FakeSoul, _ = _patch_create_deps(monkeypatch) + + await KimiCLI.create(session, config=config, plan_mode=False, resumed=False) + + soul = FakeSoul.instances[0] + assert soul._set_plan_mode_calls == [(True, "manual")] + + @pytest.mark.asyncio + async def test_no_plan_flag_no_config_stays_inactive(self, session, config, monkeypatch): + """Without --plan or config, plan mode stays inactive.""" + FakeSoul, _ = _patch_create_deps(monkeypatch) + + await KimiCLI.create(session, config=config, plan_mode=False, resumed=False) + + soul = FakeSoul.instances[0] + assert soul._set_plan_mode_calls == [] + assert not soul._schedule_reminder_called + + @pytest.mark.asyncio + async def test_plan_flag_overrides_config_false(self, session, config, monkeypatch): + """--plan flag should activate plan mode even when config is False.""" + config.default_plan_mode = False + FakeSoul, _ = _patch_create_deps(monkeypatch) + + await KimiCLI.create(session, config=config, plan_mode=True, resumed=False) + + soul = FakeSoul.instances[0] + assert soul._set_plan_mode_calls == [(True, "manual")] + + +class TestPlanFlagResumedSession: + """Resumed sessions (resumed=True).""" + + @pytest.mark.asyncio + async def test_config_not_applied_on_resumed_session(self, session, config, monkeypatch): + """config.default_plan_mode should NOT be applied when resuming a session.""" + config.default_plan_mode = True + FakeSoul, _ = _patch_create_deps(monkeypatch) + + await KimiCLI.create(session, config=config, plan_mode=False, resumed=True) + + soul = FakeSoul.instances[0] + # plan_mode param stays False because resumed=True skips config + assert soul._set_plan_mode_calls == [] + assert not soul._schedule_reminder_called + + @pytest.mark.asyncio + async def test_plan_flag_activates_on_resumed_session_without_plan( + self, session, config, monkeypatch + ): + """--plan on a resumed session that was NOT in plan mode should activate it.""" + FakeSoul, _ = _patch_create_deps(monkeypatch, session_plan_mode=False) + + await KimiCLI.create(session, config=config, plan_mode=True, resumed=True) + + soul = FakeSoul.instances[0] + assert soul._set_plan_mode_calls == [(True, "manual")] + assert not soul._schedule_reminder_called + + @pytest.mark.asyncio + async def test_plan_flag_on_already_plan_session_triggers_reminder( + self, session, config, monkeypatch + ): + """--plan on a resumed session already in plan mode should schedule a reminder.""" + FakeSoul, _ = _patch_create_deps(monkeypatch, session_plan_mode=True) + + await KimiCLI.create(session, config=config, plan_mode=True, resumed=True) + + soul = FakeSoul.instances[0] + # Should NOT call set_plan_mode since already active + assert soul._set_plan_mode_calls == [] + assert soul._schedule_reminder_called + + @pytest.mark.asyncio + async def test_resumed_session_preserves_existing_plan_mode(self, session, config, monkeypatch): + """Resumed session with plan_mode already active (no --plan flag) stays in plan mode.""" + FakeSoul, _ = _patch_create_deps(monkeypatch, session_plan_mode=True) + + await KimiCLI.create(session, config=config, plan_mode=False, resumed=True) + + soul = FakeSoul.instances[0] + assert soul._set_plan_mode_calls == [] + assert not soul._schedule_reminder_called + # plan_mode is still True from session state + assert soul.plan_mode is True + + +class TestPlanFlagPriority: + """CLI flag > config > default.""" + + @pytest.mark.asyncio + async def test_flag_true_beats_config_false(self, session, config, monkeypatch): + """--plan should win over default_plan_mode=False.""" + config.default_plan_mode = False + FakeSoul, _ = _patch_create_deps(monkeypatch) + + await KimiCLI.create(session, config=config, plan_mode=True, resumed=False) + + soul = FakeSoul.instances[0] + assert soul._set_plan_mode_calls == [(True, "manual")] + + @pytest.mark.asyncio + async def test_config_true_with_no_flag(self, session, config, monkeypatch): + """default_plan_mode=True should activate when no --plan flag is given.""" + config.default_plan_mode = True + FakeSoul, _ = _patch_create_deps(monkeypatch) + + await KimiCLI.create(session, config=config, plan_mode=False, resumed=False) + + soul = FakeSoul.instances[0] + assert soul._set_plan_mode_calls == [(True, "manual")] + + @pytest.mark.asyncio + async def test_default_is_false(self, session, config, monkeypatch): + """Default state: no flag, no config → plan mode inactive.""" + assert config.default_plan_mode is False + FakeSoul, _ = _patch_create_deps(monkeypatch) + + await KimiCLI.create(session, config=config, resumed=False) + + soul = FakeSoul.instances[0] + assert soul._set_plan_mode_calls == [] + + @pytest.mark.asyncio + async def test_plan_and_yolo_coexist(self, session, config, monkeypatch): + """--plan and --yolo can be used together without conflict.""" + FakeSoul, runtime_create_calls = _patch_create_deps(monkeypatch) + + await KimiCLI.create(session, config=config, plan_mode=True, yolo=True, resumed=False) + + soul = FakeSoul.instances[0] + assert soul._set_plan_mode_calls == [(True, "manual")] + assert runtime_create_calls[0]["yolo"] is True + + +class TestSchedulePlanActivationReminder: + """Unit tests for KimiSoul.schedule_plan_activation_reminder().""" + + def test_schedules_when_in_plan_mode(self, runtime, tmp_path, monkeypatch): + """Reminder is scheduled when plan mode is active.""" + from kosong.tooling.empty import EmptyToolset + + from kimi_cli.soul.agent import Agent + from kimi_cli.soul.context import Context + from kimi_cli.soul.kimisoul import KimiSoul + + # Redirect PLANS_DIR to tmp_path to avoid filesystem side effects + monkeypatch.setattr("kimi_cli.tools.plan.heroes.PLANS_DIR", tmp_path) + + agent = Agent( + name="Test", + system_prompt="Test", + toolset=EmptyToolset(), + runtime=runtime, + ) + soul = KimiSoul(agent, context=Context(file_backend=tmp_path / "ctx.jsonl")) + + # Enable plan mode first + soul._set_plan_mode(True, source="manual") + # Reset the pending flag set by _set_plan_mode + soul._pending_plan_activation_injection = False + + # Now test schedule_plan_activation_reminder + soul.schedule_plan_activation_reminder() + assert soul._pending_plan_activation_injection is True + + def test_noop_when_not_in_plan_mode(self, runtime, tmp_path): + """Reminder is NOT scheduled when plan mode is inactive.""" + from kosong.tooling.empty import EmptyToolset + + from kimi_cli.soul.agent import Agent + from kimi_cli.soul.context import Context + from kimi_cli.soul.kimisoul import KimiSoul + + agent = Agent( + name="Test", + system_prompt="Test", + toolset=EmptyToolset(), + runtime=runtime, + ) + soul = KimiSoul(agent, context=Context(file_backend=tmp_path / "ctx.jsonl")) + + assert soul.plan_mode is False + soul.schedule_plan_activation_reminder() + assert soul._pending_plan_activation_injection is False + + +class TestWebWorkerResumedDetection: + """Verify web worker derives `resumed` from session state on disk.""" + + @pytest.mark.asyncio + async def test_new_session_without_state_file_is_not_resumed(self, tmp_path): + """A brand-new web session (no state.json) should pass resumed=False.""" + from kimi_cli.web.runner.worker import run_worker + + session_dir = tmp_path / "session-dir" + session_dir.mkdir() + # No state.json → new session + + create_calls: list[dict] = [] + + class _StopWorker(Exception): + pass + + async def spy_create(session, **kwargs): + create_calls.append(kwargs) + raise _StopWorker # abort after capturing args + + fake_session = SimpleNamespace(dir=session_dir) + fake_joint = SimpleNamespace(kimi_cli_session=fake_session) + + with ( + patch("kimi_cli.web.runner.worker.load_session_by_id", return_value=fake_joint), + patch( + "kimi_cli.web.runner.worker.get_global_mcp_config_file", + return_value=tmp_path / "no-mcp.json", + ), + patch.object(KimiCLI, "create", side_effect=spy_create), + pytest.raises(_StopWorker), + ): + await run_worker(uuid4()) + + assert create_calls[0]["resumed"] is False + + @pytest.mark.asyncio + async def test_existing_session_with_state_file_is_resumed(self, tmp_path): + """A session with state.json on disk should pass resumed=True.""" + from kimi_cli.web.runner.worker import run_worker + + session_dir = tmp_path / "session-dir" + session_dir.mkdir() + (session_dir / "state.json").write_text("{}", encoding="utf-8") + + create_calls: list[dict] = [] + + class _StopWorker(Exception): + pass + + async def spy_create(session, **kwargs): + create_calls.append(kwargs) + raise _StopWorker + + fake_session = SimpleNamespace(dir=session_dir) + fake_joint = SimpleNamespace(kimi_cli_session=fake_session) + + with ( + patch("kimi_cli.web.runner.worker.load_session_by_id", return_value=fake_joint), + patch( + "kimi_cli.web.runner.worker.get_global_mcp_config_file", + return_value=tmp_path / "no-mcp.json", + ), + patch.object(KimiCLI, "create", side_effect=spy_create), + pytest.raises(_StopWorker), + ): + await run_worker(uuid4()) + + assert create_calls[0]["resumed"] is True