From b35c3ce59ab3b321b37dfb6419ab8bab942cda3b Mon Sep 17 00:00:00 2001 From: JayLin Date: Fri, 3 Jul 2026 01:20:32 +0800 Subject: [PATCH] fix: keep web composer and history visible --- CHANGELOG.md | 10 +++- README.md | 5 +- docs/learning/m8-web-console.md | 4 +- pyproject.toml | 2 +- src/mini_code_agent/web/app.py | 37 +++++++++++-- src/mini_code_agent/web/manager.py | 42 ++++++++++++++- src/mini_code_agent/web/models.py | 6 +++ src/mini_code_agent/web/static/app.js | 64 ++++++++++++++++++++--- src/mini_code_agent/web/static/styles.css | 6 +++ tests/cli/test_cli.py | 4 +- tests/integration/test_agent_loop.py | 2 +- tests/unit/test_package.py | 2 +- tests/unit/tools/test_runtime_info.py | 2 +- tests/unit/web/test_app.py | 49 +++++++++++++++++ tests/unit/web/test_manager.py | 47 +++++++++++++++++ tests/unit/web/test_static.py | 24 +++++++++ uv.lock | 2 +- 17 files changed, 286 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b8ea0dc..0e91d98 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ All notable changes follow Keep a Changelog. Versions follow Semantic Versioning ## [Unreleased] +### Fixed + +- Keep the Web composer pinned inside the viewport while long Agent answers scroll within the + transcript pane. +- Restore the latest 20 in-process run transcripts after browser refresh without adding prompts + to lifecycle events, and reconnect SSE from the latest observed sequence to avoid duplicates. +- Ensure HTML `hidden` states cannot be overridden by component display styles. + ### Added - Loopback-only `mini-code-agent web` command with a responsive three-pane local workbench, @@ -34,7 +42,7 @@ All notable changes follow Keep a Changelog. Versions follow Semantic Versioning ### Verification -- M8 local Windows verification passed 1218 tests with 13 privilege/platform skips and 88.52% +- M8 local Windows verification passed 1224 tests with 13 privilege/platform skips and 88.50% branch-aware package coverage. Ruff format/check, strict Pyright, and browser layout checks at 1440x1024, 1024x768, and 390x844 passed. - Local uv-managed Python 3.13.14 passed 1201 tests with 13 Windows privilege/platform skips and diff --git a/README.md b/README.md index aeb48db..c6305eb 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,10 @@ fixed when the process starts; it cannot be changed from the browser. The Web console shows Agent lifecycle events, Tool activity, token usage, bounded action previews, and file diffs. Write and command actions pause until they are approved or rejected in the inspector. API keys stay in server-side settings; the browser receives only a configured/not -configured flag and a process-random request token. +configured flag and a process-random request token. The most recent 20 runs are retained in +bounded process memory so refreshing the browser restores the current transcript. Restarting the +server clears this display history, and restored runs are not automatically added to later model +context. Each `chat` prompt starts an independent bounded Agent run against the same workspace; durable conversation memory is not implied. Read-only tools run automatically. File writes and local argv diff --git a/docs/learning/m8-web-console.md b/docs/learning/m8-web-console.md index 868caa3..88485c5 100644 --- a/docs/learning/m8-web-console.md +++ b/docs/learning/m8-web-console.md @@ -38,6 +38,7 @@ M8 增加一个本地 Web 适配层,不重写 Agent Runtime: `WebRunManager` 是 Web 层的运行状态机: - 一次只允许一个活跃任务,避免多个浏览器操作争用同一工作区; +- 在有界内存中保留最近 20 次运行,浏览器刷新后恢复当前 transcript; - 使用递增 `sequence` 给事件排序,并在有界 `deque` 中保留最近事件; - 浏览器断线后通过 `after` 序号重放事件; - 完成、失败和取消都产生明确终态; @@ -108,7 +109,8 @@ Python 的关键差异是:同一事件循环中的共享状态通常不需要 ## 6. 当前边界 - 仅支持本地单用户、单工作区、单活跃任务; -- 运行和会话只保存在内存中,进程退出后不恢复 Web 状态; +- 最近 20 次运行只保存在内存中;刷新页面可以恢复,进程退出后不恢复 Web 状态; +- 恢复的是界面历史,不会把旧问答自动作为下一次模型调用的上下文; - 最终回答不是逐 Token 流式输出; - 当前未把 Skills、MCP、Subagent、Worktree candidate 组合进 Web composition root; - 图像生成 API 尚未接入,后续应作为受治理 Tool,而不是让浏览器直接持有 Key; diff --git a/pyproject.toml b/pyproject.toml index 0df983f..e278b18 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "mini-code-agent" -version = "0.18.0a0" +version = "0.18.0a1" description = "A framework-light, provider-neutral, enterprise-grade mini code agent." readme = "README.md" requires-python = ">=3.12,<3.14" diff --git a/src/mini_code_agent/web/app.py b/src/mini_code_agent/web/app.py index 5f5fc2f..78d526d 100644 --- a/src/mini_code_agent/web/app.py +++ b/src/mini_code_agent/web/app.py @@ -24,6 +24,7 @@ ) from mini_code_agent.web.models import ( ApprovalDecisionRequest, + RunDetail, RunSnapshot, StartRunRequest, ) @@ -129,6 +130,7 @@ async def bootstrap() -> dict[str, object]: else settings.anthropic_api_key is not None ) active = run_manager.active_snapshot() + latest = run_manager.latest_snapshot() return { "version": __version__, "workspace": str(workspace_root), @@ -137,8 +139,21 @@ async def bootstrap() -> dict[str, object]: "api_key_configured": key_configured, "csrf_token": token, "active_run": active.model_dump(mode="json") if active else None, + "latest_run": latest.model_dump(mode="json") if latest else None, } + async def run_detail(run_id: str) -> RunDetail: + try: + return run_manager.detail(run_id) + except RunNotFoundError: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Run not found.", + ) from None + + async def run_history() -> list[RunDetail]: + return list(run_manager.details()) + async def start_run(payload: StartRunRequest) -> RunSnapshot: try: return await run_manager.start(payload.prompt) @@ -203,7 +218,7 @@ async def cancel_run(run_id: str) -> dict[str, bool]: ) return {"cancelled": True} - mutation_dependencies = [Depends(require_token)] + protected_dependencies = [Depends(require_token)] app.add_api_route("/healthz", health, methods=["GET"]) app.add_api_route("/", index, methods=["GET"], response_class=HTMLResponse) app.add_api_route("/static/styles.css", styles, methods=["GET"]) @@ -215,7 +230,21 @@ async def cancel_run(run_id: str) -> dict[str, bool]: methods=["POST"], response_model=RunSnapshot, status_code=status.HTTP_202_ACCEPTED, - dependencies=mutation_dependencies, + dependencies=protected_dependencies, + ) + app.add_api_route( + "/api/runs", + run_history, + methods=["GET"], + response_model=list[RunDetail], + dependencies=protected_dependencies, + ) + app.add_api_route( + "/api/runs/{run_id}", + run_detail, + methods=["GET"], + response_model=RunDetail, + dependencies=protected_dependencies, ) app.add_api_route( "/api/runs/{run_id}/events", @@ -226,13 +255,13 @@ async def cancel_run(run_id: str) -> dict[str, bool]: "/api/runs/{run_id}/approvals/{tool_call_id}", decide_approval, methods=["POST"], - dependencies=mutation_dependencies, + dependencies=protected_dependencies, ) app.add_api_route( "/api/runs/{run_id}/cancel", cancel_run, methods=["POST"], - dependencies=mutation_dependencies, + dependencies=protected_dependencies, ) return app diff --git a/src/mini_code_agent/web/manager.py b/src/mini_code_agent/web/manager.py index fb2312a..0fb80d9 100644 --- a/src/mini_code_agent/web/manager.py +++ b/src/mini_code_agent/web/manager.py @@ -11,7 +11,7 @@ from mini_code_agent.agent.models import AgentResult from mini_code_agent.policy.approval import ApprovalHandler from mini_code_agent.policy.models import ApprovalRequest -from mini_code_agent.web.models import RunSnapshot, WebEvent, WebRunStatus +from mini_code_agent.web.models import RunDetail, RunSnapshot, WebEvent, WebRunStatus type TaskRunner = Callable[ [str, ApprovalHandler, EventSink], @@ -30,7 +30,10 @@ class RunNotFoundError(KeyError): @dataclass class _RunState: run_id: str + prompt: str status: WebRunStatus = WebRunStatus.RUNNING + final_text: str | None = None + error: str | None = None events: deque[WebEvent] = field(default_factory=lambda: deque[WebEvent]()) pending: dict[str, asyncio.Future[bool]] = field( default_factory=lambda: dict[str, asyncio.Future[bool]]() @@ -66,13 +69,19 @@ def __init__( runner: TaskRunner, *, max_retained_events: int = 512, + max_retained_runs: int = 20, ) -> None: if max_retained_events < 8: raise ValueError("max_retained_events must be at least 8") + if not 1 <= max_retained_runs <= 100: + raise ValueError("max_retained_runs must be between 1 and 100") self._runner = runner self._max_retained_events = max_retained_events + self._max_retained_runs = max_retained_runs self._runs: dict[str, _RunState] = {} + self._run_order: deque[str] = deque() self._active_run_id: str | None = None + self._latest_run_id: str | None = None async def start(self, prompt: str) -> RunSnapshot: if self._active_run_id is not None: @@ -80,13 +89,20 @@ async def start(self, prompt: str) -> RunSnapshot: if active.status is WebRunStatus.RUNNING: raise RunConflictError("A run is already active.") + while len(self._run_order) >= self._max_retained_runs: + expired_run_id = self._run_order.popleft() + self._runs.pop(expired_run_id, None) + run_id = uuid4().hex state = _RunState( run_id=run_id, + prompt=prompt, events=deque(maxlen=self._max_retained_events), ) self._runs[run_id] = state + self._run_order.append(run_id) self._active_run_id = run_id + self._latest_run_id = run_id self._publish(run_id, "web_run_started", {"status": "running"}) state.task = asyncio.create_task( self._execute(run_id, prompt), @@ -108,16 +124,19 @@ async def _execute(self, run_id: str, prompt: str) -> None: self._publish(run_id, "web_run_cancelled", {"status": "cancelled"}) except Exception: state.status = WebRunStatus.FAILED + state.error = "The Agent run failed. Check the local server logs." self._publish( run_id, "web_run_failed", { "status": "failed", - "message": "The Agent run failed. Check the local server logs.", + "message": state.error, }, ) else: state.status = WebRunStatus.COMPLETED + state.final_text = result.final_text + state.error = result.error self._publish( run_id, "web_run_completed", @@ -220,6 +239,25 @@ def active_snapshot(self) -> RunSnapshot | None: return None return self.snapshot(self._active_run_id) + def latest_snapshot(self) -> RunSnapshot | None: + if self._latest_run_id is None: + return None + return self.snapshot(self._latest_run_id) + + def detail(self, run_id: str) -> RunDetail: + state = self._state(run_id) + return RunDetail( + run_id=state.run_id, + status=state.status, + last_sequence=state.next_sequence - 1, + prompt=state.prompt, + final_text=state.final_text, + error=state.error, + ) + + def details(self) -> tuple[RunDetail, ...]: + return tuple(self.detail(run_id) for run_id in self._run_order) + def events_after( self, run_id: str, diff --git a/src/mini_code_agent/web/models.py b/src/mini_code_agent/web/models.py index 827595f..bf5201c 100644 --- a/src/mini_code_agent/web/models.py +++ b/src/mini_code_agent/web/models.py @@ -37,3 +37,9 @@ class RunSnapshot(WebModel): run_id: str = Field(pattern=_IDENTIFIER_PATTERN) status: WebRunStatus last_sequence: int = Field(default=0, ge=0) + + +class RunDetail(RunSnapshot): + prompt: str = Field(min_length=1, max_length=20_000) + final_text: str | None = Field(default=None, max_length=2_000_000) + error: str | None = Field(default=None, max_length=500) diff --git a/src/mini_code_agent/web/static/app.js b/src/mini_code_agent/web/static/app.js index ed782a1..941cf8b 100644 --- a/src/mini_code_agent/web/static/app.js +++ b/src/mini_code_agent/web/static/app.js @@ -5,6 +5,7 @@ const state = { runId: null, lastSequence: 0, eventSource: null, + reconnectTimer: null, pendingApproval: null, activityCount: 0, }; @@ -195,6 +196,10 @@ function describeAgentEvent(event) { } function stopEventStream() { + if (state.reconnectTimer !== null) { + window.clearTimeout(state.reconnectTimer); + state.reconnectTimer = null; + } if (state.eventSource !== null) { state.eventSource.close(); state.eventSource = null; @@ -279,8 +284,16 @@ function connectEvents(runId) { }); } source.onerror = () => { - if (state.runId !== null) { + if (state.runId === runId && state.eventSource === source) { + source.close(); + state.eventSource = null; addActivity("连接正在恢复", "等待本地事件流重新连接", "pending"); + state.reconnectTimer = window.setTimeout(() => { + state.reconnectTimer = null; + if (state.runId === runId) { + connectEvents(runId); + } + }, 1000); } }; } @@ -290,7 +303,7 @@ async function api(path, options = {}) { if (options.body !== undefined) { headers.set("Content-Type", "application/json"); } - if (options.method && options.method !== "GET") { + if (state.bootstrap?.csrf_token) { headers.set("X-Mini-Code-Agent-Token", state.bootstrap.csrf_token); } const response = await fetch(path, { ...options, headers }); @@ -366,6 +379,47 @@ async function cancelRun() { } } +async function restoreHistory(activeRun) { + try { + const history = await api("/api/runs"); + for (const detail of history) { + addMessage("user", detail.prompt); + if (detail.final_text || detail.error) { + addMessage( + "assistant", + detail.final_text || detail.error, + Boolean(detail.error), + ); + } + } + + const latest = history.at(-1); + const activeDetail = activeRun + ? history.find((detail) => detail.run_id === activeRun.run_id) + : null; + if (activeDetail?.status === "running") { + state.runId = activeDetail.run_id; + state.lastSequence = 0; + setRunState("running", "运行中"); + connectEvents(activeDetail.run_id); + } else if (latest) { + if (latest.status === "cancelled") { + setRunState("idle", "已停止"); + } else { + const failed = latest.status === "failed" || Boolean(latest.error); + setRunState( + failed ? "failed" : "completed", + failed ? "失败" : "已完成", + ); + } + } + } catch (error) { + state.runId = null; + setRunState("failed", "恢复失败"); + showToast(error.message); + } +} + async function bootstrap() { try { const response = await fetch("/api/bootstrap", { cache: "no-store" }); @@ -394,10 +448,8 @@ async function bootstrap() { if (!state.bootstrap.api_key_configured || !state.bootstrap.model) { showToast("请在启动服务前配置模型和服务端环境变量。"); } - if (state.bootstrap.active_run) { - state.runId = state.bootstrap.active_run.run_id; - setRunState("running", "运行中"); - connectEvents(state.runId); + if (state.bootstrap.latest_run) { + await restoreHistory(state.bootstrap.active_run); } } catch (error) { elements.providerStatus.classList.add("error"); diff --git a/src/mini_code_agent/web/static/styles.css b/src/mini_code_agent/web/static/styles.css index 28caf88..d5e0686 100644 --- a/src/mini_code_agent/web/static/styles.css +++ b/src/mini_code_agent/web/static/styles.css @@ -30,6 +30,10 @@ box-sizing: border-box; } +[hidden] { + display: none !important; +} + html, body { width: 100%; @@ -308,8 +312,10 @@ svg { .conversation { min-width: 0; + min-height: 0; display: grid; grid-template-rows: auto minmax(0, 1fr) auto; + overflow: hidden; background: var(--surface); } diff --git a/tests/cli/test_cli.py b/tests/cli/test_cli.py index ff1c63d..d41c720 100644 --- a/tests/cli/test_cli.py +++ b/tests/cli/test_cli.py @@ -52,7 +52,7 @@ def test_version_option_prints_package_version() -> None: result = runner.invoke(app, ["--version"]) assert result.exit_code == 0 - assert result.stdout.strip() == "0.18.0a0" + assert result.stdout.strip() == "0.18.0a1" def test_module_entrypoint_prints_package_version() -> None: @@ -64,7 +64,7 @@ def test_module_entrypoint_prints_package_version() -> None: ) assert result.returncode == 0 - assert result.stdout.strip() == "0.18.0a0" + assert result.stdout.strip() == "0.18.0a1" def test_doctor_json_never_prints_secrets( diff --git a/tests/integration/test_agent_loop.py b/tests/integration/test_agent_loop.py index 15b818e..fb6a590 100644 --- a/tests/integration/test_agent_loop.py +++ b/tests/integration/test_agent_loop.py @@ -53,7 +53,7 @@ async def test_fake_provider_drives_native_tool_call_round_trip() -> None: assert tool_result_message.role is MessageRole.USER assert tool_result_message.tool_results[0].tool_call_id == "call-1" payload = json.loads(tool_result_message.tool_results[0].content) - assert payload["package_version"] == "0.18.0a0" + assert payload["package_version"] == "0.18.0a1" assert [type(event) for event in events.events] == [ RunStarted, ModelStarted, diff --git a/tests/unit/test_package.py b/tests/unit/test_package.py index 9a64612..b91b8b3 100644 --- a/tests/unit/test_package.py +++ b/tests/unit/test_package.py @@ -4,7 +4,7 @@ def test_package_exports_release_version() -> None: - assert __version__ == "0.18.0a0" + assert __version__ == "0.18.0a1" def test_package_includes_pep561_marker() -> None: diff --git a/tests/unit/tools/test_runtime_info.py b/tests/unit/tools/test_runtime_info.py index aee4bf3..d3e5415 100644 --- a/tests/unit/tools/test_runtime_info.py +++ b/tests/unit/tools/test_runtime_info.py @@ -35,7 +35,7 @@ async def test_runtime_info_returns_safe_structured_data() -> None: payload = json.loads(result.content) assert result.tool_call_id == "call-1" assert result.is_error is False - assert payload["package_version"] == "0.18.0a0" + assert payload["package_version"] == "0.18.0a1" assert payload["python_version"] assert payload["platform"] diff --git a/tests/unit/web/test_app.py b/tests/unit/web/test_app.py index 10a3001..222ef4c 100644 --- a/tests/unit/web/test_app.py +++ b/tests/unit/web/test_app.py @@ -199,3 +199,52 @@ async def runner(prompt: str, approval: object, events: object) -> AgentResult: ) assert response.status_code == 409 + + +@pytest.mark.asyncio +async def test_completed_run_can_be_restored_after_browser_refresh( + tmp_path: Path, +) -> None: + async def runner(prompt: str, approval: object, events: object) -> AgentResult: + del approval, events + assert prompt == "Remember this task" + return result() + + manager = WebRunManager(runner) + app = create_web_app( + settings(tmp_path), + workspace=tmp_path, + manager=manager, + csrf_token="fixed-token", + ) + headers = {"X-Mini-Code-Agent-Token": "fixed-token"} + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), + base_url="http://127.0.0.1:8765", + ) as client: + started = await client.post( + "/api/runs", + json={"prompt": "Remember this task"}, + headers=headers, + ) + run_id = started.json()["run_id"] + await manager.wait(run_id) + + bootstrap = await client.get("/api/bootstrap") + unauthenticated = await client.get(f"/api/runs/{run_id}") + restored = await client.get(f"/api/runs/{run_id}", headers=headers) + history = await client.get("/api/runs", headers=headers) + + assert bootstrap.json()["active_run"] is None + assert bootstrap.json()["latest_run"]["run_id"] == run_id + assert unauthenticated.status_code == 403 + assert restored.status_code == 200 + assert restored.json() == { + "run_id": run_id, + "status": "completed", + "last_sequence": 2, + "prompt": "Remember this task", + "final_text": "Finished.", + "error": None, + } + assert history.json() == [restored.json()] diff --git a/tests/unit/web/test_manager.py b/tests/unit/web/test_manager.py index d8273fe..9d9d33d 100644 --- a/tests/unit/web/test_manager.py +++ b/tests/unit/web/test_manager.py @@ -86,6 +86,53 @@ async def runner(prompt: str, approval: object, events: object) -> AgentResult: await manager.wait(second.run_id) +@pytest.mark.asyncio +async def test_manager_retains_latest_run_detail_outside_event_payloads() -> None: + secret_prompt = "Explain the project without exposing this prompt in events." + + async def runner(prompt: str, approval: object, events: object) -> AgentResult: + del approval, events + assert prompt == secret_prompt + return completed_result(final_text="Explanation") + + manager = WebRunManager(runner) + started = await manager.start(secret_prompt) + await manager.wait(started.run_id) + + latest = manager.latest_snapshot() + detail = manager.detail(started.run_id) + serialized_events = json.dumps( + [event.model_dump(mode="json") for event in manager.events_after(started.run_id)] + ) + + assert latest is not None + assert latest.run_id == started.run_id + assert detail.prompt == secret_prompt + assert detail.status is WebRunStatus.COMPLETED + assert detail.final_text == "Explanation" + assert secret_prompt not in serialized_events + + +@pytest.mark.asyncio +async def test_manager_retains_a_bounded_ordered_run_history() -> None: + async def runner(prompt: str, approval: object, events: object) -> AgentResult: + del approval, events + return completed_result(final_text=f"answer:{prompt}") + + manager = WebRunManager(runner, max_retained_runs=2) + for prompt in ("first", "second", "third"): + started = await manager.start(prompt) + await manager.wait(started.run_id) + + details = manager.details() + + assert [detail.prompt for detail in details] == ["second", "third"] + assert [detail.final_text for detail in details] == [ + "answer:second", + "answer:third", + ] + + @pytest.mark.asyncio async def test_approval_is_bounded_single_use_and_can_be_approved() -> None: request = ApprovalRequest( diff --git a/tests/unit/web/test_static.py b/tests/unit/web/test_static.py index b582bdb..6cb2232 100644 --- a/tests/unit/web/test_static.py +++ b/tests/unit/web/test_static.py @@ -51,3 +51,27 @@ def test_styles_define_desktop_and_mobile_workbench_tracks() -> None: assert "--topbar-height" in css assert "grid-template-columns" in css assert "@media (max-width: 720px)" in css + + +def test_conversation_column_cannot_grow_past_the_viewport() -> None: + css = static_text("styles.css") + conversation_block = css.split(".conversation {", maxsplit=1)[1].split("}", maxsplit=1)[0] + + assert "min-height: 0;" in conversation_block + assert "overflow: hidden;" in conversation_block + + +def test_hidden_ui_states_never_participate_in_layout() -> None: + css = static_text("styles.css") + + assert "[hidden]" in css + hidden_block = css.split("[hidden] {", maxsplit=1)[1].split("}", maxsplit=1)[0] + assert "display: none !important;" in hidden_block + + +def test_frontend_restores_latest_run_and_reconnects_from_latest_sequence() -> None: + javascript = static_text("app.js") + + assert "latest_run" in javascript + assert "restoreHistory" in javascript + assert "reconnectTimer" in javascript diff --git a/uv.lock b/uv.lock index 667f2d1..0f36674 100644 --- a/uv.lock +++ b/uv.lock @@ -376,7 +376,7 @@ wheels = [ [[package]] name = "mini-code-agent" -version = "0.18.0a0" +version = "0.18.0a1" source = { editable = "." } dependencies = [ { name = "defusedxml" },