From 093576f6ae63c83e0dc14cefb1e128df3d30be7d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 18 Aug 2026 02:29:00 +0000 Subject: [PATCH 1/3] Keep Stop/idle partials, cancel in-flight tools, and unblock CI. Stop and idle now keep the streamed partial (including sync_response), mark interrupted tools cancelled instead of completed, and actually cancel serial/prefetched tool work. Session delete clears the dead draft and stream cache immediately. Local python/command spawn silently drops control-plane secrets while keeping PATH/HOME/user env. Image HTTP gets a timeout; emit_run_event and file write/edit leave the event loop. Ruff F541 and useColResize render-ref writes are fixed so CI can run. Co-authored-by: wu1w --- backend/agent/loop.py | 4 +- backend/agent/phases/tool_round.py | 88 ++++++++++-- backend/agent/run_events.py | 3 +- backend/core/host_commands.py | 53 +++++++ backend/services/image/local.py | 3 +- backend/services/image/openai.py | 3 +- backend/services/tools/executors.py | 103 ++++++++------ backend/tests/test_hang_avoidance.py | 29 ++++ backend/tests/test_loop_stop_ux.py | 21 ++- backend/tests/test_tool_parallel.py | 45 +++++- backend/tests/test_tool_spawn_env.py | 54 +++++++ frontend/app/chat/page.tsx | 133 ++++++++++++++---- frontend/components/chat/ActivityPanel.tsx | 1 + .../components/chat/ComposerContextStrip.tsx | 1 + .../components/chat/ContactSessionPicker.tsx | 2 + frontend/components/chat/MessageBubble.tsx | 14 +- frontend/components/chat/MessageInput.tsx | 5 +- frontend/components/chat/ToolCallPanel.tsx | 25 +++- frontend/hooks/useColResize.ts | 4 +- frontend/hooks/useSession.ts | 2 + frontend/lib/chatDisplay.ts | 2 +- frontend/lib/sessionLocalCleanup.ts | 22 +++ frontend/types/index.ts | 2 +- 23 files changed, 514 insertions(+), 105 deletions(-) create mode 100644 backend/tests/test_hang_avoidance.py create mode 100644 backend/tests/test_tool_spawn_env.py create mode 100644 frontend/lib/sessionLocalCleanup.ts diff --git a/backend/agent/loop.py b/backend/agent/loop.py index 9af7bfae..9c300ecc 100644 --- a/backend/agent/loop.py +++ b/backend/agent/loop.py @@ -390,7 +390,7 @@ async def _kernel_iteration_gate( await self._push_status( session_id, "thinking", - f"正在等待继续…", + "正在等待继续…", ) logger.info("kernel 进程挂起等待 proc=%s reason=%s", proc.id, reason) def _refresh_from_shared(p): @@ -3474,7 +3474,7 @@ def _micro_keep(t: dict) -> bool: await self._push_status( session_id, "thinking", - f"步数已用完,正在给出答复…", + "步数已用完,正在给出答复…", ) logger.info( "Iteration budget grace session=%s used=%s", diff --git a/backend/agent/phases/tool_round.py b/backend/agent/phases/tool_round.py index 6120a116..2beb302e 100644 --- a/backend/agent/phases/tool_round.py +++ b/backend/agent/phases/tool_round.py @@ -83,6 +83,39 @@ def _risk_name(tool: Any) -> str: return str(getattr(rl, "value", rl) or "").lower() +def _stop_skips_remaining_tool(loop: Any, tc: Any, capped: dict[str, str]) -> bool: + """User Stop wins over prefetch cache. Policy-capped results still report.""" + if not getattr(loop, "_should_stop", False): + return False + cid = str(getattr(tc, "id", "") or "") + return cid not in capped + + +async def _run_tool_cancellable(loop: Any, coro: Any, timeout: float) -> Any: + """Run one tool; cancel the in-flight call when the user hits Stop.""" + task = asyncio.ensure_future(coro) + + async def _watch_stop() -> None: + while not getattr(loop, "_should_stop", False): + if task.done(): + return + await asyncio.sleep(0.1) + if not task.done(): + task.cancel() + + watcher = asyncio.create_task(_watch_stop()) + try: + if timeout > 0: + return await _await_with_timeout_cleanup(task, timeout) + return await task + finally: + watcher.cancel() + try: + await watcher + except (asyncio.CancelledError, Exception): + pass + + async def _await_with_timeout_cleanup(coro: Any, timeout: float) -> Any: """wait_for 超时后显式 cancel + await 清理(L2-H1)。 @@ -792,7 +825,7 @@ async def run_tool_round( # 执行每个 tool call for tc in tool_calls: - if getattr(loop, "_should_stop", False) and str(getattr(tc, "id", "") or "") not in _capped_results and getattr(tc, "id", None) not in prefetched: + if _stop_skips_remaining_tool(loop, tc, _capped_results): _cancel = "[Cancelled] stopped by user" _args = tc.arguments if isinstance(tc.arguments, dict) else {} if not isinstance(_args, dict): @@ -951,13 +984,11 @@ async def run_tool_round( "timeout", max(15, int(min(90, _tool_timeout - 5))), ) - if _tool_timeout > 0: - tool_result = await _await_with_timeout_cleanup( - loop._execute_registered_tool(tc.name, validated_args), - _tool_timeout, - ) - else: - tool_result = await loop._execute_registered_tool(tc.name, validated_args) + tool_result = await _run_tool_cancellable( + loop, + loop._execute_registered_tool(tc.name, validated_args), + _tool_timeout, + ) query = ( tc.arguments.get("query", "") if tc.name == "search_knowledge_base" @@ -993,7 +1024,9 @@ async def run_tool_round( if _gate_err: tool_result = _gate_err else: - tool_result = await skill.execute(**validated_args) + tool_result = await _run_tool_cancellable( + loop, skill.execute(**validated_args), 0 + ) query = "" else: # 尝试执行数据库中的自定义 Skill / Tool @@ -1021,8 +1054,10 @@ async def run_tool_round( db_tool = await tool_repo.get_tool_by_name(tc.name) if db_tool is not None and db_tool.enabled: # 走 Registry(内含 tool_gate);参数用 validated 而非裸 tc.arguments - tool_result = await UnifiedToolRegistry.execute( - tc.name, validated_args + tool_result = await _run_tool_cancellable( + loop, + UnifiedToolRegistry.execute(tc.name, validated_args), + 0, ) query = "" else: @@ -1037,7 +1072,9 @@ async def run_tool_round( tool_result = _gate_err else: try: - tool_result = await dynamic.execute(**validated_args) + tool_result = await _run_tool_cancellable( + loop, dynamic.execute(**validated_args), 0 + ) except Exception as _de: tool_result = ( f"[Error] Tool '{tc.name}' not found or disabled " @@ -1192,6 +1229,33 @@ async def run_tool_round( await asyncio.sleep(0) except Exception: pass + except asyncio.CancelledError: + tool_result = "[Cancelled] stopped by user" + query = "" + logger.info("Tool %s cancelled by user stop", tc.name) + try: + await loop._push_tool_event( + session_id, + phase="end", + tool_call_id=tc.id, + name=tc.name, + arguments=args_dict if isinstance(args_dict, dict) else {}, + status="failed", + result=tool_result, + duration_ms=(_time.monotonic() - _tc_t0) * 1000, + ) + except Exception: + pass + if task_id is not None: + try: + await loop._push_task_update( + session_id, task_id, 0, "failed", tool_result[:200] + ) + except Exception: + pass + if not getattr(loop, "_should_stop", False): + raise + except asyncio.TimeoutError: _to = float(getattr(settings, "agent_tool_timeout_seconds", 180) or 180) tool_result = f"[Error] Tool '{tc.name}' timed out after {_to:.0f}s" diff --git a/backend/agent/run_events.py b/backend/agent/run_events.py index 49caac3e..e382a739 100644 --- a/backend/agent/run_events.py +++ b/backend/agent/run_events.py @@ -7,6 +7,7 @@ """ from __future__ import annotations +import asyncio import json import logging import os @@ -256,7 +257,7 @@ async def emit_run_event( msg_base["generation"] = int(generation) msg_base["run_generation"] = int(generation) - seq, msg = _atomic_emit(sid, event, msg_base) + seq, msg = await asyncio.to_thread(_atomic_emit, sid, event, msg_base) if ws_manager is None: return seq diff --git a/backend/core/host_commands.py b/backend/core/host_commands.py index da0e30c6..9a113a73 100644 --- a/backend/core/host_commands.py +++ b/backend/core/host_commands.py @@ -225,3 +225,56 @@ def build_process_env( if "PATH" in (extra or {}): env["PATH"] = enrich_path(str(extra.get("PATH") or "")) return env + + +# Control-plane secrets injected into the FastAPI process. Agent python/command +# children must not inherit them. User-intended env (PATH, HOME, provider keys) +# is kept so local tools keep working — silent strip, no confirm. +_CONTROL_PLANE_ENV_KEYS = frozenset( + { + "TEVARN_JWT_SECRET", + "TEVARN_SECRET_KEY", + "JWT_SECRET", + "TEVARN_API_KEY", + "TEVARN_DEFAULT_ADMIN_PASSWORD", + "TEVARN_DESKTOP_PERMISSION_SECRET", + "TEVARN_SETTINGS_ENCRYPTION_SALT", + "TEVARN_SETTINGS_ENCRYPTION_KEY", + "TEVARN_KERNEL_RPC_SECRET", + "TEVARN_TOKEN_HMAC_SECRET", + "TEVARN_BRIDGE_TOKEN", + } +) + + +def is_control_plane_env_key(key: str) -> bool: + k = (key or "").strip().upper() + if not k: + return False + if k in _CONTROL_PLANE_ENV_KEYS: + return True + if k.startswith("TEVARN_") and any( + token in k for token in ("SECRET", "PASSWORD", "HMAC", "ENCRYPTION") + ): + return True + return False + + +def tool_spawn_env(extra: dict[str, str] | None = None) -> dict[str, str]: + """Host env minus product secrets. PATH / HOME / user env stay intact.""" + env: dict[str, str] = {} + for k, v in os.environ.items(): + if v is None or is_control_plane_env_key(str(k)): + continue + env[str(k)] = str(v) + if extra: + for k, v in extra.items(): + if v is None or is_control_plane_env_key(str(k)): + continue + env[str(k)] = str(v) + if "PATH" not in env: + env["PATH"] = os.environ.get("PATH", "") + home = os.environ.get("HOME") + if home and "HOME" not in env: + env["HOME"] = home + return env diff --git a/backend/services/image/local.py b/backend/services/image/local.py index f1804215..5fce64aa 100644 --- a/backend/services/image/local.py +++ b/backend/services/image/local.py @@ -48,8 +48,9 @@ async def generate( "n": min(n, 4), "size": f"{width}x{height}", } + timeout = aiohttp.ClientTimeout(total=120, connect=10) try: - async with aiohttp.ClientSession() as session: + async with aiohttp.ClientSession(timeout=timeout) as session: async with session.post(url, json=payload, headers=self._get_headers()) as resp: resp.raise_for_status() data = await resp.json() diff --git a/backend/services/image/openai.py b/backend/services/image/openai.py index fabf61b9..10495165 100644 --- a/backend/services/image/openai.py +++ b/backend/services/image/openai.py @@ -61,8 +61,9 @@ async def generate( "size": self._get_size(width, height), "response_format": "url", } + timeout = aiohttp.ClientTimeout(total=120, connect=10) try: - async with aiohttp.ClientSession() as session: + async with aiohttp.ClientSession(timeout=timeout) as session: async with session.post(url, json=payload, headers=self._get_headers()) as resp: resp.raise_for_status() data = await resp.json() diff --git a/backend/services/tools/executors.py b/backend/services/tools/executors.py index 051a6657..2f29a3b2 100644 --- a/backend/services/tools/executors.py +++ b/backend/services/tools/executors.py @@ -1336,7 +1336,12 @@ async def execute_command(config: dict[str, Any], arguments: dict[str, Any]) -> format_process, ) - proc = await create_process(command, cwd=cwd if cwd else None) + from backend.core.host_commands import tool_spawn_env + + _child_env = tool_spawn_env() + proc = await create_process( + command, cwd=cwd if cwd else None, env=_child_env + ) mode = "shell" if needs_shell(command) else "exec" try: stdout_b, stderr_b = await asyncio.wait_for( @@ -1411,9 +1416,13 @@ async def execute_command(config: dict[str, Any], arguments: dict[str, Any]) -> m = re.search(r"(?i)\s-p\s+(\S+)", command or "") if m: clean = f"cargo clean -p {m.group(1)}" - proc2 = await create_process(clean, cwd=cwd if cwd else None) + proc2 = await create_process( + clean, cwd=cwd if cwd else None, env=_child_env + ) await asyncio.wait_for(proc2.communicate(), timeout=min(float(timeout), 120.0)) - proc3 = await create_process(command, cwd=cwd if cwd else None) + proc3 = await create_process( + command, cwd=cwd if cwd else None, env=_child_env + ) o3, e3 = await asyncio.wait_for( proc3.communicate(), timeout=float(timeout) ) @@ -1790,14 +1799,16 @@ async def execute_file_write(config: dict[str, Any], arguments: dict[str, Any]) f"(workspace={base_abs}). Use a relative path like 'docs/foo.md'." ) - # 确保目录存在 parent = os.path.dirname(full_path) - if parent: - os.makedirs(parent, exist_ok=True) try: - with open(full_path, "w", encoding="utf-8") as f: - f.write(content) + def _write() -> None: + if parent: + os.makedirs(parent, exist_ok=True) + with open(full_path, "w", encoding="utf-8") as f: + f.write(content) + + await asyncio.to_thread(_write) return f"[Success] Written {len(content)} characters to {filepath}" except Exception as e: return f"[Error] {e}" @@ -2062,7 +2073,11 @@ def _cmd_quote(s: str) -> str: timeout_s = float(timeout or 30) except (TypeError, ValueError): timeout_s = 30.0 - proc = await create_process_exec(py, script_path) + from backend.core.host_commands import tool_spawn_env + + proc = await create_process_exec( + py, script_path, env=tool_spawn_env() + ) stdout, stderr = await asyncio.wait_for( proc.communicate(), timeout=timeout_s ) @@ -2287,48 +2302,50 @@ async def execute_edit(config: dict[str, Any], arguments: dict[str, Any]) -> str return f"[Error] Not a file: {filepath}" try: - with open(full_path, "r", encoding="utf-8", errors="replace") as f: - content = f.read() + def _edit() -> str: + with open(full_path, "r", encoding="utf-8", errors="replace") as f: + content = f.read() - occurrences = content.count(old_text) + occurrences = content.count(old_text) - if occurrences == 0: - return ( - f"[Error] old_text not found in {filepath}. " - f"Read the file first and copy the exact text, including indentation " - f"and line breaks." - ) + if occurrences == 0: + return ( + f"[Error] old_text not found in {filepath}. " + f"Read the file first and copy the exact text, including indentation " + f"and line breaks." + ) - if occurrences > 1 and not replace_all: - # 定位前两处所在行号,帮模型判断该扩多少上下文 - first = content[: content.index(old_text)].count("\n") + 1 - second_off = content.index(old_text, content.index(old_text) + 1) - second = content[:second_off].count("\n") + 1 - return ( - f"[Error] old_text appears {occurrences} times in {filepath} " - f"(first at line {first}, next at line {second}). " - f"Include more surrounding lines to make it unique, " - f"or pass replace_all=true to replace every occurrence." - ) + if occurrences > 1 and not replace_all: + first = content[: content.index(old_text)].count("\n") + 1 + second_off = content.index(old_text, content.index(old_text) + 1) + second = content[:second_off].count("\n") + 1 + return ( + f"[Error] old_text appears {occurrences} times in {filepath} " + f"(first at line {first}, next at line {second}). " + f"Include more surrounding lines to make it unique, " + f"or pass replace_all=true to replace every occurrence." + ) - line_no = content[: content.index(old_text)].count("\n") + 1 - if replace_all: - new_content = content.replace(old_text, new_text) - else: - new_content = content.replace(old_text, new_text, 1) + line_no = content[: content.index(old_text)].count("\n") + 1 + if replace_all: + new_content = content.replace(old_text, new_text) + else: + new_content = content.replace(old_text, new_text, 1) - with open(full_path, "w", encoding="utf-8") as f: - f.write(new_content) + with open(full_path, "w", encoding="utf-8") as f: + f.write(new_content) - if replace_all and occurrences > 1: + if replace_all and occurrences > 1: + return ( + f"[Success] Edited {filepath}: replaced all {occurrences} occurrences " + f"(first at line {line_no}), {len(old_text)} -> {len(new_text)} chars each" + ) return ( - f"[Success] Edited {filepath}: replaced all {occurrences} occurrences " - f"(first at line {line_no}), {len(old_text)} -> {len(new_text)} chars each" + f"[Success] Edited {filepath}:{line_no} — " + f"replaced {len(old_text)} chars with {len(new_text)} chars" ) - return ( - f"[Success] Edited {filepath}:{line_no} — " - f"replaced {len(old_text)} chars with {len(new_text)} chars" - ) + + return await asyncio.to_thread(_edit) except Exception as e: return f"[Error] {e}" diff --git a/backend/tests/test_hang_avoidance.py b/backend/tests/test_hang_avoidance.py new file mode 100644 index 00000000..3fd5c7bd --- /dev/null +++ b/backend/tests/test_hang_avoidance.py @@ -0,0 +1,29 @@ +"""Hang-avoidance: image HTTP timeout + emit_run_event off the event loop.""" + +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] + + +def test_image_services_set_aiohttp_timeout(): + for rel in ( + "backend/services/image/openai.py", + "backend/services/image/local.py", + ): + src = (ROOT / rel).read_text(encoding="utf-8") + assert "ClientTimeout" in src + assert "total=120" in src + assert "connect=10" in src + + +def test_emit_run_event_uses_to_thread(): + src = (ROOT / "backend" / "agent" / "run_events.py").read_text(encoding="utf-8") + assert "await asyncio.to_thread(_atomic_emit" in src + + +def test_file_write_edit_use_to_thread(): + src = (ROOT / "backend" / "services" / "tools" / "executors.py").read_text( + encoding="utf-8" + ) + assert "await asyncio.to_thread(_write)" in src + assert "await asyncio.to_thread(_edit)" in src diff --git a/backend/tests/test_loop_stop_ux.py b/backend/tests/test_loop_stop_ux.py index e8cf0ac5..4e10c78e 100644 --- a/backend/tests/test_loop_stop_ux.py +++ b/backend/tests/test_loop_stop_ux.py @@ -85,10 +85,29 @@ def test_ws_stop_status_is_user_facing(): def test_chat_page_keeps_stop_partial(): src = (ROOT / "frontend" / "app" / "chat" / "page.tsx").read_text(encoding="utf-8") assert "keepPartialAssistantOnIdle" in src + assert "snapshotStoppedTools" in src + assert "clearDeletedSessionLocalState" in src assert "_${streamStatusDetail}_" not in src assert "mapStreamStatusDetail" in src assert "if (leftover && !wasStopping)" not in src - assert src.count("keepPartialAssistantOnIdle") >= 3 + assert src.count("keepPartialAssistantOnIdle") >= 4 + # sync_response idle must keep the partial, not wipe-then-load + assert "keepPartialAssistantOnIdle" in src.split("payload.agent_running")[1] + + +def test_chat_page_stop_does_not_fake_complete_tools(): + src = (ROOT / "frontend" / "app" / "chat" / "page.tsx").read_text(encoding="utf-8") + assert 'status: \'completed\' as const' not in src + assert "snapshotStoppedTools" in src + panel = (ROOT / "frontend" / "components" / "chat" / "ToolCallPanel.tsx").read_text( + encoding="utf-8" + ) + assert "cancelled" in panel + cleanup = ( + ROOT / "frontend" / "lib" / "sessionLocalCleanup.ts" + ).read_text(encoding="utf-8") + assert "tevarn-chat-draft:" in cleanup + assert "streamSessionApi().clear" in cleanup def test_no_tool_round_no_english_stopped(): diff --git a/backend/tests/test_tool_parallel.py b/backend/tests/test_tool_parallel.py index 390d6a74..fa446646 100644 --- a/backend/tests/test_tool_parallel.py +++ b/backend/tests/test_tool_parallel.py @@ -12,7 +12,11 @@ import pytest -from backend.agent.phases.tool_round import _prefetch_readonly_calls +from backend.agent.phases.tool_round import ( + _prefetch_readonly_calls, + _run_tool_cancellable, + _stop_skips_remaining_tool, +) from backend.tools.base import ToolRiskLevel @@ -271,3 +275,42 @@ async def _flip(): ) for item in out.values() ) + + +def test_stop_skips_prefetched_but_keeps_capped(): + loop = _Loop() + loop._should_stop = True + tc = _Call("pref-1", "file_read") + assert _stop_skips_remaining_tool(loop, tc, {}) + assert not _stop_skips_remaining_tool(loop, tc, {"pref-1": "[Blocked]"}) + loop._should_stop = False + assert not _stop_skips_remaining_tool(loop, tc, {}) + + +@pytest.mark.asyncio +async def test_serial_tool_cancels_on_stop(): + loop = _Loop() + started = asyncio.Event() + + async def slow(): + started.set() + await asyncio.sleep(2) + return "done" + + async def flip(): + await started.wait() + loop._should_stop = True + + asyncio.create_task(flip()) + with pytest.raises(asyncio.CancelledError): + await _run_tool_cancellable(loop, slow(), timeout=5) + + +@pytest.mark.asyncio +async def test_serial_tool_returns_when_not_stopped(): + loop = _Loop() + + async def quick(): + return "ok" + + assert await _run_tool_cancellable(loop, quick(), timeout=2) == "ok" diff --git a/backend/tests/test_tool_spawn_env.py b/backend/tests/test_tool_spawn_env.py new file mode 100644 index 00000000..d0d4495b --- /dev/null +++ b/backend/tests/test_tool_spawn_env.py @@ -0,0 +1,54 @@ +"""Local python/command spawn strips product secrets, keeps PATH/HOME/user env.""" + +from __future__ import annotations + +from backend.core.host_commands import is_control_plane_env_key, tool_spawn_env + + +def test_control_plane_keys_are_recognized(): + assert is_control_plane_env_key("TEVARN_JWT_SECRET") + assert is_control_plane_env_key("TEVARN_API_KEY") + assert is_control_plane_env_key("TEVARN_DEFAULT_ADMIN_PASSWORD") + assert is_control_plane_env_key("TEVARN_DESKTOP_PERMISSION_SECRET") + assert is_control_plane_env_key("TEVARN_SETTINGS_ENCRYPTION_SALT") + assert is_control_plane_env_key("TEVARN_KERNEL_RPC_SECRET") + assert not is_control_plane_env_key("PATH") + assert not is_control_plane_env_key("HOME") + assert not is_control_plane_env_key("OPENAI_API_KEY") + assert not is_control_plane_env_key("TEVARN_HOME") + + +def test_tool_spawn_env_strips_secrets_keeps_user_env(monkeypatch): + monkeypatch.setenv("TEVARN_JWT_SECRET", "jwt-must-not-leak") + monkeypatch.setenv("TEVARN_API_KEY", "api-must-not-leak") + monkeypatch.setenv("TEVARN_DEFAULT_ADMIN_PASSWORD", "admin-must-not-leak") + monkeypatch.setenv("PATH", "/usr/bin:/bin") + monkeypatch.setenv("HOME", "/home/owner") + monkeypatch.setenv("OPENAI_API_KEY", "sk-user-intended") + monkeypatch.setenv("MY_CUSTOM_FLAG", "keep-me") + + env = tool_spawn_env() + assert "TEVARN_JWT_SECRET" not in env + assert "TEVARN_API_KEY" not in env + assert "TEVARN_DEFAULT_ADMIN_PASSWORD" not in env + assert env.get("PATH") + assert env.get("HOME") == "/home/owner" + assert env.get("OPENAI_API_KEY") == "sk-user-intended" + assert env.get("MY_CUSTOM_FLAG") == "keep-me" + + +def test_tool_spawn_env_rejects_secret_in_extra(monkeypatch): + monkeypatch.setenv("PATH", "/usr/bin") + env = tool_spawn_env({"TEVARN_JWT_SECRET": "injected", "FOO": "bar"}) + assert "TEVARN_JWT_SECRET" not in env + assert env["FOO"] == "bar" + + +def test_executors_local_spawn_pass_curated_env(): + from pathlib import Path + + src = Path(__file__).resolve().parents[1] / "services" / "tools" / "executors.py" + text = src.read_text(encoding="utf-8") + assert "tool_spawn_env" in text + assert "create_process_exec" in text + assert "env=tool_spawn_env()" in text or "env=_child_env" in text diff --git a/frontend/app/chat/page.tsx b/frontend/app/chat/page.tsx index d197f8be..f11adc7b 100644 --- a/frontend/app/chat/page.tsx +++ b/frontend/app/chat/page.tsx @@ -28,6 +28,7 @@ import { ContactSessionPicker } from '@/components/chat/ContactSessionPicker'; import { useToastStore } from '@/stores/toastStore'; import { useT } from '@/stores/localeStore'; import { streamSessionApi } from '@/stores/streamSessionStore'; +import { clearDeletedSessionLocalState } from '@/lib/sessionLocalCleanup'; import { openSessionTabChannel } from '@/lib/sessionTabChannel'; import { useChatInspectorStore, type ChatInspectorTab } from '@/stores/chatInspectorStore'; import { Eye, FolderOpen, ListTodo, ScanSearch, Terminal } from 'lucide-react'; @@ -86,14 +87,41 @@ function mapStreamStatusDetail( return raw; } +function snapshotStoppedTools(tools: ToolCallData[]): ToolCallData[] { + return tools.map((t) => { + if (t.status === 'failed' || t.status === 'completed') return t; + if (t.status === 'cancelled') { + return { + ...t, + result: t.result || '[Cancelled] stopped by user', + }; + } + return { + ...t, + status: 'cancelled' as const, + result: t.result || '[Cancelled] stopped by user', + }; + }); +} + function keepPartialAssistantOnIdle( sid: string, leftover: string, loadMessages: (id: string) => Promise, addMessage: (m: Message) => void, + leftoverTools: ToolCallData[] = [], ) { const finish = () => { - if (!leftover.trim()) return; + const tools = leftoverTools.length + ? leftoverTools.map((tc) => ({ + id: tc.id, + name: tc.name, + arguments: tc.arguments, + result: tc.result, + status: tc.status, + })) + : []; + if (!leftover.trim() && tools.length === 0) return; const msgs = useSessionStore.getState().messages || []; const lastA = [...msgs].reverse().find( (m) => @@ -102,17 +130,25 @@ function keepPartialAssistantOnIdle( !String(m.id || '').startsWith('optimistic:'), ); const head = leftover.trim().slice(0, 80); - const have = lastA && String(lastA.content || '').includes(head); - if (!have) { + const haveText = leftover.trim() + ? lastA && String(lastA.content || '').includes(head) + : Boolean(lastA); + if (!haveText) { addMessage({ id: generateUUID(), session_id: sid, role: 'assistant', content: leftover, - tool_calls: null, + tool_calls: tools.length ? (tools as Message['tool_calls']) : null, token_count: null, created_at: new Date().toISOString(), }); + return; + } + if (tools.length && lastA && !lastA.tool_calls?.length) { + useSessionStore.getState().updateMessage(lastA.id, { + tool_calls: tools as Message['tool_calls'], + }); } }; if (sid) { @@ -208,6 +244,10 @@ function ChatPageInner() { streamingContentRef.current = streamingContent; }, [streamingContent]); const [liveToolCalls, setLiveToolCalls] = useState([]); + const liveToolCallsRef = React.useRef([]); + React.useEffect(() => { + liveToolCallsRef.current = liveToolCalls; + }, [liveToolCalls]); const [streamStatusDetail, setStreamStatusDetail] = useState(null); const termHasEntries = useTerminalStore((s) => s.entries.length > 0); @@ -499,12 +539,19 @@ function ChatPageInner() { React.useEffect(() => { const onInvalid = (e: Event) => { const id = (e as CustomEvent).detail?.sessionId as string | undefined; + if (id) { + clearDeletedSessionLocalState(id); + setStoppingSid(id, false); + } const cur = useSessionStore.getState().currentSession?.id; if (id && cur === id) { useSessionStore.getState().setCurrentSession(null); useSessionStore.getState().clearMessages(); setIsStreaming(false); setStreamStatusDetail(null); + setLiveToolCalls([]); + streamingContentRef.current = ''; + setStreamingContent(''); } }; window.addEventListener('tevarn:session-invalid', onInvalid); @@ -520,7 +567,7 @@ function ChatPageInner() { }) .catch(() => undefined); return () => window.removeEventListener('tevarn:session-invalid', onInvalid); - }, []); + }, [setStoppingSid]); const handleStreamDelta = useCallback((msg: StreamDeltaMessage) => { const sid = currentSession?.id || ''; @@ -827,28 +874,22 @@ function ChatPageInner() { /* already cached on status updates */ } const leftover = streamingContentRef.current; + const leftoverTools = snapshotStoppedTools(liveToolCallsRef.current); streamingContentRef.current = ''; setStreamingContent(''); - // 先把残留 running 标 completed,再清空,避免 idle 瞬间 UI 仍显示「运行中」 - setLiveToolCalls((prev) => - prev.map((t) => - t.status === 'failed' - ? t - : { ...t, status: 'completed' as const }, - ), - ); - // 下一帧清空 live 列表(历史消息已 load) - window.setTimeout(() => setLiveToolCalls([]), 0); + setLiveToolCalls(leftoverTools); if (sid) streamSessionApi().markIdle(sid); // Stop keeps a local partial if history has not landed yet (ChatGPT/Cursor). - if (leftover || sid) { + if (leftover || leftoverTools.length || sid) { setTimeout(() => { keepPartialAssistantOnIdle( sid, leftover || '', loadMessages, addMessage, + leftoverTools, ); + setLiveToolCalls([]); }, 0); } } @@ -1042,15 +1083,43 @@ function ChatPageInner() { }); } } else { + if (sid) setStoppingSid(sid, false); setIsStreaming(false); setStreamStatusDetail(null); + const leftover = streamingContentRef.current || payload.partial_content || ''; + const leftoverTools = snapshotStoppedTools( + liveToolCallsRef.current.length + ? liveToolCallsRef.current + : (payload.live_tools || []).map((t) => ({ + id: String(t.id || ''), + name: String(t.name || 'tool'), + arguments: (t.arguments && typeof t.arguments === 'object' + ? t.arguments + : {}) as Record, + status: (t.status === 'failed' + ? 'failed' + : t.status === 'running' + ? 'running' + : t.status === 'cancelled' + ? 'cancelled' + : 'completed') as ToolCallData['status'], + result: t.result ?? undefined, + })), + ); streamingContentRef.current = ''; setStreamingContent(''); - setLiveToolCalls([]); + setLiveToolCalls(leftoverTools); if (sid) { streamSessionApi().markIdle(sid); - loadMessages(sid).catch(console.error); + keepPartialAssistantOnIdle( + sid, + leftover, + loadMessages, + addMessage, + leftoverTools, + ); } + window.setTimeout(() => setLiveToolCalls([]), 0); } if (payload.messages?.length && sid) { for (const m of payload.messages) { @@ -1066,7 +1135,7 @@ function ChatPageInner() { }); } } - }, [reconcileMessage, currentSession?.id, loadMessages]); + }, [reconcileMessage, currentSession?.id, loadMessages, addMessage, setStoppingSid]); const handleUserMessageAck = useCallback( (payload: { @@ -1376,8 +1445,7 @@ const handleUserMessageAck = useCallback( subAgentIds?: string[], control?: 'steer' | 'queue' | 'interrupt' ): Promise => { - const stopCheckSid = currentSession?.id || ''; - if (sendInFlightRef.current || isStoppingSid(stopCheckSid)) return false; + if (sendInFlightRef.current) return false; sendInFlightRef.current = true; // D10 专业模式:强制项目文件夹 @@ -1529,7 +1597,6 @@ const handleUserMessageAck = useCallback( createAndLoadSession, waitForConnection, t, - isStoppingSid, setStoppingSid, kickedByPeer, ] @@ -1673,16 +1740,23 @@ const handleUserMessageAck = useCallback( if (!ok) { // 未连上:本地直接收束,保留已流出正文 const leftover = streamingContentRef.current || ''; + const leftoverTools = snapshotStoppedTools(liveToolCallsRef.current); setStoppingSid(sid, false); if (useSessionStore.getState().currentSession?.id === sid) { setIsStreaming(false); setStreamStatusDetail(null); - setLiveToolCalls([]); + setLiveToolCalls(leftoverTools); streamingContentRef.current = ''; setStreamingContent(''); } streamSessionApi().markIdle(sid); - keepPartialAssistantOnIdle(sid, leftover, loadMessages, addMessage); + keepPartialAssistantOnIdle( + sid, + leftover, + loadMessages, + addMessage, + leftoverTools, + ); return; } // 兜底:8s 仍无 idle 则强制收束——仅影响发起 stop 的 sid,且仅当仍在看该会话时改 UI @@ -1690,16 +1764,23 @@ const handleUserMessageAck = useCallback( if (!isStoppingSid(sid)) return; locallyStoppedRef.current.add(sid); const leftover = streamingContentRef.current || ''; + const leftoverTools = snapshotStoppedTools(liveToolCallsRef.current); setStoppingSid(sid, false); streamSessionApi().markIdle(sid); if (useSessionStore.getState().currentSession?.id === sid) { setIsStreaming(false); setStreamStatusDetail(null); - setLiveToolCalls([]); + setLiveToolCalls(leftoverTools); streamingContentRef.current = ''; setStreamingContent(''); } - keepPartialAssistantOnIdle(sid, leftover, loadMessages, addMessage); + keepPartialAssistantOnIdle( + sid, + leftover, + loadMessages, + addMessage, + leftoverTools, + ); }, 8000); }, [sendStop, currentSession, loadMessages, addMessage, t, setStoppingSid, isStoppingSid]); diff --git a/frontend/components/chat/ActivityPanel.tsx b/frontend/components/chat/ActivityPanel.tsx index 68348b8c..e120b750 100644 --- a/frontend/components/chat/ActivityPanel.tsx +++ b/frontend/components/chat/ActivityPanel.tsx @@ -14,6 +14,7 @@ const STATUS_COLOR: Record = { running: 'text-brand-cyan', completed: 'text-status-online', failed: 'text-status-offline', + cancelled: 'text-foreground-dim', }; export function ActivityPanel({ diff --git a/frontend/components/chat/ComposerContextStrip.tsx b/frontend/components/chat/ComposerContextStrip.tsx index 2b6ed7a3..03e6e632 100644 --- a/frontend/components/chat/ComposerContextStrip.tsx +++ b/frontend/components/chat/ComposerContextStrip.tsx @@ -16,6 +16,7 @@ const STATUS_COLOR: Record = { running: 'text-brand-cyan', completed: 'text-status-online', failed: 'text-status-offline', + cancelled: 'text-foreground-dim', }; export function ComposerContextStrip({ diff --git a/frontend/components/chat/ContactSessionPicker.tsx b/frontend/components/chat/ContactSessionPicker.tsx index b56a0229..cf7afde1 100644 --- a/frontend/components/chat/ContactSessionPicker.tsx +++ b/frontend/components/chat/ContactSessionPicker.tsx @@ -188,6 +188,8 @@ export function ContactSessionPicker({ try { // 用户显式删除:force 放行活跃/联系人保护 await api.deleteSession(sessionId, true); + const { clearDeletedSessionLocalState } = await import('@/lib/sessionLocalCleanup'); + clearDeletedSessionLocalState(sessionId); // 清理本地标题 / 星标 / lastSessionByContact const st = useSessionStore.getState(); diff --git a/frontend/components/chat/MessageBubble.tsx b/frontend/components/chat/MessageBubble.tsx index 3d7551b4..b1d28c81 100644 --- a/frontend/components/chat/MessageBubble.tsx +++ b/frontend/components/chat/MessageBubble.tsx @@ -134,11 +134,15 @@ function MessageBubbleInner({ const status: ToolCallData['status'] = dtc.status === 'failed' ? 'failed' - : dtc.status === 'completed' || hasResult - ? 'completed' - : streaming - ? 'running' - : 'completed'; + : dtc.status === 'cancelled' + ? 'cancelled' + : dtc.status === 'completed' || (hasResult && !String(dtc.result || '').startsWith('[Cancelled]')) + ? 'completed' + : String(dtc.result || '').startsWith('[Cancelled]') + ? 'cancelled' + : streaming + ? 'running' + : 'cancelled'; return { id: dtc.id, name: dtc.name, diff --git a/frontend/components/chat/MessageInput.tsx b/frontend/components/chat/MessageInput.tsx index 9953c681..72ea3ddd 100644 --- a/frontend/components/chat/MessageInput.tsx +++ b/frontend/components/chat/MessageInput.tsx @@ -29,6 +29,7 @@ import { useT } from '@/stores/localeStore'; import { useToastStore } from '@/stores/toastStore'; import type { SubAgent } from '@/types/subagent'; import type { Device } from '@/types'; +import { chatDraftKey } from '@/lib/sessionLocalCleanup'; export interface Attachment { filename: string; @@ -143,9 +144,9 @@ export const MessageInput = forwardRef(fu const isEditing = !!initialContent; const inputLocked = disabled || uploading; const clusterOn = activeModes.has('cluster'); - // audit-fix: 草稿按会话隔离,避免切会话后草稿串台; + // 草稿按会话隔离,避免切会话后草稿串台; // 旧全局 key 'tevarn-chat-draft' 不再读取(按清单约定不迁移) - const draftKey = `tevarn-chat-draft:${sessionId || 'default'}`; + const draftKey = chatDraftKey(sessionId); useEffect(() => { if (isEditing) return; diff --git a/frontend/components/chat/ToolCallPanel.tsx b/frontend/components/chat/ToolCallPanel.tsx index b894473d..337b0a21 100644 --- a/frontend/components/chat/ToolCallPanel.tsx +++ b/frontend/components/chat/ToolCallPanel.tsx @@ -12,7 +12,7 @@ export interface ToolCallData { arguments: Record; result?: string; duration_ms?: number; - status?: 'running' | 'completed' | 'failed'; + status?: 'running' | 'completed' | 'failed' | 'cancelled'; } interface ToolCallPanelProps { @@ -25,13 +25,16 @@ interface ToolCallPanelProps { export function resolveToolCallStatus( tc: Pick, pending: boolean, -): 'running' | 'completed' | 'failed' { +): 'running' | 'completed' | 'failed' | 'cancelled' { if (tc.status === 'failed') return 'failed'; - // 有结果 → 完成 - if (tc.result !== undefined && tc.result !== null) return 'completed'; + if (tc.status === 'cancelled') return 'cancelled'; + if (tc.result !== undefined && tc.result !== null) { + if (String(tc.result).startsWith('[Cancelled]')) return 'cancelled'; + return 'completed'; + } if (tc.status === 'completed') return 'completed'; - // 对话已结束:禁止「运行中」卡死(结果往往在独立 tool 消息里) - if (!pending) return 'completed'; + // 对话已结束且无结果:interrupted, not a fake success + if (!pending) return tc.status === 'running' ? 'cancelled' : 'completed'; return 'running'; } @@ -47,6 +50,9 @@ export function ToolCallPanel({ toolCalls, pending = false }: ToolCallPanelProps const failedCount = toolCalls.filter( (tc) => resolveToolCallStatus(tc, pending) === 'failed', ).length; + const cancelledCount = toolCalls.filter( + (tc) => resolveToolCallStatus(tc, pending) === 'cancelled', + ).length; const [open, setOpen] = useState(false); // 有工具开始运行时自动展开一次;结束后保持用户折叠选择 @@ -61,7 +67,9 @@ export function ToolCallPanel({ toolCalls, pending = false }: ToolCallPanelProps ? ` · ${runningCount} 运行中` : failedCount > 0 ? ` · ${failedCount} 失败` - : ` · 已完成`; + : cancelledCount > 0 + ? ` · ${cancelledCount} 已取消` + : ` · 已完成`; return (
@@ -120,6 +128,7 @@ function TraceStep({ const summary = useMemo(() => { if (hasResult) return summarizeToolResult(toolCall.result, toolCall.name); if (status === 'running') return '执行中…'; + if (status === 'cancelled') return '已取消'; if (status === 'completed' && !hasResult) return '已完成'; if (hasArgs) { const keys = Object.keys(toolCall.arguments); @@ -140,6 +149,8 @@ function TraceStep({ ) : status === 'failed' ? ( + ) : status === 'cancelled' ? ( + ) : ( ); diff --git a/frontend/hooks/useColResize.ts b/frontend/hooks/useColResize.ts index d1749dfc..7fb6be4c 100644 --- a/frontend/hooks/useColResize.ts +++ b/frontend/hooks/useColResize.ts @@ -20,7 +20,9 @@ export function useColResize(opts: { const [width, setWidth] = useState(defaultWidth); const drag = useRef({ startX: 0, startW: defaultWidth, active: false }); const widthRef = useRef(width); - widthRef.current = width; + useEffect(() => { + widthRef.current = width; + }, [width]); const resolveMax = useCallback(() => { const hi = typeof max === 'function' ? max() : max; diff --git a/frontend/hooks/useSession.ts b/frontend/hooks/useSession.ts index 109c5ca0..a45886bf 100644 --- a/frontend/hooks/useSession.ts +++ b/frontend/hooks/useSession.ts @@ -170,6 +170,8 @@ export function useSession() { if (isContactSessionLocal(sessionId)) return false; await api.deleteSession(sessionId); + const { clearDeletedSessionLocalState } = await import('@/lib/sessionLocalCleanup'); + clearDeletedSessionLocalState(sessionId); // 清理本地标题 / 星标 const st = useSessionStore.getState(); const { [sessionId]: _removed, ...restTitles } = st.sessionTitles; diff --git a/frontend/lib/chatDisplay.ts b/frontend/lib/chatDisplay.ts index 485ae2b1..8cf3cb35 100644 --- a/frontend/lib/chatDisplay.ts +++ b/frontend/lib/chatDisplay.ts @@ -6,7 +6,7 @@ import { t } from '@/stores/localeStore'; export interface DisplayToolCall extends ToolCall { result?: string; - status?: 'running' | 'completed' | 'failed'; + status?: 'running' | 'completed' | 'failed' | 'cancelled'; } export interface DisplayMessage extends Message { diff --git a/frontend/lib/sessionLocalCleanup.ts b/frontend/lib/sessionLocalCleanup.ts new file mode 100644 index 00000000..607baaac --- /dev/null +++ b/frontend/lib/sessionLocalCleanup.ts @@ -0,0 +1,22 @@ +import { streamSessionApi } from '@/stores/streamSessionStore'; + +export const CHAT_DRAFT_KEY_PREFIX = 'tevarn-chat-draft:'; + +export function chatDraftKey(sessionId: string | null | undefined): string { + return `${CHAT_DRAFT_KEY_PREFIX}${sessionId || 'default'}`; +} + +/** Drop composer draft + stream cache for a deleted session. Immediate, no confirm. */ +export function clearDeletedSessionLocalState(sessionId: string | null | undefined): void { + if (!sessionId) return; + try { + localStorage.removeItem(chatDraftKey(sessionId)); + } catch { + /* ignore quota / private mode */ + } + try { + streamSessionApi().clear(sessionId); + } catch { + /* ignore */ + } +} diff --git a/frontend/types/index.ts b/frontend/types/index.ts index 3437981a..ed46e204 100644 --- a/frontend/types/index.ts +++ b/frontend/types/index.ts @@ -78,7 +78,7 @@ export interface ToolCall { tool_call_id?: string; /** 展示层合并进来的执行结果 */ result?: string; - status?: 'running' | 'completed' | 'failed'; + status?: 'running' | 'completed' | 'failed' | 'cancelled'; } // ====== Task ====== From 36e93d618cef5fe1a6df28e4e143fe6e7e83a27f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 18 Aug 2026 02:31:11 +0000 Subject: [PATCH 2/3] Fix ruff import order and tighten the sync-idle stop UX assertion. Co-authored-by: wu1w --- backend/services/tools/executors.py | 3 +-- backend/tests/test_loop_stop_ux.py | 7 +++++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/backend/services/tools/executors.py b/backend/services/tools/executors.py index 2f29a3b2..441e692c 100644 --- a/backend/services/tools/executors.py +++ b/backend/services/tools/executors.py @@ -1330,14 +1330,13 @@ async def execute_command(config: dict[str, Any], arguments: dict[str, Any]) -> # Foreground with Grok-style timeout→background (do not kill long work). try: from backend.computer.text_decode import decode_process_bytes + from backend.core.host_commands import tool_spawn_env from backend.core.safe_subprocess import create_process, needs_shell from backend.services.tools.process_registry import ( adopt_running, format_process, ) - from backend.core.host_commands import tool_spawn_env - _child_env = tool_spawn_env() proc = await create_process( command, cwd=cwd if cwd else None, env=_child_env diff --git a/backend/tests/test_loop_stop_ux.py b/backend/tests/test_loop_stop_ux.py index 4e10c78e..66868fc5 100644 --- a/backend/tests/test_loop_stop_ux.py +++ b/backend/tests/test_loop_stop_ux.py @@ -3,7 +3,6 @@ from __future__ import annotations from pathlib import Path -from types import SimpleNamespace from backend.agent.exit_reasons import format_exit_user_message from backend.agent.thinking_format import ensure_user_facing_final @@ -92,7 +91,11 @@ def test_chat_page_keeps_stop_partial(): assert "if (leftover && !wasStopping)" not in src assert src.count("keepPartialAssistantOnIdle") >= 4 # sync_response idle must keep the partial, not wipe-then-load - assert "keepPartialAssistantOnIdle" in src.split("payload.agent_running")[1] + sync_fn = src.split("const handleSyncResponse", 1)[1].split( + "const handleUserMessageAck", 1 + )[0] + assert "keepPartialAssistantOnIdle" in sync_fn + assert "snapshotStoppedTools" in sync_fn def test_chat_page_stop_does_not_fake_complete_tools(): From cf50470dd0329d1f25d8a115e65732ae6fe15a68 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 18 Aug 2026 02:34:08 +0000 Subject: [PATCH 3/3] Fix typecheck: unshadow chat i18n t, wire CronWebhookPanel useT. The useColResize lint fix let tsc run. Chat page shadowed useT() with a setTimeout id (Number not callable). CronWebhookPanel already called t() without importing useT. Co-authored-by: wu1w --- frontend/app/chat/page.tsx | 4 ++-- frontend/components/cron-webhook/CronWebhookPanel.tsx | 3 +++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/frontend/app/chat/page.tsx b/frontend/app/chat/page.tsx index f11adc7b..5e98bd5b 100644 --- a/frontend/app/chat/page.tsx +++ b/frontend/app/chat/page.tsx @@ -1294,10 +1294,10 @@ const handleUserMessageAck = useCallback( window.setTimeout(trySync, 100); } }; - const t = window.setTimeout(trySync, 50); + const syncTimer = window.setTimeout(trySync, 50); return () => { cancelled = true; - window.clearTimeout(t); + window.clearTimeout(syncTimer); useChatWsBridge.getState().setHandlers(null); }; }, [ diff --git a/frontend/components/cron-webhook/CronWebhookPanel.tsx b/frontend/components/cron-webhook/CronWebhookPanel.tsx index 8fded8f6..20b76f9a 100644 --- a/frontend/components/cron-webhook/CronWebhookPanel.tsx +++ b/frontend/components/cron-webhook/CronWebhookPanel.tsx @@ -37,6 +37,7 @@ import type { } from '@/types/zero-code'; import type { CronJob } from '@/types'; import { useConfirm } from '@/components/desktop/ConfirmDialog'; +import { useT } from '@/stores/localeStore'; // ────────────────── 子组件:Webhook 表单对话框 ────────────────── @@ -63,6 +64,7 @@ function WebhookFormDialog({ const [newEvent, setNewEvent] = useState(''); const [loading, setLoading] = useState(false); const [error, setError] = useState(''); + const t = useT(); const addEvent = () => { if (newEvent && !form.events?.includes(newEvent)) { @@ -195,6 +197,7 @@ function HookFormDialog({ onSaved: () => void; }) { const isEdit = !!initial; + const t = useT(); const [form, setForm] = useState({ name: initial?.name || '', cron_job_id: cronJobId,