Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
ab59020
docs(webui): the engine layer has six files, not five (webui-parity 107)
fengzhi09 Oct 1, 2026
1dc559a
test(webui): M2 capability-declaration snapshot vs the real host (eng…
fengzhi09 Oct 1, 2026
8c085fa
test(webui): point the capability snapshot at the engine layer's real…
fengzhi09 Oct 1, 2026
aa5ab47
fix(webui): stop the shell from carrying one session's state into ano…
fengzhi09 Oct 1, 2026
af01e0e
refactor(webui): the plugins and turn-diff routes take the host from …
fengzhi09 Oct 1, 2026
f1842ba
test(webui): make the run-mirror, first-turn-guard and mavis-usage su…
fengzhi09 Oct 1, 2026
726d286
refactor(webui): the plugins and turn-diff routes take the host from …
fengzhi09 Oct 1, 2026
a4fad96
test(webui): make the run-mirror, first-turn-guard and mavis-usage su…
fengzhi09 Oct 1, 2026
e7c0ce9
feat(webui): the five read endpoints ask the engine facade, not the t…
fengzhi09 Oct 1, 2026
e053ae7
feat(webui): the session-tree and export endpoints ask the engine fac…
fengzhi09 Oct 1, 2026
4fb8267
feat(webui): the usage endpoints ask the engine facade, and the deriv…
fengzhi09 Oct 1, 2026
88b9a48
fix(webui): rebase M3-B3 onto M3-B2, register B2's two tmp prefixes, …
fengzhi09 Oct 1, 2026
6bc24bd
feat(webui): the account, model and capability reads ask the engine f…
fengzhi09 Oct 2, 2026
eb2a429
feat(webui): #73 swaps the ACP wire table for the 14-key engine-capab…
fengzhi09 Oct 2, 2026
2baf051
fix(webui): stop two B4 comments describing behaviour the code no lon…
fengzhi09 Oct 2, 2026
edf2b1e
feat(webui): move the session write family behind the engine facade
fengzhi09 Oct 2, 2026
1506cc2
fix(webui): drop whitespace text nodes in markdown tables and dedupe …
fengzhi09 Oct 2, 2026
8cca235
fix(webui): sweep the non-flipping inverted text token off primary su…
fengzhi09 Oct 2, 2026
eecd8c0
feat(webui): move session switch behind the engine facade
fengzhi09 Oct 2, 2026
0cfd51f
Merge main into dev-lhl
fengzhi09 Oct 2, 2026
e4cf052
chore: allowlist the leak-tripwire fixture in model-reads tests
fengzhi09 Oct 2, 2026
3f5b8d2
test(webui): pin session-writes cleanup-orphans test to isolated paths
fengzhi09 Oct 2, 2026
e7df93d
chore: ignore gitleaks fingerprints of deliberate test fixtures
fengzhi09 Oct 2, 2026
62814ff
chore: make the gitleaks fixture allowlists path-only
fengzhi09 Oct 2, 2026
3074010
feat(webui): move interrupt and load endpoints behind the engine facade
fengzhi09 Oct 3, 2026
063a43a
fix(webui): take the plan's 5s abort force-kill bound by product call
fengzhi09 Oct 3, 2026
90cf85e
Merge main into dev-lhl
fengzhi09 Oct 3, 2026
a9af820
docs(webui): add session-switch, interrupt and session-load to the ar…
fengzhi09 Oct 3, 2026
4d904c3
docs(webui): add the missing zh-CN section for the B5 write family
fengzhi09 Oct 3, 2026
fdc3ff2
fix(webui): make webui-only session delete return promptly instead of…
fengzhi09 Oct 3, 2026
dab453d
fix(webui): retire lossy streaming mirrors when the engine transcript…
fengzhi09 Oct 3, 2026
7138b5b
feat(webui): add the streaming-send capability gate and pure stream b…
fengzhi09 Oct 3, 2026
a2223f4
feat(webui): run send on the runtime transport behind the engine facade
fengzhi09 Oct 3, 2026
8b51fdd
Merge main into dev-lhl
fengzhi09 Oct 3, 2026
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
48 changes: 48 additions & 0 deletions docs/webui.md
Original file line number Diff line number Diff line change
Expand Up @@ -2186,6 +2186,7 @@ do not come from the same place.
| Kind | Written by | In the engine runtime DB? | Survives a poll tick? |
| --- | --- | --- | --- |
| engine turn (`› ping`, `● pong`, tool blocks) | the engine, streamed into `cs.chat` | yes | yes, refreshed from the DB |
| the streaming **mirror** of that turn (one folded `● answer…`, a `→ bash` header without its args) | the same stream, into the same array | yes — the same text, folded | **no — it retires**, the engine's own lines take its place |
| `/api/cmd` echo (`› /help`, `● 可用命令:…`, `● 当前 model=…`, `● 变更概览 …`) | `interaction/commands.js`, into `cs.chat` | **no — the engine never sees it** | yes, and it is the only thing that keeps it there |
| a turn another client ran (desktop app, TUI) | the engine, for a different cid | yes | yes, pulled in — that is the poll's purpose |

Expand All @@ -2196,6 +2197,31 @@ catches up in an open tab. It is a **merge**, not a replacement:
the lines already shown in lockstep, keeps any line the engine does not
know about in place, and appends the engine's remainder.

The mirror is the third kind, and it is the one the merge has to retire. While
a turn streams, the same engine output is written into `cs.chat` a second time
in a folded form — the answer and thinking branches write one line
(`prefix + text.replace(/\n+/g, " ").trim()`) where the engine's own mapper
keeps one array entry per source line, and a tool header is written `→ bash`
when the frame carried no `rawInput` against the engine's
`→ bash {"command":…}`. Neither can ever be byte-equal to what the engine
holds, so the lockstep walk called every mirror a locally-authored line, kept
it, and appended the engine's whole spine behind it. The answer, the tool block
and the thinking chain each rendered twice, and `persistCurrentChat` made the
duplicate permanent. Measured on a UAT session: 81 stored lines against a
67-line engine read, 14 of them a second copy of engine content.

Retirement is an identity test, not a shape heuristic. A folded prose mirror
(`●`/`▲`/`›`/`○`) retires when the maximal run of engine lines carrying the
same glyph, starting at the cursor, folds — their texts joined by a single
space, every whitespace run collapsed — to exactly the mirror's folded text. A
tool header retires when the engine line at the cursor names the same tool, and
the indented block goes with it on both sides. So the engine must already hold
that text at that position: a `/api/cmd` echo, which the engine has never seen,
has no fold to match and is kept. And a test that misses — unusual spacing, a
tool block the engine has not finished writing — leaves the line in place, which
is the double render the merge already had. No path drops content the engine
read did not account for.

Server-written annotations — `§§ processed_duration=Nms`, `§§ turn_msg=<id>`,
`##tc:<id>` — are the one class of engine line the merge may not treat as an
ordinary line, because position is their entire meaning: the decoder resolves
Expand Down Expand Up @@ -2445,6 +2471,28 @@ authorize round-trip (5-minute default timeout, fail-closed):
The whitelist is the only source of truth — anything not on this list
cannot be gated via the modal flow.

**The 5-minute budget answers "a human saw the modal and did not answer".
It does not answer "no human was ever shown one".** The gate is a push:
`pushAuthRequest` writes a `needs_authorization` frame into the requesting
tab's SSE response, and the decision comes back on `POST
/api/auth/decision`. When the request's client has no live connection
(`state-bus.js#hasDecisionListener` — no response registered for that cid,
or the registered one can no longer be written to), nobody can decide, so
the fail-closed result is already determined. The request is answered at
once with the ordinary decline body — `403 {ok:false, error:"authorize
declined", decidedBy:"timeout", decidedAt}` — and audited as
`auth.unreachable` with `reason:"no_connected_client"`, so an operator can
tell "nobody was there" from "somebody said no". Before this the same call
held the socket open for the full five minutes with no status and no body,
which is indistinguishable from a hang; in practice that is what
`curl -X DELETE /api/sessions/<id>` from a script saw, because a request
without `?cid=` has no client to ask.

