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
10 changes: 9 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion docs/learning/m8-web-console.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ M8 增加一个本地 Web 适配层,不重写 Agent Runtime:
`WebRunManager` 是 Web 层的运行状态机:

- 一次只允许一个活跃任务,避免多个浏览器操作争用同一工作区;
- 在有界内存中保留最近 20 次运行,浏览器刷新后恢复当前 transcript;
- 使用递增 `sequence` 给事件排序,并在有界 `deque` 中保留最近事件;
- 浏览器断线后通过 `after` 序号重放事件;
- 完成、失败和取消都产生明确终态;
Expand Down Expand Up @@ -108,7 +109,8 @@ Python 的关键差异是:同一事件循环中的共享状态通常不需要
## 6. 当前边界

- 仅支持本地单用户、单工作区、单活跃任务;
- 运行和会话只保存在内存中,进程退出后不恢复 Web 状态;
- 最近 20 次运行只保存在内存中;刷新页面可以恢复,进程退出后不恢复 Web 状态;
- 恢复的是界面历史,不会把旧问答自动作为下一次模型调用的上下文;
- 最终回答不是逐 Token 流式输出;
- 当前未把 Skills、MCP、Subagent、Worktree candidate 组合进 Web composition root;
- 图像生成 API 尚未接入,后续应作为受治理 Tool,而不是让浏览器直接持有 Key;
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
37 changes: 33 additions & 4 deletions src/mini_code_agent/web/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
)
from mini_code_agent.web.models import (
ApprovalDecisionRequest,
RunDetail,
RunSnapshot,
StartRunRequest,
)
Expand Down Expand Up @@ -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),
Expand All @@ -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)
Expand Down Expand Up @@ -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"])
Expand All @@ -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",
Expand All @@ -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

Expand Down
42 changes: 40 additions & 2 deletions src/mini_code_agent/web/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand All @@ -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]]()
Expand Down Expand Up @@ -66,27 +69,40 @@ 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:
active = self._runs[self._active_run_id]
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),
Expand All @@ -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",
Expand Down Expand Up @@ -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,
Expand Down
6 changes: 6 additions & 0 deletions src/mini_code_agent/web/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
64 changes: 58 additions & 6 deletions src/mini_code_agent/web/static/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ const state = {
runId: null,
lastSequence: 0,
eventSource: null,
reconnectTimer: null,
pendingApproval: null,
activityCount: 0,
};
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
};
}
Expand All @@ -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 });
Expand Down Expand Up @@ -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" });
Expand Down Expand Up @@ -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");
Expand Down
6 changes: 6 additions & 0 deletions src/mini_code_agent/web/static/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@
box-sizing: border-box;
}

[hidden] {
display: none !important;
}

html,
body {
width: 100%;
Expand Down Expand Up @@ -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);
}

Expand Down
Loading
Loading