Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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 AI title generation overwriting a manually-set title when the LLM call finishes after the user has already renamed the session — the final write now reloads state and yields to a `title_generated` flag set by another request
- Web: Surface session rename, archive, unarchive, and title generation failures as toast notifications instead of only logging to the console
- Web: Keep tool media previews visible when tool details are collapsed — images and videos returned by tools now render below the tool card instead of inside the collapsible detail area, so preview thumbnails remain accessible after collapsing a tool
- 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
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 AI title generation overwriting a manually-set title when the LLM call finishes after the user has already renamed the session — the final write now reloads state and yields to a `title_generated` flag set by another request
- Web: Surface session rename, archive, unarchive, and title generation failures as toast notifications instead of only logging to the console
- Web: Keep tool media previews visible when tool details are collapsed — images and videos returned by tools now render below the tool card instead of inside the collapsible detail area, so preview thumbnails remain accessible after collapsing a tool
- 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
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:修复 AI 标题生成在用户已手动重命名后才返回时覆盖手动标题的问题——最终写入前会重新读取状态,若另一请求已将 `title_generated` 标记为完成,则尊重新标题不再覆盖
- Web:会话重命名、归档、取消归档、生成标题失败时弹出 toast 提示,而不仅仅是记录到 console
- Web:折叠工具详情后仍保留工具媒体预览——工具返回的图片和视频现在渲染在工具卡片下方,而不是折叠详情区域内部,因此折叠工具后预览缩略图仍然可见
- Kosong:修复 Kimi 供应商在 OAuth 令牌刷新后仍使用过期的 API 密钥的问题——`on_retryable_error` 现在从当前 client 读取 `api_key`,而不是缓存的 `_api_key`,因此在可重试错误后重建 client 时会保留通过 `client.api_key` 应用的 OAuth 令牌刷新
- Core:修复审批请求 5 分钟自动超时并被误报为 `Rejected by user` 的问题;现在活跃的前台和子 Agent 审批请求都会无限等待用户响应
Expand Down
10 changes: 10 additions & 0 deletions src/kimi_cli/web/api/sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -789,6 +789,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 +861,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
116 changes: 116 additions & 0 deletions tests/web/test_sessions_api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
from __future__ import annotations

from pathlib import Path
from types import SimpleNamespace
from typing import TYPE_CHECKING, cast
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

if TYPE_CHECKING:
from kimi_cli.web.runner.process import KimiCLIRunner


@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 _FakeRunner:
"""Stand-in for ``KimiCLIRunner`` for tests that bypass FastAPI dependency injection."""

def get_session(self, _session_id: UUID) -> 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.",
),
runner=cast("KimiCLIRunner", _FakeRunner()),
)

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
18 changes: 15 additions & 3 deletions web/src/features/sessions/sessions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
memo,
useCallback,
useMemo,
useRef,
type ReactElement,
useEffect,
useState,
Expand Down Expand Up @@ -200,6 +201,9 @@ export const SessionsSidebar = memo(function SessionsSidebarComponent({
const [isRefreshing, setIsRefreshing] = useState(false);
const [editingSessionId, setEditingSessionId] = useState<string | null>(null);
const [editingTitle, setEditingTitle] = useState("");
// Guard against re-entry: pressing Enter and the resulting blur (e.g. when
// the user clicks the toast to dismiss it) both call handleSaveEdit.
const isSavingRenameRef = useRef(false);

// Session search state
const [sessionSearch, setSessionSearch] = useState(searchQuery);
Expand Down Expand Up @@ -437,6 +441,9 @@ export const SessionsSidebar = memo(function SessionsSidebarComponent({
};

const handleSaveEdit = async () => {
if (isSavingRenameRef.current) {
return;
}
if (!(editingSessionId && onRenameSession)) {
handleCancelEdit();
return;
Expand All @@ -448,9 +455,14 @@ export const SessionsSidebar = memo(function SessionsSidebarComponent({
return;
}

const success = await onRenameSession(editingSessionId, trimmedTitle);
if (success) {
handleCancelEdit();
isSavingRenameRef.current = true;
try {
const success = await onRenameSession(editingSessionId, trimmedTitle);
if (success) {
handleCancelEdit();
}
} finally {
isSavingRenameRef.current = false;
}
};

Expand Down
13 changes: 13 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 @@ -703,7 +704,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 +744,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 +797,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 +841,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