A bus that cannot answer the question (a test double that does not model
the connection registry) is treated as "might have a listener" and keeps
the old wait. The short-circuit can only ever deny — no path approves
anything without a recorded decision.

## Blocking prompts: what each one can actually answer

`components/modals.tsx` renders three blocking prompts. Two of them carry a
Expand Down
34 changes: 34 additions & 0 deletions docs/webui.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -1604,6 +1604,7 @@ composer 实际调用的那个函数。
| 种类 | 谁写的 | 在引擎 runtime DB 里? | 轮询后还在吗? |
| --- | --- | --- | --- |
| 引擎回合(`› ping`、`● pong`、工具块) | 引擎,流式写进 `cs.chat` | 在 | 在,并从 DB 刷新 |
| 该回合的流式**镜像**(压平成一行的 `● 答案…`、缺参数的 `→ bash` 头) | 同一条流,写进同一个数组 | 在——同样的文本,只是被折叠了 | **不在——它会退役**,由引擎自己的行顶替 |
| `/api/cmd` 回显(`› /help`、`● 可用命令:…`、`● 当前 model=…`、`● 变更概览 …`) | `interaction/commands.js` 写进 `cs.chat` | **不在——引擎从没见过它** | 在,而且只有它能让它留下 |
| 别的客户端跑的回合(桌面版、TUI) | 引擎,属于另一个 cid | 在 | 在,会被拉进来——这正是轮询的目的 |

