diff --git a/.gitignore b/.gitignore index f00b02169..fd80c51cb 100644 --- a/.gitignore +++ b/.gitignore @@ -44,4 +44,6 @@ node_modules/ static/ .memo/ .entire -.claude \ No newline at end of file +.claude +.local/ + diff --git a/CHANGELOG.md b/CHANGELOG.md index e48d1ab4b..eefaa49c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,7 @@ Only write entries that are worth mentioning to users. - Core: Add shared `file_filter` module — unifies file mention logic between shell and web UIs via `src/kimi_cli/utils/file_filter.py`, providing consistent path filtering, ignored directory exclusion, and git-aware file discovery - Shell: Prevent path traversal in file mention scope parameter — the `scope` parameter in file completer requests is now validated to prevent directory traversal attacks - Web: Restore unfiltered directory listing in file browser API — file browser endpoint no longer applies git-aware filtering, ensuring all files are visible in the web UI file picker +- Web: Add YOLO mode support to web interface — users can now toggle auto-approve mode directly from the web UI; adds `/api/sessions/{id}/yolo` endpoints and `yolo_mode` field to `StatusUpdate` (Wire 1.9) - Todo: Refactor SetTodoList to persist state and prevent tool call storms — todos are now persisted to session state (root agent) and independent state files (sub-agents); adds query mode (omit `todos` to read current state) and clear mode (pass `[]`); includes anti-storm guidance in tool description to prevent repeated calls without progress (fixes #1710) - ReadFile: Add total line count to every read response and support negative `line_offset` for tail mode — the tool now reports `Total lines in file: N.` in its message so the model can plan subsequent reads; negative `line_offset` (e.g. `-100`) reads the last N lines using a sliding window, useful for viewing recent log output without shell commands; the absolute value is capped at 1000 (MAX_LINES) - Shell: Fix black background on inline code and code blocks in Markdown rendering — `NEUTRAL_MARKDOWN_THEME` now overrides all Rich default `markdown.*` styles to `"none"`, preventing Rich's built-in `"cyan on black"` from leaking through on non-black terminals diff --git a/docs/en/reference/kimi-web.md b/docs/en/reference/kimi-web.md index 7049b4e1c..f954ab575 100644 --- a/docs/en/reference/kimi-web.md +++ b/docs/en/reference/kimi-web.md @@ -188,9 +188,10 @@ Web UI provides a unified prompt toolbar above the input box, displaying various - **File changes**: Detects Git repository status, showing the number of new, modified, and deleted files (including untracked files). Click to view a detailed list of changes - **Todo list**: When the `SetTodoList` tool is active, shows task progress with support for expanding to view the detailed list - **Plan mode**: Toggle plan mode on/off from the input toolbar. When plan mode is active, the composer displays a dashed blue border. Plan mode can also be set programmatically via the `set_plan_mode` Wire protocol method +- **YOLO mode**: Toggle YOLO (auto-approve) mode on/off from the input toolbar. When YOLO mode is active, the composer displays a dashed yellow border and all operations are automatically approved without confirmation ::: info Changed -Git diff status bar added in version 1.5. Activity status indicator added in version 1.9. Version 1.10 unified it into the prompt toolbar. Version 1.11 moved the context usage indicator to the prompt toolbar. Plan mode toggle added in version 1.20. +Git diff status bar added in version 1.5. Activity status indicator added in version 1.9. Version 1.10 unified it into the prompt toolbar. Version 1.11 moved the context usage indicator to the prompt toolbar. Plan mode toggle added in version 1.20. YOLO mode toggle added in version 1.30. ::: ### Open-in functionality diff --git a/docs/en/release-notes/changelog.md b/docs/en/release-notes/changelog.md index f6e4d6eb8..5fb188b17 100644 --- a/docs/en/release-notes/changelog.md +++ b/docs/en/release-notes/changelog.md @@ -32,6 +32,7 @@ This page documents the changes in each Kimi Code CLI release. - Core: Add shared `file_filter` module — unifies file mention logic between shell and web UIs via `src/kimi_cli/utils/file_filter.py`, providing consistent path filtering, ignored directory exclusion, and git-aware file discovery - Shell: Prevent path traversal in file mention scope parameter — the `scope` parameter in file completer requests is now validated to prevent directory traversal attacks - Web: Restore unfiltered directory listing in file browser API — file browser endpoint no longer applies git-aware filtering, ensuring all files are visible in the web UI file picker +- Web: Add YOLO mode support to web interface — users can now toggle auto-approve mode directly from the web UI; adds `/api/sessions/{id}/yolo` endpoints and `yolo_mode` field to `StatusUpdate` (Wire 1.9) - Todo: Refactor SetTodoList to persist state and prevent tool call storms — todos are now persisted to session state (root agent) and independent state files (sub-agents); adds query mode (omit `todos` to read current state) and clear mode (pass `[]`); includes anti-storm guidance in tool description to prevent repeated calls without progress (fixes #1710) - ReadFile: Add total line count to every read response and support negative `line_offset` for tail mode — the tool now reports `Total lines in file: N.` in its message so the model can plan subsequent reads; negative `line_offset` (e.g. `-100`) reads the last N lines using a sliding window, useful for viewing recent log output without shell commands; the absolute value is capped at 1000 (MAX_LINES) - Shell: Fix black background on inline code and code blocks in Markdown rendering — `NEUTRAL_MARKDOWN_THEME` now overrides all Rich default `markdown.*` styles to `"none"`, preventing Rich's built-in `"cyan on black"` from leaking through on non-black terminals diff --git a/docs/zh/reference/kimi-web.md b/docs/zh/reference/kimi-web.md index 0ce22037f..8dea1b4f5 100644 --- a/docs/zh/reference/kimi-web.md +++ b/docs/zh/reference/kimi-web.md @@ -188,9 +188,10 @@ Web UI 在输入框上方提供统一的提示工具栏,以可折叠标签页 - **文件变更**:检测 Git 仓库状态,显示新增、修改和删除的文件数量(包含未跟踪文件),点击可查看详细的变更列表 - **待办事项**:当 `SetTodoList` 工具处于活动状态时,显示任务进度,支持展开查看详细列表 - **Plan 模式**:在输入工具栏中切换 Plan 模式开关。Plan 模式激活时,输入框显示蓝色虚线边框。也可以通过 `set_plan_mode` Wire 协议方法程序化设置 +- **YOLO 模式**:在输入工具栏中切换 YOLO(自动审批)模式开关。YOLO 模式激活时,输入框显示黄色虚线边框,所有操作将自动批准无需确认 ::: info 变更 -Git diff 状态栏新增于 1.5 版本。1.9 版本添加了活动状态指示器。1.10 版本将其统一为提示工具栏。1.11 版本将上下文用量指示器移至提示工具栏。1.20 版本新增 Plan 模式切换。 +Git diff 状态栏新增于 1.5 版本。1.9 版本添加了活动状态指示器。1.10 版本将其统一为提示工具栏。1.11 版本将上下文用量指示器移至提示工具栏。1.20 版本新增 Plan 模式切换。1.30 版本新增 YOLO 模式切换。 ::: ### Open-in 功能 diff --git a/docs/zh/release-notes/changelog.md b/docs/zh/release-notes/changelog.md index 99a594a7c..33e0ddb41 100644 --- a/docs/zh/release-notes/changelog.md +++ b/docs/zh/release-notes/changelog.md @@ -32,6 +32,7 @@ - Core:新增共享的 `file_filter` 模块——通过 `src/kimi_cli/utils/file_filter.py` 统一 Shell 和 Web 的文件引用逻辑,提供一致的路径过滤、忽略目录排除和 Git 感知文件发现 - Shell:防止文件引用 scope 参数的路径遍历——文件补全器请求中的 `scope` 参数现在会经过验证,防止目录遍历攻击 - Web:恢复文件浏览器 API 中的未过滤目录列表——文件浏览器端点不再应用 Git 感知过滤,确保 Web UI 文件选择器中显示所有文件 +- Web:Web 界面新增 YOLO 模式支持——用户可直接在 Web UI 中开关自动审批模式;新增 `/api/sessions/{id}/yolo` 接口,并在 `StatusUpdate` 中增加 `yolo_mode` 字段(Wire 1.9) - Todo:重构 `SetTodoList` 工具,支持状态持久化并防止工具调用风暴——待办事项现在会持久化到会话状态(主 Agent)和独立状态文件(子 Agent);新增查询模式(省略 `todos` 参数可读取当前状态)和清空模式(传 `[]` 清空);工具描述中增加了防风暴指导,防止在没有实际进展的情况下反复调用(修复 #1710) - ReadFile:每次读取返回文件总行数,并支持负数 `line_offset` 实现 tail 模式——工具现在会在消息中报告 `Total lines in file: N.`,方便模型规划后续读取;负数 `line_offset`(如 `-100`)通过滑动窗口读取文件末尾 N 行,适用于无需 Shell 命令即可查看最新日志输出的场景;绝对值上限为 1000(MAX_LINES) - Shell:修复 Markdown 渲染中行内代码和代码块出现黑色背景的问题——`NEUTRAL_MARKDOWN_THEME` 现在将所有 Rich 默认的 `markdown.*` 样式覆盖为 `"none"`,防止 Rich 内置的 `"cyan on black"` 在非黑色背景终端上泄露 diff --git a/src/kimi_cli/web/api/sessions.py b/src/kimi_cli/web/api/sessions.py index 11573715d..9b295b2eb 100644 --- a/src/kimi_cli/web/api/sessions.py +++ b/src/kimi_cli/web/api/sessions.py @@ -21,8 +21,10 @@ from starlette.websockets import WebSocket, WebSocketDisconnect from kimi_cli import logger +from kimi_cli.config import load_config from kimi_cli.metadata import load_metadata, save_metadata from kimi_cli.session import Session as KimiCLISession +from kimi_cli.session_state import load_session_state, save_session_state from kimi_cli.utils.subprocess_env import get_clean_env from kimi_cli.web.auth import is_origin_allowed, is_private_ip, verify_token from kimi_cli.web.models import ( @@ -33,6 +35,8 @@ Session, SessionStatus, UpdateSessionRequest, + UpdateYoloRequest, + YoloStatus, ) from kimi_cli.web.runner.messages import new_session_status_message, send_history_complete from kimi_cli.web.runner.process import KimiCLIRunner @@ -51,6 +55,7 @@ JSONRPCPromptMessage, ) from kimi_cli.wire.serde import deserialize_wire_message +from kimi_cli.wire.file import WireFile from kimi_cli.wire.types import is_request router = APIRouter(prefix="/api/sessions", tags=["sessions"]) @@ -332,6 +337,14 @@ async def create_session(request: CreateSessionRequest | None = None) -> Session else: work_dir = KaosPath.unsafe_from_local_path(Path.home()) kimi_cli_session = await KimiCLISession.create(work_dir=work_dir) + + # Apply default_yolo config setting to new session + config = load_config() + if config.default_yolo: + state = load_session_state(kimi_cli_session.dir) + state.approval.yolo = True + save_session_state(state, kimi_cli_session.dir) + context_file = kimi_cli_session.dir / "context.jsonl" invalidate_sessions_cache() invalidate_work_dirs_cache() @@ -869,6 +882,59 @@ async def generate_session_title( return GenerateTitleResponse(title=title) +@router.get("/{session_id}/yolo", summary="Get YOLO mode status") +async def get_yolo_status(session_id: UUID) -> YoloStatus: + """Get the YOLO (auto-approve) mode status for a session.""" + session = load_session_by_id(session_id) + if session is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Session not found") + + session_dir = session.kimi_cli_session.dir + state = load_session_state(session_dir) + + return YoloStatus( + enabled=state.approval.yolo, + auto_approve_actions=list(state.approval.auto_approve_actions), + ) + + +@router.post("/{session_id}/yolo", summary="Update YOLO mode status") +async def update_yolo_status( + session_id: UUID, + request: UpdateYoloRequest, + runner: KimiCLIRunner = Depends(get_runner), +) -> YoloStatus: + """Enable or disable YOLO (auto-approve) mode for a session.""" + session = load_session_by_id(session_id) + if session is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Session not found") + + session_dir = session.kimi_cli_session.dir + state = load_session_state(session_dir) + + # Update state + state.approval.yolo = request.enabled + + # If session is running, notify the worker to update runtime state. + # The worker will persist state via its notify_change callback, so we skip + # saving here to avoid race conditions with concurrent worker changes. + session_process = runner.get_session(session_id) + if session_process is not None and session_process.is_alive: + await session_process.set_yolo_mode(request.enabled) + else: + # Only save directly to disk when the worker is not alive + save_session_state(state, session_dir) + # Also persist a status update to wire.jsonl so replay shows correct YOLO state + wire_file = WireFile(session_dir / "wire.jsonl") + from kimi_cli.wire.types import StatusUpdate + await wire_file.append_message(StatusUpdate(yolo_mode=request.enabled)) + + return YoloStatus( + enabled=state.approval.yolo, + auto_approve_actions=list(state.approval.auto_approve_actions), + ) + + @router.websocket("/{session_id}/stream") async def session_stream( session_id: UUID, diff --git a/src/kimi_cli/web/models.py b/src/kimi_cli/web/models.py index c33c4938d..4b483d0f5 100644 --- a/src/kimi_cli/web/models.py +++ b/src/kimi_cli/web/models.py @@ -96,3 +96,18 @@ class GenerateTitleResponse(BaseModel): """Generate title response.""" title: str + + +class YoloStatus(BaseModel): + """YOLO (auto-approve) mode status.""" + + enabled: bool = Field(..., description="Whether YOLO mode is enabled") + auto_approve_actions: list[str] = Field( + default_factory=list, description="List of auto-approved action types" + ) + + +class UpdateYoloRequest(BaseModel): + """Update YOLO mode request.""" + + enabled: bool = Field(..., description="Enable or disable YOLO mode") diff --git a/src/kimi_cli/web/runner/process.py b/src/kimi_cli/web/runner/process.py index 91bfe7eb0..fdbeaaa17 100644 --- a/src/kimi_cli/web/runner/process.py +++ b/src/kimi_cli/web/runner/process.py @@ -614,6 +614,25 @@ async def _close_all_websockets(self) -> None: # Ignore errors closing already-disconnected WebSockets pass + async def set_yolo_mode(self, enabled: bool) -> None: + """Set YOLO mode for the running session. + + Sends a set_yolo_mode message to the worker process. + """ + if not self.is_alive: + return + + message = json.dumps( + { + "jsonrpc": "2.0", + "method": "set_yolo_mode", + "id": str(uuid4()), + "params": {"enabled": enabled}, + }, + ensure_ascii=False, + ) + await self.send_message(message) + async def remove_websocket(self, ws: WebSocket) -> None: """Remove a WebSocket connection from this session.""" async with self._ws_lock: diff --git a/src/kimi_cli/wire/jsonrpc.py b/src/kimi_cli/wire/jsonrpc.py index bf802055f..a9f1e9124 100644 --- a/src/kimi_cli/wire/jsonrpc.py +++ b/src/kimi_cli/wire/jsonrpc.py @@ -161,6 +161,18 @@ class JSONRPCSetPlanModeMessage(_MessageBase): params: _SetPlanModeParams +class _SetYoloModeParams(BaseModel): + enabled: bool + + model_config = ConfigDict(extra="ignore") + + +class JSONRPCSetYoloModeMessage(_MessageBase): + method: Literal["set_yolo_mode"] = "set_yolo_mode" + id: str + params: _SetYoloModeParams + + class JSONRPCCancelMessage(_MessageBase): method: Literal["cancel"] = "cancel" id: str @@ -212,10 +224,19 @@ def _validate_params(cls, value: Any) -> Request: | JSONRPCSteerMessage | JSONRPCReplayMessage | JSONRPCSetPlanModeMessage + | JSONRPCSetYoloModeMessage | JSONRPCCancelMessage ) JSONRPCInMessageAdapter = TypeAdapter[JSONRPCInMessage](JSONRPCInMessage) -JSONRPC_IN_METHODS = {"initialize", "prompt", "steer", "replay", "set_plan_mode", "cancel"} +JSONRPC_IN_METHODS = { + "initialize", + "prompt", + "steer", + "replay", + "set_plan_mode", + "set_yolo_mode", + "cancel", +} type JSONRPCOutMessage = ( JSONRPCSuccessResponse diff --git a/src/kimi_cli/wire/server.py b/src/kimi_cli/wire/server.py index 9d62f6136..2e9eefac9 100644 --- a/src/kimi_cli/wire/server.py +++ b/src/kimi_cli/wire/server.py @@ -52,6 +52,7 @@ JSONRPCReplayMessage, JSONRPCRequestMessage, JSONRPCSetPlanModeMessage, + JSONRPCSetYoloModeMessage, JSONRPCSteerMessage, JSONRPCSuccessResponse, Statuses, @@ -365,6 +366,8 @@ async def _dispatch_msg(self, msg: JSONRPCInMessage) -> None: resp = await self._handle_steer(msg) case JSONRPCSetPlanModeMessage(): resp = await self._handle_set_plan_mode(msg) + case JSONRPCSetYoloModeMessage(): + resp = await self._handle_set_yolo_mode(msg) case JSONRPCCancelMessage(): resp = await self._handle_cancel(msg) case JSONRPCSuccessResponse() | JSONRPCErrorResponse(): @@ -763,6 +766,31 @@ async def _handle_set_plan_mode( result={"status": "ok", "plan_mode": new_state}, ) + async def _handle_set_yolo_mode( + self, msg: JSONRPCSetYoloModeMessage + ) -> JSONRPCSuccessResponse | JSONRPCErrorResponse: + if not isinstance(self._soul, KimiSoul): + return JSONRPCErrorResponse( + id=msg.id, + error=JSONRPCErrorObject( + code=ErrorCodes.INVALID_STATE, + message="YOLO mode is not supported", + ), + ) + + # Update the approval state + self._soul.runtime.approval.set_yolo(msg.params.enabled) + new_state = self._soul.runtime.approval.is_yolo() + + status = StatusUpdate(yolo_mode=new_state) + await self._send_msg(JSONRPCEventMessage(params=status)) + # Persist to wire file so replay reconstructs yolo mode state + await self._soul.wire_file.append_message(status) + return JSONRPCSuccessResponse( + id=msg.id, + result={"status": "ok", "yolo_mode": new_state}, + ) + async def _handle_replay( self, msg: JSONRPCReplayMessage ) -> JSONRPCSuccessResponse | JSONRPCErrorResponse: diff --git a/src/kimi_cli/wire/types.py b/src/kimi_cli/wire/types.py index 500104b11..d2804e06e 100644 --- a/src/kimi_cli/wire/types.py +++ b/src/kimi_cli/wire/types.py @@ -174,6 +174,8 @@ class StatusUpdate(BaseModel): """The message ID of the current step.""" plan_mode: bool | None = None """Whether plan mode (read-only) is active. None means no change.""" + yolo_mode: bool | None = None + """Whether YOLO (auto-approve) mode is active. None means no change.""" mcp_status: MCPStatusSnapshot | None = None """The current MCP startup snapshot. None means no change.""" diff --git a/tests/core/test_wire_message.py b/tests/core/test_wire_message.py index b7b7674ac..4789cfc1c 100644 --- a/tests/core/test_wire_message.py +++ b/tests/core/test_wire_message.py @@ -150,6 +150,7 @@ async def test_wire_message_serde(): "token_usage": None, "message_id": None, "plan_mode": None, + "yolo_mode": None, "mcp_status": { "loading": True, "connected": 0, @@ -168,6 +169,43 @@ async def test_wire_message_serde(): ) _test_serde(msg) + # StatusUpdate with yolo_mode explicitly set + msg = StatusUpdate(yolo_mode=True) + assert serialize_wire_message(msg) == snapshot( + { + "type": "StatusUpdate", + "payload": { + "context_usage": None, + "context_tokens": None, + "max_context_tokens": None, + "token_usage": None, + "message_id": None, + "plan_mode": None, + "yolo_mode": True, + "mcp_status": None, + }, + } + ) + _test_serde(msg) + + msg = StatusUpdate(yolo_mode=False) + assert serialize_wire_message(msg) == snapshot( + { + "type": "StatusUpdate", + "payload": { + "context_usage": None, + "context_tokens": None, + "max_context_tokens": None, + "token_usage": None, + "message_id": None, + "plan_mode": None, + "yolo_mode": False, + "mcp_status": None, + }, + } + ) + _test_serde(msg) + msg = Notification( id="n1234567", category="task", diff --git a/tests/web/test_sessions_yolo.py b/tests/web/test_sessions_yolo.py new file mode 100644 index 000000000..1e3a98550 --- /dev/null +++ b/tests/web/test_sessions_yolo.py @@ -0,0 +1,383 @@ +"""Tests for sessions YOLO mode API endpoints.""" + +from __future__ import annotations + +import json +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock +from uuid import UUID, uuid4 + +import pytest +from fastapi.testclient import TestClient + +from kimi_cli.session_state import ApprovalStateData, SessionState, save_session_state +from kimi_cli.web.app import create_app +from kimi_cli.web.runner.process import KimiCLIRunner, SessionProcess +from kimi_cli.wire.file import WireFile +from kimi_cli.wire.types import StatusUpdate + + +def create_mock_runner() -> KimiCLIRunner: + """Create a mock KimiCLIRunner with no running sessions.""" + mock_runner = MagicMock(spec=KimiCLIRunner) + mock_runner.get_session.return_value = None + return mock_runner + + +def create_mock_runner_with_running_session() -> KimiCLIRunner: + """Create a mock KimiCLIRunner with a running session.""" + mock_runner = MagicMock(spec=KimiCLIRunner) + mock_process = MagicMock(spec=SessionProcess) + mock_process.is_alive = True + mock_process.set_yolo_mode = AsyncMock() + mock_runner.get_session.return_value = mock_process + return mock_runner + + +def create_test_session(share_dir: Path) -> tuple[UUID, Path]: + """Create a test session with a temporary directory.""" + from kimi_cli.metadata import Metadata, WorkDirMeta, save_metadata + + session_id = uuid4() + work_dir = share_dir / "work" + work_dir.mkdir() + + metadata = Metadata(work_dirs=[WorkDirMeta(path=str(work_dir))]) + save_metadata(metadata) + + session_dir = metadata.work_dirs[0].sessions_dir / str(session_id) + session_dir.mkdir(parents=True) + + # Create a minimal context.jsonl file + (session_dir / "context.jsonl").write_text("{}", encoding="utf-8") + + return session_id, session_dir + + +@pytest.fixture +def client(tmp_path: Path, monkeypatch) -> TestClient: + """Create a test client with isolated metadata and mock runner.""" + monkeypatch.setenv("KIMI_SHARE_DIR", str(tmp_path)) + app = create_app() + app.state.runner = create_mock_runner() + return TestClient(app) + + +class TestGetYoloStatus: + """Tests for GET /api/sessions/{session_id}/yolo endpoint.""" + + def test_get_yolo_status_session_not_found(self, client: TestClient) -> None: + """Test 404 response when session does not exist.""" + response = client.get(f"/api/sessions/{uuid4()}/yolo") + + assert response.status_code == 404 + assert response.json()["detail"] == "Session not found" + + def test_get_yolo_status_default_false(self, client: TestClient, tmp_path: Path) -> None: + """Test that default YOLO status is false.""" + session_id, _ = create_test_session(tmp_path) + + response = client.get(f"/api/sessions/{session_id}/yolo") + + assert response.status_code == 200 + data = response.json() + assert data["enabled"] is False + assert data["auto_approve_actions"] == [] + + def test_get_yolo_status_enabled(self, client: TestClient, tmp_path: Path) -> None: + """Test getting YOLO status when enabled.""" + session_id, session_dir = create_test_session(tmp_path) + + # Set YOLO mode to enabled in session state + state = SessionState( + approval=ApprovalStateData( + yolo=True, + auto_approve_actions={"Shell", "WriteFile"}, + ), + ) + save_session_state(state, session_dir) + + response = client.get(f"/api/sessions/{session_id}/yolo") + + assert response.status_code == 200 + data = response.json() + assert data["enabled"] is True + assert set(data["auto_approve_actions"]) == {"Shell", "WriteFile"} + + +class TestUpdateYoloStatus: + """Tests for POST /api/sessions/{session_id}/yolo endpoint.""" + + def test_update_yolo_status_session_not_found(self, client: TestClient) -> None: + """Test 404 response when session does not exist.""" + response = client.post( + f"/api/sessions/{uuid4()}/yolo", + json={"enabled": True}, + ) + + assert response.status_code == 404 + assert response.json()["detail"] == "Session not found" + + def test_update_yolo_status_enable_stopped_session( + self, client: TestClient, tmp_path: Path + ) -> None: + """Test enabling YOLO mode for a stopped session.""" + session_id, session_dir = create_test_session(tmp_path) + + response = client.post( + f"/api/sessions/{session_id}/yolo", + json={"enabled": True}, + ) + + assert response.status_code == 200 + data = response.json() + assert data["enabled"] is True + + # Verify session state was saved + state_file = session_dir / "state.json" + assert state_file.exists() + state_data = json.loads(state_file.read_text(encoding="utf-8")) + assert state_data["approval"]["yolo"] is True + + def test_update_yolo_status_disable_stopped_session( + self, client: TestClient, tmp_path: Path + ) -> None: + """Test disabling YOLO mode for a stopped session.""" + session_id, session_dir = create_test_session(tmp_path) + + # First enable YOLO + client.post(f"/api/sessions/{session_id}/yolo", json={"enabled": True}) + + # Then disable it + response = client.post( + f"/api/sessions/{session_id}/yolo", + json={"enabled": False}, + ) + + assert response.status_code == 200 + data = response.json() + assert data["enabled"] is False + + # Verify session state was saved + state_file = session_dir / "state.json" + state_data = json.loads(state_file.read_text(encoding="utf-8")) + assert state_data["approval"]["yolo"] is False + + def test_update_yolo_status_preserves_auto_approve_actions( + self, client: TestClient, tmp_path: Path + ) -> None: + """Test that updating YOLO mode preserves auto_approve_actions.""" + session_id, session_dir = create_test_session(tmp_path) + + # Set initial state with auto_approve_actions + state = SessionState( + approval=ApprovalStateData( + yolo=False, + auto_approve_actions={"Shell", "WriteFile"}, + ), + ) + save_session_state(state, session_dir) + + response = client.post( + f"/api/sessions/{session_id}/yolo", + json={"enabled": True}, + ) + + assert response.status_code == 200 + data = response.json() + assert data["enabled"] is True + assert set(data["auto_approve_actions"]) == {"Shell", "WriteFile"} + + @pytest.mark.anyio + async def test_update_yolo_status_persists_to_wire_jsonl( + self, tmp_path: Path, monkeypatch + ) -> None: + """Test that updating YOLO mode for stopped session persists to wire.jsonl.""" + monkeypatch.setenv("KIMI_SHARE_DIR", str(tmp_path)) + session_id, session_dir = create_test_session(tmp_path) + + app = create_app() + app.state.runner = create_mock_runner() + + with TestClient(app) as client: + response = client.post( + f"/api/sessions/{session_id}/yolo", + json={"enabled": True}, + ) + + assert response.status_code == 200 + + # Verify wire.jsonl was created with StatusUpdate + wire_file = WireFile(session_dir / "wire.jsonl") + assert wire_file.path.exists() + + # Read and verify the wire file contents + records = [] + async for record in wire_file.iter_records(): + records.append(record) + + assert len(records) == 1 + msg = records[0].to_wire_message() + assert isinstance(msg, StatusUpdate) + assert msg.yolo_mode is True + + @pytest.mark.anyio + async def test_update_yolo_status_disable_persists_to_wire_jsonl( + self, tmp_path: Path, monkeypatch + ) -> None: + """Test that disabling YOLO mode for stopped session persists to wire.jsonl.""" + monkeypatch.setenv("KIMI_SHARE_DIR", str(tmp_path)) + session_id, session_dir = create_test_session(tmp_path) + + app = create_app() + app.state.runner = create_mock_runner() + + with TestClient(app) as client: + # First enable, then disable + client.post(f"/api/sessions/{session_id}/yolo", json={"enabled": True}) + response = client.post( + f"/api/sessions/{session_id}/yolo", + json={"enabled": False}, + ) + + assert response.status_code == 200 + + # Verify wire.jsonl contains both status updates + wire_file = WireFile(session_dir / "wire.jsonl") + records = [] + async for record in wire_file.iter_records(): + records.append(record) + + assert len(records) == 2 + # Check the second update (disable) + msg = records[1].to_wire_message() + assert isinstance(msg, StatusUpdate) + assert msg.yolo_mode is False + + +class TestUpdateYoloStatusRunningSession: + """Tests for updating YOLO status on running sessions.""" + + @pytest.mark.anyio + async def test_update_yolo_status_running_session_calls_set_yolo_mode( + self, + tmp_path: Path, + monkeypatch, + ) -> None: + """Test that updating YOLO on running session notifies the worker.""" + from kimi_cli.web.api.sessions import update_yolo_status + from kimi_cli.web.models import UpdateYoloRequest + + session_id, _ = create_test_session(tmp_path) + + # Create mock runner with running session + mock_runner = create_mock_runner_with_running_session() + + result = await update_yolo_status( + session_id=session_id, + request=UpdateYoloRequest(enabled=True), + runner=mock_runner, + ) + + assert result.enabled is True + + # Verify set_yolo_mode was called on the running session + mock_process = mock_runner.get_session.return_value + mock_process.set_yolo_mode.assert_awaited_once_with(True) + + @pytest.mark.anyio + async def test_update_yolo_status_running_session_does_not_save_to_disk( + self, + tmp_path: Path, + monkeypatch, + ) -> None: + """Test that updating YOLO on running session does not save to disk directly.""" + from kimi_cli.web.api.sessions import update_yolo_status + from kimi_cli.web.models import UpdateYoloRequest + + session_id, session_dir = create_test_session(tmp_path) + + # Create mock runner with running session + mock_runner = create_mock_runner_with_running_session() + + await update_yolo_status( + session_id=session_id, + request=UpdateYoloRequest(enabled=True), + runner=mock_runner, + ) + + # Verify state.json was NOT created (worker handles persistence) + state_file = session_dir / "state.json" + assert not state_file.exists() + + # Verify wire.jsonl was NOT created + wire_file = session_dir / "wire.jsonl" + assert not wire_file.exists() + + +class TestYoloStatusRoundTrip: + """Tests for round-trip YOLO status operations.""" + + def test_yolo_status_round_trip( + self, client: TestClient, tmp_path: Path + ) -> None: + """Test that YOLO status can be set and retrieved correctly.""" + session_id, _ = create_test_session(tmp_path) + + # Initially false + response = client.get(f"/api/sessions/{session_id}/yolo") + assert response.json()["enabled"] is False + + # Enable YOLO + response = client.post(f"/api/sessions/{session_id}/yolo", json={"enabled": True}) + assert response.json()["enabled"] is True + + # Verify it's now enabled + response = client.get(f"/api/sessions/{session_id}/yolo") + assert response.json()["enabled"] is True + + # Disable YOLO + response = client.post(f"/api/sessions/{session_id}/yolo", json={"enabled": False}) + assert response.json()["enabled"] is False + + # Verify it's now disabled + response = client.get(f"/api/sessions/{session_id}/yolo") + assert response.json()["enabled"] is False + + +class TestYoloStatusWireReplay: + """Tests for YOLO status replay from wire.jsonl.""" + + @pytest.mark.anyio + async def test_wire_replay_includes_yolo_updates( + self, tmp_path: Path, monkeypatch + ) -> None: + """Test that wire.jsonl contains YOLO updates for replay.""" + monkeypatch.setenv("KIMI_SHARE_DIR", str(tmp_path)) + session_id, session_dir = create_test_session(tmp_path) + + app = create_app() + app.state.runner = create_mock_runner() + + with TestClient(app) as client: + # Enable YOLO + client.post(f"/api/sessions/{session_id}/yolo", json={"enabled": True}) + # Disable YOLO + client.post(f"/api/sessions/{session_id}/yolo", json={"enabled": False}) + # Enable again + client.post(f"/api/sessions/{session_id}/yolo", json={"enabled": True}) + + # Verify wire.jsonl contains all three status updates + wire_file = WireFile(session_dir / "wire.jsonl") + records = [] + async for record in wire_file.iter_records(): + records.append(record) + + assert len(records) == 3 + + # Check sequence: True, False, True + msgs = [r.to_wire_message() for r in records] + assert all(isinstance(m, StatusUpdate) for m in msgs) + assert msgs[0].yolo_mode is True + assert msgs[1].yolo_mode is False + assert msgs[2].yolo_mode is True diff --git a/tests_e2e/test_wire_approvals_tools.py b/tests_e2e/test_wire_approvals_tools.py index 19a41d3c6..bc66f2e81 100644 --- a/tests_e2e/test_wire_approvals_tools.py +++ b/tests_e2e/test_wire_approvals_tools.py @@ -119,6 +119,7 @@ def test_shell_approval_approve(tmp_path) -> None: "token_usage": None, "message_id": None, "plan_mode": False, + "yolo_mode": None, "mcp_status": None, }, }, @@ -174,6 +175,7 @@ def test_shell_approval_approve(tmp_path) -> None: "token_usage": None, "message_id": None, "plan_mode": False, + "yolo_mode": None, "mcp_status": None, }, }, @@ -250,6 +252,7 @@ def test_shell_approval_reject(tmp_path) -> None: "token_usage": None, "message_id": None, "plan_mode": False, + "yolo_mode": None, "mcp_status": None, }, }, @@ -381,6 +384,7 @@ def test_approve_for_session(tmp_path) -> None: "token_usage": None, "message_id": None, "plan_mode": False, + "yolo_mode": None, "mcp_status": None, }, }, @@ -440,6 +444,7 @@ def test_approve_for_session(tmp_path) -> None: "token_usage": None, "message_id": None, "plan_mode": False, + "yolo_mode": None, "mcp_status": None, }, }, @@ -475,6 +480,7 @@ def test_approve_for_session(tmp_path) -> None: "token_usage": None, "message_id": None, "plan_mode": False, + "yolo_mode": None, "mcp_status": None, }, }, @@ -508,6 +514,7 @@ def test_approve_for_session(tmp_path) -> None: "token_usage": None, "message_id": None, "plan_mode": False, + "yolo_mode": None, "mcp_status": None, }, }, @@ -581,6 +588,7 @@ def test_yolo_skips_approval(tmp_path) -> None: "token_usage": None, "message_id": None, "plan_mode": False, + "yolo_mode": None, "mcp_status": None, }, }, @@ -614,6 +622,7 @@ def test_yolo_skips_approval(tmp_path) -> None: "token_usage": None, "message_id": None, "plan_mode": False, + "yolo_mode": None, "mcp_status": None, }, }, @@ -884,6 +893,7 @@ def test_display_block_todo(tmp_path) -> None: "token_usage": None, "message_id": None, "plan_mode": False, + "yolo_mode": None, "mcp_status": None, }, }, @@ -919,6 +929,7 @@ def test_display_block_todo(tmp_path) -> None: "token_usage": None, "message_id": None, "plan_mode": False, + "yolo_mode": None, "mcp_status": None, }, }, @@ -1002,6 +1013,7 @@ def test_tool_call_part_streaming(tmp_path) -> None: "token_usage": None, "message_id": None, "plan_mode": False, + "yolo_mode": None, "mcp_status": None, }, }, @@ -1037,6 +1049,7 @@ def test_tool_call_part_streaming(tmp_path) -> None: "token_usage": None, "message_id": None, "plan_mode": False, + "yolo_mode": None, "mcp_status": None, }, }, @@ -1110,6 +1123,7 @@ def test_default_agent_missing_tool(tmp_path) -> None: "token_usage": None, "message_id": None, "plan_mode": False, + "yolo_mode": None, "mcp_status": None, }, }, @@ -1143,6 +1157,7 @@ def test_default_agent_missing_tool(tmp_path) -> None: "token_usage": None, "message_id": None, "plan_mode": False, + "yolo_mode": None, "mcp_status": None, }, }, @@ -1230,6 +1245,7 @@ def test_custom_agent_exclude_tool(tmp_path) -> None: "token_usage": None, "message_id": None, "plan_mode": False, + "yolo_mode": None, "mcp_status": None, }, }, @@ -1263,6 +1279,7 @@ def test_custom_agent_exclude_tool(tmp_path) -> None: "token_usage": None, "message_id": None, "plan_mode": False, + "yolo_mode": None, "mcp_status": None, }, }, diff --git a/tests_e2e/test_wire_config.py b/tests_e2e/test_wire_config.py index ef494a03b..44134fc00 100644 --- a/tests_e2e/test_wire_config.py +++ b/tests_e2e/test_wire_config.py @@ -80,6 +80,7 @@ def test_config_string(tmp_path) -> None: "token_usage": None, "message_id": None, "plan_mode": False, + "yolo_mode": None, "mcp_status": None, }, }, @@ -170,6 +171,7 @@ def test_model_override(tmp_path) -> None: "token_usage": None, "message_id": None, "plan_mode": False, + "yolo_mode": None, "mcp_status": None, }, }, diff --git a/tests_e2e/test_wire_prompt.py b/tests_e2e/test_wire_prompt.py index d6058717a..aa79f9499 100644 --- a/tests_e2e/test_wire_prompt.py +++ b/tests_e2e/test_wire_prompt.py @@ -90,6 +90,7 @@ def test_basic_prompt_events(tmp_path) -> None: }, "message_id": "scripted-1", "plan_mode": False, + "yolo_mode": None, "mcp_status": None, }, }, @@ -294,6 +295,7 @@ def test_max_steps_reached(tmp_path) -> None: "token_usage": None, "message_id": None, "plan_mode": False, + "yolo_mode": None, "mcp_status": None, }, }, @@ -376,6 +378,7 @@ def test_status_update_fields(tmp_path) -> None: }, "message_id": "scripted-1", "plan_mode": False, + "yolo_mode": None, "mcp_status": None, }, } @@ -474,6 +477,7 @@ def test_concurrent_prompt_error(tmp_path) -> None: "token_usage": None, "message_id": None, "plan_mode": False, + "yolo_mode": None, "mcp_status": None, }, }, @@ -529,6 +533,7 @@ def test_concurrent_prompt_error(tmp_path) -> None: "token_usage": None, "message_id": None, "plan_mode": False, + "yolo_mode": None, "mcp_status": None, }, }, diff --git a/tests_e2e/test_wire_protocol.py b/tests_e2e/test_wire_protocol.py index 602da121b..457ab724b 100644 --- a/tests_e2e/test_wire_protocol.py +++ b/tests_e2e/test_wire_protocol.py @@ -325,6 +325,7 @@ def handle_request(msg: dict[str, Any]) -> dict[str, Any]: "token_usage": None, "message_id": None, "plan_mode": False, + "yolo_mode": None, "mcp_status": None, }, }, @@ -367,6 +368,7 @@ def handle_request(msg: dict[str, Any]) -> dict[str, Any]: "token_usage": None, "message_id": None, "plan_mode": False, + "yolo_mode": None, "mcp_status": None, }, }, @@ -419,6 +421,7 @@ def test_prompt_without_initialize(tmp_path) -> None: "token_usage": None, "message_id": None, "plan_mode": False, + "yolo_mode": None, "mcp_status": None, }, }, diff --git a/tests_e2e/test_wire_sessions.py b/tests_e2e/test_wire_sessions.py index ef63b30d5..81dc9cc72 100644 --- a/tests_e2e/test_wire_sessions.py +++ b/tests_e2e/test_wire_sessions.py @@ -223,6 +223,7 @@ def test_clear_context_rotates(tmp_path) -> None: "token_usage": None, "message_id": None, "plan_mode": None, + "yolo_mode": None, "mcp_status": None, }, }, @@ -308,6 +309,7 @@ def test_manual_compact(tmp_path) -> None: "token_usage": None, "message_id": None, "plan_mode": None, + "yolo_mode": None, "mcp_status": None, }, }, @@ -464,6 +466,7 @@ def test_replay_streams_wire_history(tmp_path) -> None: "token_usage": None, "message_id": None, "plan_mode": False, + "yolo_mode": None, "mcp_status": None, }, }, @@ -497,6 +500,7 @@ def test_replay_streams_wire_history(tmp_path) -> None: "token_usage": None, "message_id": None, "plan_mode": False, + "yolo_mode": None, "mcp_status": None, }, }, diff --git a/tests_e2e/test_wire_skills_mcp.py b/tests_e2e/test_wire_skills_mcp.py index 88b49376d..6adda9cc1 100644 --- a/tests_e2e/test_wire_skills_mcp.py +++ b/tests_e2e/test_wire_skills_mcp.py @@ -117,6 +117,7 @@ def test_skill_prompt_injects_skill_text(tmp_path) -> None: "token_usage": None, "message_id": None, "plan_mode": False, + "yolo_mode": None, "mcp_status": None, }, }, @@ -208,6 +209,7 @@ def test_flow_skill(tmp_path) -> None: "token_usage": None, "message_id": None, "plan_mode": False, + "yolo_mode": None, "mcp_status": None, }, }, @@ -306,6 +308,7 @@ def ping(text: str) -> str: "token_usage": None, "message_id": None, "plan_mode": None, + "yolo_mode": None, "mcp_status": { "loading": True, "connected": 0, @@ -326,6 +329,7 @@ def ping(text: str) -> str: "token_usage": None, "message_id": None, "plan_mode": None, + "yolo_mode": None, "mcp_status": { "loading": False, "connected": 1, @@ -362,6 +366,7 @@ def ping(text: str) -> str: "token_usage": None, "message_id": None, "plan_mode": False, + "yolo_mode": None, "mcp_status": None, }, }, @@ -417,6 +422,7 @@ def ping(text: str) -> str: "token_usage": None, "message_id": None, "plan_mode": False, + "yolo_mode": None, "mcp_status": None, }, }, diff --git a/web/package-lock.json b/web/package-lock.json index 7c06e36ba..77241853a 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -187,7 +187,6 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.6.tgz", "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", @@ -948,7 +947,6 @@ "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.39.11.tgz", "integrity": "sha512-bWdeR8gWM87l4DB/kYSF9A+dVackzDb/V56Tq7QVrQ7rn86W0rgZFtlL3g3pem6AeGcb9NQNoy3ao4WpW4h5tQ==", "license": "MIT", - "peer": true, "dependencies": { "@codemirror/state": "^6.5.0", "crelt": "^1.0.6", @@ -1875,7 +1873,6 @@ "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", "license": "MIT", - "peer": true, "engines": { "node": "^14.21.3 || >=16" }, @@ -4919,7 +4916,6 @@ "integrity": "sha512-ne4A0IpG3+2ETuREInjPNhUGis1SFjv1d5asp8MzEAGtOZeTeHVDOYqOgqfhvseqg/iXty2hjBf1zAOb7RNiNw==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -4935,7 +4931,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.9.tgz", "integrity": "sha512-Lpo8kgb/igvMIPeNV2rsYKTgaORYdO1XGVZ4Qz3akwOj0ySGYMPlQWa8BaLn0G63D1aSaAQ5ldR06wCpChQCjA==", "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -4946,7 +4941,6 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -5611,7 +5605,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -6302,7 +6295,6 @@ "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.1.tgz", "integrity": "sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10" } @@ -6712,7 +6704,6 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", - "peer": true, "engines": { "node": ">=12" } @@ -7147,8 +7138,7 @@ "version": "8.6.0", "resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz", "integrity": "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/embla-carousel-react": { "version": "8.6.0", @@ -7439,7 +7429,6 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -9806,7 +9795,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", @@ -10443,8 +10431,7 @@ "url": "https://opencollective.com/unified" } ], - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/micromatch": { "version": "4.0.8", @@ -11553,7 +11540,6 @@ "resolved": "https://registry.npmjs.org/preact/-/preact-10.28.2.tgz", "integrity": "sha512-lbteaWGzGHdlIuiJ0l2Jq454m6kcpI1zNje6d8MlGAFlYvP2GO4ibnat7P74Esfz4sPTdM6UxtTwh/d3pwM9JA==", "license": "MIT", - "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/preact" @@ -11917,7 +11903,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -11927,7 +11912,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -13021,7 +13005,6 @@ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.56.0.tgz", "integrity": "sha512-9FwVqlgUHzbXtDg9RCMgodF3Ua4Na6Gau+Sdt9vyCN4RhHfVKX2DCHy3BjMLTDd47ITDhYAnTwGulWTblJSDLg==", "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -14134,7 +14117,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -14232,7 +14214,6 @@ "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", "license": "MIT", - "peer": true, "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", @@ -14587,7 +14568,6 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -14947,7 +14927,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/web/src/features/chat/chat-workspace-container.tsx b/web/src/features/chat/chat-workspace-container.tsx index b0ceccd19..d15d38601 100644 --- a/web/src/features/chat/chat-workspace-container.tsx +++ b/web/src/features/chat/chat-workspace-container.tsx @@ -124,6 +124,8 @@ export function ChatWorkspaceContainer({ isReplayingHistory, planMode, sendSetPlanMode, + yoloMode, + sendSetYoloMode, slashCommands, } = sessionStream; @@ -316,6 +318,10 @@ export function ChatWorkspaceContainer({ sendSetPlanMode(enabled); }, [sendSetPlanMode]); + const handleYoloModeChange = useCallback((enabled: boolean) => { + sendSetYoloMode(enabled); + }, [sendSetYoloMode]); + const handleForkSession = useCallback( async (turnIndex: number) => { if (!(selectedSessionId && onForkSession)) { @@ -386,6 +392,8 @@ export function ChatWorkspaceContainer({ slashCommands={slashCommands} planMode={planMode} onPlanModeChange={handlePlanModeChange} + yoloMode={yoloMode} + onYoloModeChange={handleYoloModeChange} onForkSession={onForkSession ? handleForkSession : undefined} /> ); diff --git a/web/src/features/chat/chat.tsx b/web/src/features/chat/chat.tsx index 9a839b56b..3092f1d5c 100644 --- a/web/src/features/chat/chat.tsx +++ b/web/src/features/chat/chat.tsx @@ -86,6 +86,10 @@ type ChatWorkspaceProps = { planMode?: boolean; /** Callback to set plan mode */ onPlanModeChange?: (enabled: boolean) => void; + /** Whether yolo mode is active */ + yoloMode?: boolean; + /** Callback to set yolo mode */ + onYoloModeChange?: (enabled: boolean) => void; /** Maximum context size for the current model (tokens) */ maxContextSize?: number; /** Fork session at a specific turn */ @@ -120,6 +124,8 @@ export const ChatWorkspace = memo(function ChatWorkspaceComponent({ slashCommands = [], planMode = false, onPlanModeChange, + yoloMode = false, + onYoloModeChange, onForkSession, }: ChatWorkspaceProps): ReactElement { const [blocksExpanded, setBlocksExpanded] = useState(false); @@ -341,6 +347,8 @@ export const ChatWorkspace = memo(function ChatWorkspaceComponent({ slashCommands={slashCommands} planMode={planMode} onPlanModeChange={onPlanModeChange} + yoloMode={yoloMode} + onYoloModeChange={onYoloModeChange} activityStatus={activityStatus} usagePercent={usagePercent} usedTokens={usedTokens} diff --git a/web/src/features/chat/components/approval-dialog.tsx b/web/src/features/chat/components/approval-dialog.tsx index f9284ef12..f632396f7 100644 --- a/web/src/features/chat/components/approval-dialog.tsx +++ b/web/src/features/chat/components/approval-dialog.tsx @@ -284,7 +284,7 @@ export function ApprovalDialog({ )} > {feedbackMode ? "Cancel feedback" : "Decline with feedback"} - {!feedbackMode && !approvalPending && ( + {!(feedbackMode || approvalPending) && ( 4 )} diff --git a/web/src/features/chat/components/chat-prompt-composer.tsx b/web/src/features/chat/components/chat-prompt-composer.tsx index ba2d051dd..1cd7436ef 100644 --- a/web/src/features/chat/components/chat-prompt-composer.tsx +++ b/web/src/features/chat/components/chat-prompt-composer.tsx @@ -64,6 +64,8 @@ type ChatPromptComposerProps = { slashCommands?: SlashCommandDef[]; planMode?: boolean; onPlanModeChange?: (enabled: boolean) => void; + yoloMode?: boolean; + onYoloModeChange?: (enabled: boolean) => void; activityStatus?: ActivityDetail; usagePercent?: number; usedTokens?: number; @@ -87,6 +89,8 @@ export const ChatPromptComposer = memo(function ChatPromptComposerComponent({ slashCommands = [], planMode = false, onPlanModeChange, + yoloMode = false, + onYoloModeChange, activityStatus, usagePercent, usedTokens, @@ -209,7 +213,8 @@ export const ChatPromptComposer = memo(function ChatPromptComposerComponent({ accept="*" className={cn( "w-full [&_[data-slot=input-group]]:border [&_[data-slot=input-group]]:border-border", - planMode && "[&_[data-slot=input-group]]:border-dashed [&_[data-slot=input-group]]:!border-blue-200 dark:[&_[data-slot=input-group]]:!border-blue-600" + planMode && "[&_[data-slot=input-group]]:border-dashed [&_[data-slot=input-group]]:!border-blue-200 dark:[&_[data-slot=input-group]]:!border-blue-600", + yoloMode && "[&_[data-slot=input-group]]:border-dashed [&_[data-slot=input-group]]:!border-amber-200 dark:[&_[data-slot=input-group]]:!border-amber-600" )} multiple maxFiles={MEDIA_CONFIG.maxCount} @@ -304,7 +309,7 @@ export const ChatPromptComposer = memo(function ChatPromptComposerComponent({ - + {isStreaming ? (
diff --git a/web/src/features/chat/components/session-files-panel.tsx b/web/src/features/chat/components/session-files-panel.tsx index 1464f2ee5..83c864142 100644 --- a/web/src/features/chat/components/session-files-panel.tsx +++ b/web/src/features/chat/components/session-files-panel.tsx @@ -251,7 +251,7 @@ export function SessionFilesPanel({
) : null} - {!isLoading && !error && entries.length === 0 ? ( + {!(isLoading || error) && entries.length === 0 ? (
No files in this directory. diff --git a/web/src/features/chat/global-config-controls.tsx b/web/src/features/chat/global-config-controls.tsx index 4103725ee..8713723cd 100644 --- a/web/src/features/chat/global-config-controls.tsx +++ b/web/src/features/chat/global-config-controls.tsx @@ -46,12 +46,16 @@ export type GlobalConfigControlsProps = { className?: string; planMode?: boolean; onPlanModeChange?: (enabled: boolean) => void; + yoloMode?: boolean; + onYoloModeChange?: (enabled: boolean) => void; }; export function GlobalConfigControls({ className, planMode = false, onPlanModeChange, + yoloMode = false, + onYoloModeChange, }: GlobalConfigControlsProps): ReactElement { const { config, isLoading, isUpdating, error, refresh, update } = useGlobalConfig(); @@ -304,6 +308,31 @@ export function GlobalConfigControls({ )} + {onYoloModeChange && ( + <> +
+ + +
+ + YOLO + + +
+
+ + {yoloMode + ? "YOLO mode is active. All actions will be auto-approved without confirmation." + : "Enable YOLO mode to auto-approve all actions without confirmation."} + +
+ + )} + {(lastBusySkip && lastBusySkip.length > 0) || error ? (
) : null} diff --git a/web/src/hooks/useSessionStream.ts b/web/src/hooks/useSessionStream.ts index 306f9d6bb..7a324c301 100644 --- a/web/src/hooks/useSessionStream.ts +++ b/web/src/hooks/useSessionStream.ts @@ -138,6 +138,7 @@ import { createMessageId, getApiBaseUrl } from "./utils"; import { kimiCliVersion } from "@/lib/version"; import { handleToolResult, useToolEventsStore, type TodoItem } from "@/features/tool/store"; import { v4 as uuidV4 } from "uuid"; +import { useYoloMode } from "@/hooks/useYoloMode"; // Regex patterns moved to top level for performance const DATA_URL_MEDIA_TYPE_REGEX = /^data:([^;,]+)[;,]/; @@ -242,6 +243,10 @@ type UseSessionStreamReturn = { planMode: boolean; /** Set plan mode via silent RPC (no context message) */ sendSetPlanMode: (enabled: boolean) => void; + /** Whether YOLO (auto-approve) mode is active */ + yoloMode: boolean; + /** Set YOLO mode via silent RPC (no context message) */ + sendSetYoloMode: (enabled: boolean) => void; /** Available slash commands from the server */ slashCommands: SlashCommandDef[]; }; @@ -286,6 +291,7 @@ export function useSessionStream( const [contextUsage, setContextUsage] = useState(0); const [tokenUsage, setTokenUsage] = useState(null); const [planMode, setPlanMode] = useState(false); + const [yoloMode, setYoloMode] = useState(false); const [currentStep, setCurrentStep] = useState(0); const [isConnected, setIsConnected] = useState(false); const [error, setError] = useState(null); @@ -293,6 +299,18 @@ export function useSessionStream( const [isReplayingHistory, setIsReplayingHistory] = useState(true); const [slashCommands, setSlashCommands] = useState([]); + // Fetch initial YOLO mode state from the server API + const { yoloStatus } = useYoloMode(sessionId); + + // Update yoloMode state when yoloStatus changes (initial load or refresh) + // Only apply if we haven't received a live update from WebSocket to avoid + // stale HTTP responses overwriting newer real-time state + useEffect(() => { + if (yoloStatus !== null && !hasReceivedLiveYoloUpdateRef.current) { + setYoloMode(yoloStatus.enabled); + } + }, [yoloStatus]); + // Refs /** * The single source of truth for "which WebSocket is allowed to mutate React state". @@ -315,7 +333,7 @@ export function useSessionStream( const connectRef = useRef<() => void>(() => undefined); const disconnectRef = useRef<() => void>(() => undefined); const reconnectRef = useRef<() => void>(() => undefined); - const resetStateRef = useRef<(preserveSlashCommands?: boolean) => void>(() => undefined); + const resetStateRef = useRef<(preserveSlashCommands?: boolean, preserveYoloMode?: boolean) => void>(() => undefined); const historyCompleteTimeoutRef = useRef(null); const isReplayingRef = useRef(true); // Track if we're still replaying history const pendingMessageRef = useRef(null); // Message to send after connection @@ -325,6 +343,8 @@ export function useSessionStream( const lastWsMessageTimeRef = useRef(0); // Last time a WS message was received const watchdogIntervalRef = useRef(null); // Stale connection watchdog const statusRef = useRef("ready"); // Synced copy of status for watchdog + const previousSessionIdRef = useRef(null); // Track previous session for reconnect detection + const hasReceivedLiveYoloUpdateRef = useRef(false); // Track if we've received live yolo_mode from WebSocket // First turn tracking for auto-rename (simplified: backend reads from wire.jsonl) const hasTurnStartedRef = useRef(false); // Whether at least one turn has started @@ -842,7 +862,7 @@ export function useSessionStream( }, []); // Reset all state - const resetState = useCallback((preserveSlashCommands = false) => { + const resetState = useCallback((preserveSlashCommands = false, preserveYoloMode = false) => { resetStepState(); currentToolCallsRef.current?.clear(); currentToolCallIdRef.current = null; @@ -853,6 +873,10 @@ export function useSessionStream( setContextUsage(0); setTokenUsage(null); setPlanMode(false); + if (!preserveYoloMode) { + setYoloMode(false); + hasReceivedLiveYoloUpdateRef.current = false; + } setError(null); setSessionStatus(null); lastStatusSeqRef.current = null; @@ -1735,6 +1759,12 @@ export function useSessionStream( setPlanMode(nextPlanMode); } + const nextYoloMode = event.payload.yolo_mode; + if (typeof nextYoloMode === "boolean") { + hasReceivedLiveYoloUpdateRef.current = true; + setYoloMode(nextYoloMode); + } + // If we have a message_id, create a special message to display it const messageId = event.payload.message_id; if (messageId) { @@ -2397,8 +2427,12 @@ export function useSessionStream( watchdogIntervalRef.current = null; } + // Check if we're reconnecting to the same session (preserve yoloMode) or switching sessions (reset yoloMode) + const isReconnectingToSameSession = previousSessionIdRef.current === sessionId; + previousSessionIdRef.current = sessionId; + awaitingIdleRef.current = false; - resetState(true); // preserve slashCommands on reconnect + resetState(true, isReconnectingToSameSession); // preserve slashCommands on reconnect, preserve yoloMode on same-session reconnect setMessages([]); setStatus("submitted"); setAwaitingFirstResponse(Boolean(pendingMessageRef.current)); @@ -2815,6 +2849,20 @@ export function useSessionStream( wsRef.current.send(JSON.stringify(message)); }, []); + // Set YOLO mode via silent RPC (no context message) + const sendSetYoloMode = useCallback((enabled: boolean) => { + if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) { + return; + } + const message: JsonRpcRequest = { + jsonrpc: "2.0", + method: "set_yolo_mode", + id: uuidV4(), + params: { enabled }, + }; + wsRef.current.send(JSON.stringify(message)); + }, []); + // Auto-connect when sessionId changes useLayoutEffect(() => { /** @@ -2899,6 +2947,8 @@ export function useSessionStream( error, planMode, sendSetPlanMode, + yoloMode, + sendSetYoloMode, slashCommands, }; } diff --git a/web/src/hooks/useYoloMode.ts b/web/src/hooks/useYoloMode.ts new file mode 100644 index 000000000..456c095d9 --- /dev/null +++ b/web/src/hooks/useYoloMode.ts @@ -0,0 +1,166 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { getApiBaseUrl } from "@/hooks/utils"; +import { getAuthHeader } from "@/lib/auth"; +import type { YoloStatus } from "@/lib/api/models"; + +export type UseYoloModeReturn = { + yoloStatus: YoloStatus | null; + isLoading: boolean; + isUpdating: boolean; + error: string | null; + refresh: () => Promise; + setYoloMode: (enabled: boolean) => Promise; +}; + +export function useYoloMode(sessionId: string | null): UseYoloModeReturn { + const [yoloStatus, setYoloStatus] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [isUpdating, setIsUpdating] = useState(false); + const [error, setError] = useState(null); + + const isInitializedRef = useRef(false); + const sessionIdRef = useRef(sessionId); + const abortControllerRef = useRef(null); + + const refresh = useCallback(async () => { + if (!sessionId) { + setYoloStatus(null); + return; + } + + // Capture sessionId at request start to detect stale responses + const requestSessionId = sessionId; + + // Cancel any in-flight request + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + + // Create new AbortController for this request + const controller = new AbortController(); + abortControllerRef.current = controller; + + setIsLoading(true); + setError(null); + try { + const response = await fetch( + `${getApiBaseUrl()}/api/sessions/${sessionId}/yolo`, + { + headers: { + ...getAuthHeader(), + }, + signal: controller.signal, + }, + ); + + if (!response.ok) { + const data = await response.json().catch(() => ({})); + throw new Error( + data.detail || `Failed to load YOLO status: ${response.status}`, + ); + } + + const data = await response.json(); + + // Guard against stale responses: only apply if session hasn't changed + if ( + requestSessionId !== sessionIdRef.current || + abortControllerRef.current !== controller + ) { + return; + } + + setYoloStatus({ + enabled: data.enabled, + autoApproveActions: data.auto_approve_actions, + }); + } catch (err) { + // Skip state updates if the request was aborted + if (err instanceof Error && err.name === "AbortError") { + return; + } + const message = + err instanceof Error ? err.message : "Failed to load YOLO status"; + setError(message); + console.error("[useYoloMode] Failed to load YOLO status:", err); + } finally { + // Only clear loading state if this request wasn't aborted + if (abortControllerRef.current === controller) { + setIsLoading(false); + } + } + }, [sessionId]); + + const setYoloMode = useCallback( + async (enabled: boolean) => { + if (!sessionId) { + throw new Error("No session selected"); + } + + setIsUpdating(true); + setError(null); + try { + const response = await fetch( + `${getApiBaseUrl()}/api/sessions/${sessionId}/yolo`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + ...getAuthHeader(), + }, + body: JSON.stringify({ enabled }), + }, + ); + + if (!response.ok) { + const data = await response.json().catch(() => ({})); + throw new Error( + data.detail || `Failed to update YOLO mode: ${response.status}`, + ); + } + + const data = await response.json(); + setYoloStatus({ + enabled: data.enabled, + autoApproveActions: data.auto_approve_actions, + }); + } catch (err) { + const message = + err instanceof Error ? err.message : "Failed to update YOLO mode"; + setError(message); + console.error("[useYoloMode] Failed to update YOLO mode:", err); + throw err; + } finally { + setIsUpdating(false); + } + }, + [sessionId], + ); + + // Load initial data + useEffect(() => { + if (isInitializedRef.current && sessionIdRef.current === sessionId) { + return; + } + sessionIdRef.current = sessionId; + isInitializedRef.current = true; + refresh(); + + // Cleanup: abort any in-flight request when sessionId changes or component unmounts + return () => { + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + abortControllerRef.current = null; + } + }; + }, [sessionId, refresh]); + + return { + yoloStatus, + isLoading, + isUpdating, + error, + refresh, + setYoloMode, + }; +} diff --git a/web/src/hooks/wireTypes.ts b/web/src/hooks/wireTypes.ts index f35b378a4..ee88be9ad 100644 --- a/web/src/hooks/wireTypes.ts +++ b/web/src/hooks/wireTypes.ts @@ -131,6 +131,7 @@ export type StatusUpdateEvent = { token_usage?: TokenUsage | null; message_id?: string; plan_mode?: boolean | null; + yolo_mode?: boolean | null; }; }; diff --git a/web/src/lib/api/models/UpdateYoloRequest.ts b/web/src/lib/api/models/UpdateYoloRequest.ts new file mode 100644 index 000000000..a1aab9cdf --- /dev/null +++ b/web/src/lib/api/models/UpdateYoloRequest.ts @@ -0,0 +1,65 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Kimi Code CLI Web Interface + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * The version of the OpenAPI document: 0.1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import { exists, mapValues } from "../runtime"; +/** + * Update YOLO mode request. + * @export + * @interface UpdateYoloRequest + */ +export interface UpdateYoloRequest { + /** + * Enable or disable YOLO mode + * @type {boolean} + * @memberof UpdateYoloRequest + */ + enabled: boolean; +} + +/** + * Check if a given object implements the UpdateYoloRequest interface. + */ +export function instanceOfUpdateYoloRequest( + value: object, +): value is UpdateYoloRequest { + if (!("enabled" in value) || value["enabled"] === undefined) return false; + return true; +} + +export function UpdateYoloRequestFromJSON(json: any): UpdateYoloRequest { + return UpdateYoloRequestFromJSONTyped(json, false); +} + +export function UpdateYoloRequestFromJSONTyped( + json: any, + ignoreDiscriminator: boolean, +): UpdateYoloRequest { + if (json == null) { + return json; + } + return { + enabled: json["enabled"], + }; +} + +export function UpdateYoloRequestToJSON( + value?: UpdateYoloRequest | null, +): any { + if (value == null) { + return value; + } + return { + enabled: value["enabled"], + }; +} diff --git a/web/src/lib/api/models/YoloStatus.ts b/web/src/lib/api/models/YoloStatus.ts new file mode 100644 index 000000000..cf9f8d399 --- /dev/null +++ b/web/src/lib/api/models/YoloStatus.ts @@ -0,0 +1,74 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Kimi Code CLI Web Interface + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * The version of the OpenAPI document: 0.1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import { exists, mapValues } from "../runtime"; +/** + * YOLO (auto-approve) mode status. + * @export + * @interface YoloStatus + */ +export interface YoloStatus { + /** + * Whether YOLO mode is enabled + * @type {boolean} + * @memberof YoloStatus + */ + enabled: boolean; + /** + * List of auto-approved action types + * @type {Array} + * @memberof YoloStatus + */ + autoApproveActions?: Array; +} + +/** + * Check if a given object implements the YoloStatus interface. + */ +export function instanceOfYoloStatus( + value: object, +): value is YoloStatus { + if (!("enabled" in value) || value["enabled"] === undefined) return false; + return true; +} + +export function YoloStatusFromJSON(json: any): YoloStatus { + return YoloStatusFromJSONTyped(json, false); +} + +export function YoloStatusFromJSONTyped( + json: any, + ignoreDiscriminator: boolean, +): YoloStatus { + if (json == null) { + return json; + } + return { + enabled: json["enabled"], + autoApproveActions: + json["auto_approve_actions"] == null + ? undefined + : json["auto_approve_actions"], + }; +} + +export function YoloStatusToJSON(value?: YoloStatus | null): any { + if (value == null) { + return value; + } + return { + enabled: value["enabled"], + auto_approve_actions: value["autoApproveActions"], + }; +} diff --git a/web/src/lib/api/models/index.ts b/web/src/lib/api/models/index.ts index 80183dae6..be0e21ca4 100644 --- a/web/src/lib/api/models/index.ts +++ b/web/src/lib/api/models/index.ts @@ -20,6 +20,8 @@ export * from './UpdateConfigTomlResponse'; export * from './UpdateGlobalConfigRequest'; export * from './UpdateGlobalConfigResponse'; export * from './UpdateSessionRequest'; +export * from './UpdateYoloRequest'; export * from './UploadSessionFileResponse'; export * from './ValidationError'; export * from './ValidationErrorLocInner'; +export * from './YoloStatus';