Skip to content
This repository was archived by the owner on Sep 23, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ Only write entries that are worth mentioning to users.

## Unreleased

- Web: Fix session rename, archive, and title generation being incorrectly rejected when the session is busy — these state-only edits now bypass the busy check and safely merge concurrent worker changes via on-disk state reloads. Title generation also preserves a title finalized by another request while the LLM call was in flight
- Web: Show toast error notifications in the web UI for session rename, archive, unarchive, delete, and title generation failures
- Kosong: Fix stale API key after OAuth token refresh in Kimi provider — `on_retryable_error` now reads the current `api_key` from the live client instead of the cached `_api_key`, so that OAuth token refreshes applied via `client.api_key` are preserved when the client is rebuilt after a retryable error
- Core: Approval requests no longer auto-timeout after 5 minutes, which previously surfaced as `Rejected by user`; active foreground and subagent approvals now wait indefinitely for user response
- Core: Fix yolo mode reminder being lost after context compaction — the non-interactive-mode guidance ("don't call AskUserQuestion, plan-mode toggles are auto-approved") is now re-injected on the first LLM step after each compaction while yolo remains active, instead of being silently dropped when the original reminder is folded into the compaction summary
Expand Down
2 changes: 2 additions & 0 deletions docs/en/release-notes/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ This page documents the changes in each Kimi Code CLI release.

## Unreleased

- Web: Fix session rename, archive, and title generation being incorrectly rejected when the session is busy — these state-only edits now bypass the busy check and safely merge concurrent worker changes via on-disk state reloads. Title generation also preserves a title finalized by another request while the LLM call was in flight
- Web: Show toast error notifications in the web UI for session rename, archive, unarchive, delete, and title generation failures
- Kosong: Fix stale API key after OAuth token refresh in Kimi provider — `on_retryable_error` now reads the current `api_key` from the live client instead of the cached `_api_key`, so that OAuth token refreshes applied via `client.api_key` are preserved when the client is rebuilt after a retryable error
- Core: Approval requests no longer auto-timeout after 5 minutes, which previously surfaced as `Rejected by user`; active foreground and subagent approvals now wait indefinitely for user response
- Core: Fix yolo mode reminder being lost after context compaction — the non-interactive-mode guidance ("don't call AskUserQuestion, plan-mode toggles are auto-approved") is now re-injected on the first LLM step after each compaction while yolo remains active, instead of being silently dropped when the original reminder is folded into the compaction summary
Expand Down
2 changes: 2 additions & 0 deletions docs/zh/release-notes/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

## 未发布

- Web:修复会话处于忙碌状态时重命名、归档和生成标题被错误拒绝的问题——这些仅修改状态的操作现在会绕过忙碌检查,并通过从磁盘重新加载状态来安全合并并发 worker 的变更。标题生成现在也会保留在 LLM 调用过程中被其它请求最终确定的标题
- Web:在 Web UI 中为会话重命名、归档、取消归档、删除和标题生成失败添加 toast 错误提示
- Kosong:修复 Kimi 供应商在 OAuth 令牌刷新后仍使用过期的 API 密钥的问题——`on_retryable_error` 现在从当前 client 读取 `api_key`,而不是缓存的 `_api_key`,因此在可重试错误后重建 client 时会保留通过 `client.api_key` 应用的 OAuth 令牌刷新
- Core:修复审批请求 5 分钟自动超时并被误报为 `Rejected by user` 的问题;现在活跃的前台和子 Agent 审批请求都会无限等待用户响应
- Core:修复上下文压缩后 yolo 模式提示词丢失的问题——当 yolo 模式处于激活状态时,非交互模式下的指导提示(不要调用 AskUserQuestion、计划模式切换自动批准等)现在会在每次上下文压缩后的第一个 LLM 步骤重新注入,而不是在原始提示被折叠进压缩摘要后静默消失
Expand Down
50 changes: 39 additions & 11 deletions src/kimi_cli/web/api/sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,18 +106,30 @@ def get_runner_ws(ws: WebSocket) -> KimiCLIRunner:
return ws.app.state.runner


def get_editable_session(
session_id: UUID,
runner: KimiCLIRunner,
) -> JointSession:
"""Get a session and verify it's not busy."""
def get_session_or_404(session_id: UUID) -> JointSession:
"""Load a session by id, raising 404 if it doesn't exist."""
session = load_session_by_id(session_id)
if session is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Session not found",
)
# Check if session is busy
return session