Expand All @@ -1613,6 +1614,24 @@ composer 实际调用的那个函数。
(`lib/transcript.js`)把引擎读到的内容与已展示的行并行走一遍,引擎不
知道的行原地保留,引擎多出来的部分追加到末尾。

镜像是第三类,也正是合并必须让它退役的那一类。回合流式进行时,同一份引擎
输出还会以折叠形态第二次写进 `cs.chat`——答案与思维分支只写一行
(`前缀 + text.replace(/\n+/g, " ").trim()`),而引擎自己的映射器是源文本
一行一个数组项;帧里没带 `rawInput` 时工具头写成 `→ bash`,引擎那边却是
`→ bash {"command":…}`。两者永远不可能逐字节相等,于是并行走查把每一个
镜像都判成「本端自己写的行」留下,再把引擎整条脊柱追加在它后面:答案、工具
块、思维链各渲染两份,而 `persistCurrentChat` 把这份重复一并固化。UAT 实测:
落盘 81 行对应引擎 67 行读数,其中 14 行是引擎内容的第二份副本。

退役判据是同一性检验,不是形状启发式。压平的散文行镜像
(`●`/`▲`/`›`/`○`)当且仅当从游标处起、同一字形的引擎行极大连续段折叠后
(文本以单空格相接、所有空白游程压成一个空格)恰好等于镜像折叠后的文本时
退役。工具头当且仅当游标处的引擎行是同名工具时退役,两侧缩进的块体一并
带走。因此引擎必须已经在那个位置上持有同样的文本:`/api/cmd` 回显引擎从
没见过,没有可折叠的对象,保留。判据落空时——异常空格、引擎尚未写完的
工具块——该行原地保留,也就是合并原有的双份渲染。没有任何一条路径会丢掉
引擎读数没有交代的内容。

服务端写的注解行——`§§ processed_duration=Nms`、`§§ turn_msg=<id>`、
`##tc:<id>`——是唯一不能按普通行处理的引擎行:位置就是它们的全部意义,
解码器把每一行解析到它上方的块上。聊天记录早于某个标记落盘的标签页手里
Expand Down Expand Up @@ -1828,6 +1847,21 @@ Hono 仍然为所有实际请求持有 `/api/health` 与 `/api/settings`;旧

白名单是唯一可信源 —— 不在列表里的无法走模态门禁。

**5 分钟预算回答的是"人看到了弹窗但没点",不是"根本没有人看到过弹窗"。**
门禁是一次推送:`pushAuthRequest` 把 `needs_authorization` 帧写进发起方标签页的
SSE 响应,裁决再经 `POST /api/auth/decision` 从另一条连接回来。当该请求所属的
客户端没有活连接时(`state-bus.js#hasDecisionListener` —— 该 cid 没有登记响应,
或登记的响应已不可写),没有人能裁决,失败即关闭的结果在那一刻就已确定。请求
立刻以既有的拒绝体作答 —— `403 {ok:false, error:"authorize declined",
decidedBy:"timeout", decidedAt}` —— 并记 `auth.unreachable` 审计(带
`reason:"no_connected_client"`),让运维能区分"根本没人"和"有人拒绝了"。在此
之前,同一个调用会把 socket 满开五分钟、不给状态码也不给响应体,与挂起无法区分;
实际表现就是脚本里的 `curl -X DELETE /api/sessions/<id>` —— 不带 `?cid=` 的请求
压根没有可询问的客户端。

若总线无法回答这个问题(不建模连接注册表的测试替身),按"可能有人"处理,保持原
有的等待语义。这条短路只可能拒绝,不存在任何"未经记录裁决即放行"的路径。

## 阻断式弹窗:各自到底能应答什么

`components/modals.tsx` 渲染三个阻断式弹窗。其中两个的决定引擎收得到,另一个收不到 —— 那个不装样子,而是直说。这个区分是契约,不是界面偏好:**一个把决定发往引擎从不读取之处的按钮,会让点击"成功"而提问一直挂着**,比干脆不显示该按钮更糟。
Expand Down
43 changes: 40 additions & 3 deletions packages/webui/server/engine/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,10 @@
// catalogue host itself is now reached through this facade too
// (engine/host.js), so the plugins and turn-diff routes no longer name
// lib/acp-client.js. M3 batches B1 (#9 #10 #72 #74 #75), B2 (#8 #11),
// B3 (#15 #16 #17 #19), B4 (#20 #57 #73), B5 (#7 #4 #6), B6 (#3) and
// B7 (#13 #69 #70 #71) done. The rest of M3, then M4, will route their
// consumers through this facade one endpoint family at a time.
// B3 (#15 #16 #17 #19), B4 (#20 #57 #73), B5 (#7 #4 #6), B6 (#3),
// B7 (#13 #69 #70 #71), B8a (#12's pure layer + gate) and B8b (#12's
// runner + route branch) done. The rest of M3, then M4, will route
// their consumers through this facade one endpoint family at a time.

import { ENGINE_CAPABILITY_KEYS } from "./capabilities.js";
// Declarations only — importing the provider *host-construction* modules
Expand Down Expand Up @@ -260,6 +261,42 @@ export {
resolveSwitchWorkspace,
selectTranscriptBackfill,
} from "./session-switch.js";
// The STREAMING SEND family (step M3, batches B8a and B8b): #12
// POST /api/send. Same cycle, same TDZ rule, same reasoning:
// streaming-send.js's `STREAMING_SEND_ENDPOINTS` table is a literal and
// every binding it needs (`getEngineProvider`,
// `DEFAULT_ENGINE_PROVIDER_ID`) is read inside a function body, so a
// cold `import("./engine/index.js")` can never hit a temporal dead
// zone. Its static imports are `engine/capabilities.js`,
// `engine/index.js` and the node builtins; the host getter, the
// per-turn host wrapper and the attachments helper are reached through
// `await import()` inside the data plane, which is what keeps an
// acp-only server off the runtime graph.
//
// It gates HARD, the first M3 family to do so, and the reason is
// structural rather than a policy preference: #12's response is
// `{ok:true}` written BEFORE the engine is called, so a provider with
// no send surface could only be answered with an ack for a turn that
// never runs. See that module's header for the full argument, for the
// two of the three red lines it owns, and for the eight recorded
// debts.
export {
SEND_EVENT_KINDS,
STREAMING_SEND_ENDPOINTS,
assertStreamingSendCapability,
checkStreamingSendCapability,
classifySendEvent,
openEngineSendStream,
projectSendAttachments,
resolveStreamingSendProvider,
rewriteDrainedAnswerLine,
sendSegmentAdvance,
sendStillViewing,
sendTerminalOutcome,
sendToolHeaderLine,
sendToolUpdate,
sendUsageTotals,
} from "./streaming-send.js";
export { LOCAL_RUNTIME_V2_CAPABILITIES } from "./providers/local-runtime-v2.capabilities.js";
export { TUI_RUNTIME_ADAPTER_CAPABILITIES } from "./providers/tui-runtime-adapter.js";
// The INTERRUPT family (step M3, batch B7): #13 POST /api/stop, #69
Expand Down
Loading
Loading