def get_editable_session(
session_id: UUID,
runner: KimiCLIRunner,
) -> JointSession:
"""Get a session and verify it's not busy.

Use for operations that cannot run concurrently with a live worker
(delete, fork, upload). For state-only edits (rename, archive,
generate-title) the worker merges externally-mutable fields back
via ``Session.save_state``; those endpoints can use
:func:`get_session_or_404` instead.
"""
session = get_session_or_404(session_id)
session_process = runner.get_session(session_id)
if session_process and session_process.is_busy:
raise HTTPException(
Expand Down Expand Up @@ -586,12 +598,16 @@ async def delete_session(session_id: UUID, runner: KimiCLIRunner = Depends(get_r
async def update_session(
session_id: UUID,
request: UpdateSessionRequest,
runner: KimiCLIRunner = Depends(get_runner),
) -> Session:
"""Update a session (e.g., rename title or archive/unarchive)."""
"""Update a session (e.g., rename title or archive/unarchive).

Safe to invoke while a worker is running: only externally-mutable
fields are touched, and ``Session.save_state`` reloads them from
disk before each worker write.
"""
from kimi_cli.session_state import load_session_state, save_session_state

session = get_editable_session(session_id, runner)
session = get_session_or_404(session_id)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Restore concurrency guard for PATCH session updates

Switching this endpoint to get_session_or_404 allows renames/archive toggles while the worker is active, but the handler still does a full read-modify-write of state.json (load_session_state then save_session_state) without any lock or CAS. If the running worker writes session state in between (e.g., todo/plan/approval updates via Session.save_state), this PATCH can write an older snapshot back and silently roll back those non-title fields. This race was prevented before by the busy check, so allowing busy edits here introduces state-loss scenarios.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Reintroduce synchronization for busy PATCH state writes

Allowing PATCH /api/sessions/{session_id} to bypass get_editable_session reintroduces lost-update races because worker-side state writes are not uniformly routed through Session.save_state. Fresh evidence: KimiSoul’s first-turn auto-title path performs its own unlocked load_session_state/save_session_state cycle (src/kimi_cli/soul/kimisoul.py, lines 638–643). If rename/archive PATCH runs while that turn is finishing, whichever write lands last can silently revert the other change (for example, an archive flag flipping back), so this endpoint is not safe while busy without locking/CAS.

Useful? React with 👍 / 👎.

session_dir = session.kimi_cli_session.dir
state = load_session_state(session_dir)
Comment on lines 592 to 596

Copilot AI Apr 28, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change removes the busy guard for PATCH /api/sessions/{session_id} by switching to get_session_or_404, but there’s no test asserting that updates succeed while a session is busy (and that truly conflicting ops remain blocked). Adding a regression test that simulates a busy SessionProcess and verifies rename/archive still work would help prevent accidental reintroduction of the busy check.

Copilot uses AI. Check for mistakes.

Expand Down Expand Up @@ -749,14 +765,16 @@ async def fork_session_endpoint(
async def generate_session_title(
session_id: UUID,
request: GenerateTitleRequest | None = None,
runner: KimiCLIRunner = Depends(get_runner),
) -> GenerateTitleResponse:
"""Generate a concise session title using AI based on the first conversation turn.

If request body is empty or parameters are missing, the backend will
automatically read the first turn from wire.jsonl.

Safe to invoke while a worker is running: the final write reloads
state from disk to merge concurrent worker changes.
"""
session = get_editable_session(session_id, runner)
session = get_session_or_404(session_id)
session_dir = session.kimi_cli_session.dir
Comment on lines 748 to 760

Copilot AI Apr 28, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

generate_session_title is now allowed while a session is busy, but the new test only covers the late-write race and doesn’t cover the core behavior change (busy session should still accept generate-title). Consider adding a test that sets up a busy SessionProcess and asserts this endpoint returns 200 instead of the previous "Session is busy" error.

Copilot uses AI. Check for mistakes.

from kimi_cli.session_state import load_session_state, save_session_state
Expand Down Expand Up @@ -789,6 +807,11 @@ async def generate_session_title(
# If AI generation failed too many times, use fallback and mark as generated
if state.title_generate_attempts >= 3:
fresh = load_session_state(session_dir)
# Respect a title finalized by another request/user action while we
# were preparing a fallback.
if fresh.title_generated:
invalidate_sessions_cache()
return GenerateTitleResponse(title=fresh.custom_title or "Untitled")
fresh.custom_title = fallback_title
fresh.title_generated = True
save_session_state(fresh, session_dir)
Expand Down Expand Up @@ -856,6 +879,11 @@ async def generate_session_title(
# Read-modify-write: reload fresh state to avoid overwriting
# worker changes made during the LLM call
fresh = load_session_state(session_dir)
# Another request or manual rename may have finalized the title while the
# LLM call was in flight. Preserve that newer title instead of clobbering it.
Comment on lines 863 to +865

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Guard generate-title state write against concurrent worker saves

This endpoint is now callable while a worker is busy, but it still does an unlocked read-modify-write (load_session_state then save_session_state). In generate_session_title, if the worker persists session changes (e.g., todos/approval/plan fields via Session.save_state) after fresh is loaded but before this save executes, this handler can write an older snapshot and silently roll back those worker updates. Because the busy check was removed for this route in this commit, this introduces a real state-loss race under concurrent activity.

Useful? React with 👍 / 👎.

if fresh.title_generated:
invalidate_sessions_cache()
return GenerateTitleResponse(title=fresh.custom_title or "Untitled")
fresh.custom_title = title
if ai_generated:
fresh.title_generated = True
Expand Down
104 changes: 104 additions & 0 deletions tests/web/test_sessions_api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
from __future__ import annotations

from pathlib import Path
from types import SimpleNamespace
from uuid import UUID

import pytest
from kaos.path import KaosPath

from kimi_cli.session import Session
from kimi_cli.session_state import load_session_state, save_session_state
from kimi_cli.web.api import sessions as sessions_api
from kimi_cli.web.models import GenerateTitleRequest


@pytest.fixture
def isolated_share_dir(monkeypatch, tmp_path: Path) -> Path:
share_dir = tmp_path / "share"
share_dir.mkdir()

def _get_share_dir() -> Path:
share_dir.mkdir(parents=True, exist_ok=True)
return share_dir

monkeypatch.setattr("kimi_cli.share.get_share_dir", _get_share_dir)
monkeypatch.setattr("kimi_cli.metadata.get_share_dir", _get_share_dir)
return share_dir


@pytest.fixture
def work_dir(tmp_path: Path) -> KaosPath:
path = tmp_path / "work"
path.mkdir()
return KaosPath.unsafe_from_local_path(path)


class _FakeOAuthManager:
def __init__(self, _config: object) -> None:
pass

async def ensure_fresh(self) -> None:
return None


class _FakeLLM:
chat_provider = object()


class _FakeMessage:
def __init__(self, text: str) -> None:
self._text = text

def extract_text(self) -> str:
return self._text


class _FakeResult:
def __init__(self, text: str) -> None:
self.message = _FakeMessage(text)


@pytest.mark.anyio
async def test_generate_title_preserves_concurrent_manual_title(
isolated_share_dir: Path,
work_dir: KaosPath,
monkeypatch: pytest.MonkeyPatch,
) -> None:
session = await Session.create(work_dir)

config = SimpleNamespace(
default_model="test-model",
models={"test-model": SimpleNamespace(provider="test-provider")},
providers={"test-provider": object()},
)

monkeypatch.setattr("kimi_cli.config.load_config", lambda: config)
monkeypatch.setattr(
"kimi_cli.llm.create_llm",
lambda provider_config, model_config, oauth=None: _FakeLLM(),
)
monkeypatch.setattr("kimi_cli.auth.oauth.OAuthManager", _FakeOAuthManager)

async def fake_generate(*, chat_provider, system_prompt, tools, history):
state = load_session_state(session.dir)
state.custom_title = "Manual Title"
state.title_generated = True
save_session_state(state, session.dir)
return _FakeResult("AI Title")

monkeypatch.setattr("kosong.generate", fake_generate)

response = await sessions_api.generate_session_title(
UUID(session.id),
GenerateTitleRequest(
user_message="debug the flaky web session rename issue",
assistant_response="I'll inspect the session state writes.",
),
)

state = load_session_state(session.dir)
assert response.title == "Manual Title"
assert state.custom_title == "Manual Title"
assert state.title_generated is True
assert state.title_generate_attempts == 0
14 changes: 14 additions & 0 deletions web/src/hooks/useSessions.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useState, useCallback, useEffect, useRef } from "react";
import { toast } from "sonner";
import type {
Session,
UploadSessionFileResponse,
Expand Down Expand Up @@ -512,6 +513,7 @@ export function useSessions(): UseSessionsReturn {
const message =
err instanceof Error ? err.message : "Failed to delete session";
setError(message);
toast.error(message);

Copilot AI Apr 28, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

deleteSession now calls toast.error(message) after setting the global error state. App.tsx already shows a toast whenever sessionsError is set, so a delete failure will produce duplicate toasts. Consider removing the direct toast here (or stop setting the global error for this path) so failures are surfaced exactly once.

Suggested change
toast.error(message);

Copilot uses AI. Check for mistakes.
return false;
} finally {
setIsLoading(false);
Expand Down Expand Up @@ -703,7 +705,10 @@ export function useSessions(): UseSessionsReturn {
await refreshSession(sessionId);
return true;
} catch (err) {
const message =
err instanceof Error ? err.message : "Failed to rename session";
console.error("Failed to rename session:", err);
toast.error(message);
return false;
}
},
Expand Down Expand Up @@ -740,7 +745,10 @@ export function useSessions(): UseSessionsReturn {
await refreshSession(sessionId);
return result.title;
} catch (err) {
const message =
err instanceof Error ? err.message : "Failed to generate title";
console.error("Failed to generate title:", err);
toast.error(message);
return null;
}
},
Expand Down Expand Up @@ -790,7 +798,10 @@ export function useSessions(): UseSessionsReturn {

return true;
} catch (err) {
const message =
err instanceof Error ? err.message : "Failed to archive session";
console.error("Failed to archive session:", err);
toast.error(message);
return false;
}
},
Expand Down Expand Up @@ -831,7 +842,10 @@ export function useSessions(): UseSessionsReturn {

return true;
} catch (err) {
const message =
err instanceof Error ? err.message : "Failed to unarchive session";
console.error("Failed to unarchive session:", err);
toast.error(message);
return false;
}
},
Expand Down
Loading