diff --git a/docs/webui.md b/docs/webui.md index 473eee906..eaffa5188 100644 --- a/docs/webui.md +++ b/docs/webui.md @@ -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 | @@ -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=`, `##tc:` — 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 @@ -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/` 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 diff --git a/docs/webui.zh-CN.md b/docs/webui.zh-CN.md index 3c18abc5a..c504fdb18 100644 --- a/docs/webui.zh-CN.md +++ b/docs/webui.zh-CN.md @@ -1604,6 +1604,7 @@ composer 实际调用的那个函数。 | 种类 | 谁写的 | 在引擎 runtime DB 里? | 轮询后还在吗? | | --- | --- | --- | --- | | 引擎回合(`› ping`、`● pong`、工具块) | 引擎,流式写进 `cs.chat` | 在 | 在,并从 DB 刷新 | +| 该回合的流式**镜像**(压平成一行的 `● 答案…`、缺参数的 `→ bash` 头) | 同一条流,写进同一个数组 | 在——同样的文本,只是被折叠了 | **不在——它会退役**,由引擎自己的行顶替 | | `/api/cmd` 回显(`› /help`、`● 可用命令:…`、`● 当前 model=…`、`● 变更概览 …`) | `interaction/commands.js` 写进 `cs.chat` | **不在——引擎从没见过它** | 在,而且只有它能让它留下 | | 别的客户端跑的回合(桌面版、TUI) | 引擎,属于另一个 cid | 在 | 在,会被拉进来——这正是轮询的目的 | @@ -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=`、 `##tc:`——是唯一不能按普通行处理的引擎行:位置就是它们的全部意义, 解码器把每一行解析到它上方的块上。聊天记录早于某个标记落盘的标签页手里 @@ -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/` —— 不带 `?cid=` 的请求 +压根没有可询问的客户端。 + +若总线无法回答这个问题(不建模连接注册表的测试替身),按"可能有人"处理,保持原 +有的等待语义。这条短路只可能拒绝,不存在任何"未经记录裁决即放行"的路径。 + ## 阻断式弹窗:各自到底能应答什么 `components/modals.tsx` 渲染三个阻断式弹窗。其中两个的决定引擎收得到,另一个收不到 —— 那个不装样子,而是直说。这个区分是契约,不是界面偏好:**一个把决定发往引擎从不读取之处的按钮,会让点击"成功"而提问一直挂着**,比干脆不显示该按钮更糟。 diff --git a/packages/webui/server/engine/index.js b/packages/webui/server/engine/index.js index d844df2e7..a0182e368 100644 --- a/packages/webui/server/engine/index.js +++ b/packages/webui/server/engine/index.js @@ -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 @@ -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 diff --git a/packages/webui/server/engine/streaming-send.js b/packages/webui/server/engine/streaming-send.js new file mode 100644 index 000000000..851699778 --- /dev/null +++ b/packages/webui/server/engine/streaming-send.js @@ -0,0 +1,992 @@ +// webui/server/engine/streaming-send.js +// +// Migration step M3, batch B8a: the STREAMING SEND family's PURE LAYER +// and its capability gate — +// +// #12 POST /api/send — the main chat entry +// +// B8a shipped this file as the PURE layer only — the declaration, the +// gate and the derivations, with no runner and no route branch, so that +// a layer whose value is that it has no IO could be reviewed and +// trusted on its own. B8b adds the DATA PLANE at the bottom: the one +// place here that touches the engine. The two exports it adds +// (`openEngineSendStream`, `projectSendAttachments`) are the only ones +// that are not total functions over their arguments, and they are the +// only reason this module now reaches for `await import()` — which is +// why the split mattered: the purity was provable only while it held. +// +// #12 IS WIRED as of this commit, on the `runtime` transport. The acp +// path is byte-for-byte what it was at 32277c3a. +// +// What this layer is for. #12 was the single endpoint whose whole +// behaviour lived in one route body: it claims the turn, answers, and +// then runs a turn whose OUTPUT never crosses the HTTP response — it +// crosses the `/api/events` SSE channel as webui chat lines (`▲` +// thinking, `●` answer, `→ tool`, `##tc:` markers). Under the ACP +// transport that stream arrives as `mcode acp` session-update +// notifications. Under the `runtime` transport it arrives as something +// structurally different, and the difference is the whole risk of the +// B8 work: +// +// - ACP gives an EVENT vocabulary (`thought` / `message` / `tool_call` +// / `tool_update` / `plan_update` / …) that happens to be close to +// webui's line syntax. +// - The runtime gives a FRAME vocabulary (SSE `dataJson` envelopes) +// that webui has never consumed. The per-turn wrapper +// (`lib/runtime-host.js` → `TuiRuntimeAdapter.sendMessage`) already +// projects those frames into structured `TuiStreamEvent`s +// (`@minimax/code/runtime-adapter` → `projectTuiSessionStreamFrame`), +// so the bridge this file owns starts one level above the wire. +// +// The bridge is TuiStreamEvent → webui line, and it is split into a +// PURE classification layer (this file) and an imperative runner (B8b), +// because the classification is where a regression is INVISIBLE: a +// dropped `●` line does not crash, it just makes the answer disappear. +// A pure function is the only shape in which "invisible" is testable, +// and that is why this layer exists before any runner does. +// +// THE RED LINES, and which half of each is here. The plan names +// `run-mirror`, the finalize drain and `promoteDraftToMcodeSid` as the +// red lines of #12. All three are STRUCTURAL — they are the route's +// post-run tail, the state bus's per-(cid, session) line buffer, and +// the single-identity record promotion — and none of them is +// transport-specific. What this file owns is the part that is: which +// line each event produces, which line family the accumulator is +// currently in, and the two predicates the drain and the run-mirror +// consult. Two of the three red lines have their judgement expressed +// here as pure functions (`sendStillViewing`, `rewriteDrainedAnswerLine`) +// precisely so that the runner cannot re-derive them differently. The +// third — the draft promotion — is deliberately NOT re-derived here; +// the module explains why at the point where it would have gone. +// +// The fourth red line, the 409 claim, is entirely the route's and is +// taken before any transport branch exists. What this file contributes +// to it is the gate, which a future batch will call at the same place. +// +// Why this family's gate is HARD, which is the opposite of B7's. B5 +// and B7 gate soft because both endpoints have a truthful degradation: +// a stop whose escalation is webui's own child management can still +// stop the turn, and a cancel already HAS a documented "I could not +// do it" 200. #12 has no degradation at all. Its response is +// `{ok:true}` written BEFORE the engine is called — fire-and-forget by +// contract, because the output arrives on a different channel. A +// provider with no `streamingSend` surface cannot produce a truthful +// answer to any of: the turn never runs, the panel shows 思考中 with no +// stream behind it, and nothing resets the claim. That is #110's fake +// success exactly, so the gate throws and `app.js#invokeHandler` maps +// it to 501 with the shared payload. The gate is UNREACHABLE on both +// transports today — v2 declares `streamingSend: full`, and `acp` has +// no registered provider at all (M4's registry) — so no existing +// response changes, and B8a changes none either because nothing calls +// the gate yet. The suite pins both halves of that sentence, so making +// it reachable is a deliberate edit rather than a discovery. +// +// What this file deliberately does NOT do: +// +// - It does not own the runner. `openEngineSendStream` OPENS a turn's +// event stream and resolves the session; it does not consume one. +// The loop, the line writes, the idle watchdog and the finalize are +// `lib/mcode-acp.js#streamRuntimePrompt`, because they have to be +// the same machine as the ACP runner's for the route's drain and +// promotion to keep working unchanged. +// - It does not own the run claim, the run-mirror buffer, the drain +// or the draft promotion. Those are the route's and the state +// bus's, and moving them would be a second, unrelated change to +// the most fragile tail in the server. +// - It does not construct a host. `Never build a second host` is not +// even at stake: there is no host reference in this file. +// - It does not own the ACP path. `runMcodeAcp` and +// `streamAcpPrompt` are not imported, referenced or reached from +// here. +// +// Boot-path weight. `routes/chat.js` imports this file, so it is on +// the boot path. Its STATIC imports are `engine/capabilities.js`, +// `engine/index.js` and the node builtins — all of them cheap. The +// host getter, the per-turn host wrapper and the attachments helper are +// reached through `await import()` inside `openEngineSendStream` and +// nowhere else, so an acp-only server never boots the runtime graph. +// That split is the M1 lesson, and it is what lets this module be +// re-exported from `engine/index.js` at all. +// +// Provider selection is M4's job, same as B1 through B7: +// `providerByTransport()` maps a transport to a REGISTERED provider id; +// only `runtime` has one, so under the default `acp` transport the gate +// reports `gate: "unregistered-transport"` — which is correct, because +// the pre-M4 behaviour under `acp` is the only behaviour this endpoint +// has ever had. + +import { DEFAULT_ENGINE_PROVIDER_ID, getEngineProvider } from "./index.js"; +import { assertEngineCapability } from "./capabilities.js"; + +// --------------------------------------------------------------------------- +// Transport → provider +// --------------------------------------------------------------------------- + +/** + * Transport → registered engine provider id. Absent means "no provider + * claims this transport yet" (M4), NOT "the capability is unavailable" — + * the two answer differently on purpose, exactly as in + * `session-reads.js#providerByTransport`, `session-tree-reads.js`, + * `usage-reads.js`, `account-reads.js`, `session-writes.js`, + * `session-switch.js` and `interrupt.js`, which this mirrors rather + * than merges: eight families with separate contracts, and a shared + * table would force this one to inherit another's policy. + * + * Built per call rather than frozen at module scope: `engine/index.js` + * re-exports this module, so a module-level table would read + * `DEFAULT_ENGINE_PROVIDER_ID` while that binding is still in its + * temporal dead zone on a cold `import("./engine/index.js")`. Every + * consumer of the table is a function anyway. + * + * @returns {Readonly>} + */ +function providerByTransport() { + return Object.freeze({ runtime: DEFAULT_ENGINE_PROVIDER_ID }); +} + +// --------------------------------------------------------------------------- +// The declaration, and the HARD gate that goes with it +// --------------------------------------------------------------------------- + +/** + * The declaration this family's engine-facing half needs. + * + * `streamingSend` is the honest mapping and it is the same key the + * capability matrix row "聊天" names: a turn that produces a stream. + * The runtime surface behind it is `CliService.sendMessage` reached + * through the TuiRuntimeAdapter's per-turn wrapper, and the provider + * declares it `full` (see `providers/local-runtime-v2.capabilities.js`). + * + * `subItem` is `sendMessage` — the method name, not a webui concept. + * It is what a `partial` declaration would list in `missing`, and what + * the 501 payload reports, so it has to be the name a provider author + * would recognise from the source. + * + * @type {Readonly>} + */ +export const STREAMING_SEND_ENDPOINTS = Object.freeze({ + "POST /api/send": Object.freeze({ + capability: "streamingSend", + subItem: "sendMessage", + enforcement: "hard", + }), +}); + +/** + * Resolve the provider that answers the send family on `transport`, or + * `null` when none is registered yet. + * + * @param {string} transport One of the `MCODE_WEBUI_TRANSPORT` values. + * @returns {{id: string, transport: string, capabilities: object}|null} + */ +export function resolveStreamingSendProvider(transport) { + const providerId = providerByTransport()[transport]; + if (!providerId) return null; + return getEngineProvider(providerId); +} + +/** + * Read the declaration for this endpoint WITHOUT enforcing it. + * + * Returns a descriptor whose `gate` field says what happened: + * + * - `"checked"` — provider resolved, capability is `full`. + * - `"unregistered-transport"` — no provider claims this transport yet. + * This is the DEFAULT `acp` transport, and a send proceeding here is + * the pre-M3 behaviour, not a hole in the gate. + * - `"capability-absent"` — the provider WAS found and DOES declare + * `streamingSend` as `none`. + * - `"partial"` — the provider is `partial` and + * `sendMessage` is in its `missing` list. + * + * Never throws `EngineCapabilityNotSupportedError`. A genuinely unknown + * endpoint key is still a plain Error — caller confusion is not a + * capability question, and the HTTP layer must never answer 501 for a + * typo in webui's own code. + * + * @param {string} endpoint A key of STREAMING_SEND_ENDPOINTS. + * @param {string} transport The active transport. + * @returns {{endpoint: string, gate: string, provider: string|null, capability: string, subItem: string, enforcement: "hard"}} + */ +export function checkStreamingSendCapability(endpoint, transport) { + const need = STREAMING_SEND_ENDPOINTS[endpoint]; + if (need === undefined) { + const err = new Error( + `checkStreamingSendCapability: "${endpoint}" is not part of the streaming-send family ` + + `(known: ${Object.keys(STREAMING_SEND_ENDPOINTS).join(", ")})`, + ); + err.code = "unknown_streaming_send_endpoint"; + throw err; + } + const base = { + endpoint, + provider: null, + capability: need.capability, + subItem: need.subItem, + enforcement: need.enforcement, + }; + const provider = resolveStreamingSendProvider(transport); + if (!provider) return { ...base, gate: "unregistered-transport" }; + const entry = provider.capabilities ? provider.capabilities[need.capability] : undefined; + const descriptor = { ...base, provider: provider.id }; + if (entry && entry.level === "full") { + return { ...descriptor, gate: "checked" }; + } + if (entry && entry.level === "partial") { + const absent = Array.isArray(entry.missing) && entry.missing.includes(need.subItem); + return { ...descriptor, gate: absent ? "partial" : "checked" }; + } + return { ...descriptor, gate: "capability-absent" }; +} + +/** + * Enforce the declaration. Throws `EngineCapabilityNotSupportedError` + * when a RESOLVED provider does not offer the send surface, which + * `app.js#invokeHandler` maps to the shared 501 payload. + * + * Returns without throwing when no provider claims the transport. That + * is not a hole: `acp` is the default and the ACP path does not go + * through this facade at all — the gate only ever speaks about a + * provider that has been resolved and has made a declaration. + * + * @param {string} endpoint A key of STREAMING_SEND_ENDPOINTS. + * @param {string} transport The active transport. + * @returns {{endpoint: string, gate: string, provider: string|null, capability: string, subItem: string}} + */ +export function assertStreamingSendCapability(endpoint, transport) { + const need = STREAMING_SEND_ENDPOINTS[endpoint]; + if (need === undefined) { + const err = new Error( + `assertStreamingSendCapability: "${endpoint}" is not part of the streaming-send family ` + + `(known: ${Object.keys(STREAMING_SEND_ENDPOINTS).join(", ")})`, + ); + err.code = "unknown_streaming_send_endpoint"; + throw err; + } + const provider = resolveStreamingSendProvider(transport); + if (!provider) { + return { + endpoint, + gate: "unregistered-transport", + provider: null, + capability: need.capability, + subItem: need.subItem, + }; + } + assertEngineCapability(provider.capabilities, need.capability, provider.id, need.subItem); + return { + endpoint, + gate: "checked", + provider: provider.id, + capability: need.capability, + subItem: need.subItem, + }; +} + +// --------------------------------------------------------------------------- +// Pure derivations, part 1 — the event taxonomy +// --------------------------------------------------------------------------- + +/** + * What one runtime event means to webui's line syntax. + * + * These are webui's words, not the runtime's. `thought` / `message` / + * `tool` are the three families the ACP path accumulates separately; + * `authoritative` is the settled message that OVERWRITES the + * accumulator instead of appending to it (the runtime emits both + * deltas and, at close, one complete message — the ACP transport's + * `result.answer` is the same fact delivered once instead of thousands + * of times); `terminal` is a turn outcome; the rest are facts about + * the stream that produce no line. + * + * @type {Readonly>} + */ +export const SEND_EVENT_KINDS = Object.freeze({ + THOUGHT: "thought", + MESSAGE: "message", + TOOL: "tool", + AUTHORITATIVE: "authoritative", + STREAM: "stream", + TERMINAL: "terminal", + IGNORE: "ignore", +}); + +/** Tool-call lifecycle stages, from `agent-core`'s `ToolCallStatus`. */ +const TOOL_STATUS = Object.freeze({ + START: 1, + FINISHED: 2, + FAILED: 3, + PREPARING: 4, + PREPARED: 5, +}); + +/** + * The stages whose call is still moving, mapped to the ACP path's word + * for "announced but not done". Kept as its own frozen table so the + * stage list has one home: adding a stage to `ToolCallStatus` and + * forgetting to classify it is a silent "completed", which would print + * a half-streamed argument as if it were the tool's result. + */ +const PENDING_TOOL_STAGES = Object.freeze([ + TOOL_STATUS.START, + TOOL_STATUS.PREPARING, + TOOL_STATUS.PREPARED, +]); + +/** The session statuses that END a turn, from `TuiStreamEvent`. */ +const TERMINAL_SESSION_STATUS = Object.freeze(["finished", "error", "aborted", "interrupted"]); + +/** + * Classify ONE runtime stream event into what webui must do with it. + * + * Pure, total, and never throws: an event shape webui does not + * recognise becomes `{kind: IGNORE}` rather than killing a turn that is + * otherwise streaming correctly. That asymmetry is deliberate — a + * bridge that throws on an unknown frame turns every future runtime + * addition into an outage of the chat endpoint, which is a strictly + * worse failure than not rendering one line. + * + * The return is a small descriptor, not the line itself. The line is + * text the CALLER owns (`● ` + normalized text), because the + * normalization is the one thing the ACP path must not change. + * + * @param {object|null|undefined} event A `TuiStreamEvent`. + * @returns {{kind: string, text?: string, toolCalls?: object[], messageId?: string, usage?: object, finishReason?: string, status?: string, errorMessage?: string}} + */ +export function classifySendEvent(event) { + if (!event || typeof event !== "object") { + return { kind: SEND_EVENT_KINDS.IGNORE }; + } + switch (event.type) { + case "delta": { + // A delta can carry several families at once (a tool call streamed + // alongside a text fragment). Tool first, because a `→ name` line + // must be emitted before the text it interrupted, and because + // webui's segment discriminator is broken by a tool call + // regardless of whether text rode along. + if (Array.isArray(event.toolCalls) && event.toolCalls.length > 0) { + return { + kind: SEND_EVENT_KINDS.TOOL, + toolCalls: event.toolCalls, + ...(typeof event.content === "string" ? { text: event.content } : {}), + ...(typeof event.thinking === "string" ? { thinking: event.thinking } : {}), + }; + } + if (typeof event.content === "string" && event.content !== "") { + return { kind: SEND_EVENT_KINDS.MESSAGE, text: event.content }; + } + if (typeof event.thinking === "string" && event.thinking !== "") { + return { kind: SEND_EVENT_KINDS.THOUGHT, text: event.thinking }; + } + // `finish: true` with no payload is the segment terminator. It + // carries no line, but it IS a segment break, so the runner can + // recognize it safely as a no-op of the thought family. + return { kind: SEND_EVENT_KINDS.IGNORE }; + } + case "message": { + const m = event.message; + // `Array.isArray` is not optional here: an array passes + // `typeof === "object"`, so a malformed frame carrying + // `message: []` would otherwise be classified AUTHORITATIVE with + // every field undefined — which reads to the runner as "the engine + // settled a turn with no text" and lets it clear a good + // accumulation. Same rule as `isRecord` in the runtime's own + // decoders. + if (!m || typeof m !== "object" || Array.isArray(m)) { + return { kind: SEND_EVENT_KINDS.IGNORE }; + } + if (m.finishReason === "error") { + return { + kind: SEND_EVENT_KINDS.TERMINAL, + errorMessage: + typeof m.content === "string" && m.content.trim() + ? m.content.trim() + : "Runtime stream failed", + }; + } + return { + kind: SEND_EVENT_KINDS.AUTHORITATIVE, + ...(typeof m.content === "string" ? { text: m.content } : {}), + ...(typeof m.thinking === "string" ? { thinking: m.thinking } : {}), + ...(Array.isArray(m.toolCalls) && m.toolCalls.length > 0 + ? { toolCalls: m.toolCalls } + : {}), + ...(m.usage ? { usage: m.usage } : {}), + ...(typeof m.finishReason === "string" ? { finishReason: m.finishReason } : {}), + ...(typeof m.id === "string" ? { messageId: m.id } : {}), + ...(typeof m.error === "string" ? { errorMessage: m.error } : {}), + }; + } + case "session-status": { + if (!TERMINAL_SESSION_STATUS.includes(event.status)) { + return { kind: SEND_EVENT_KINDS.STREAM, status: event.status }; + } + return { + kind: SEND_EVENT_KINDS.TERMINAL, + status: event.status, + ...(typeof event.message === "string" ? { errorMessage: event.message } : {}), + }; + } + case "error": { + return { + kind: SEND_EVENT_KINDS.TERMINAL, + errorMessage: + typeof event.message === "string" ? event.message : "Runtime stream failed", + }; + } + case "done": + return { kind: SEND_EVENT_KINDS.TERMINAL, status: "finished" }; + // Liveness, resync and generic projections: real, load-bearing for + // operators, and none of them a chat line. `resync-required` in + // particular means webui's view of the turn diverged from the + // runtime's — see KNOWN DEBT 2, which is the open question about + // what webui should do with that fact. + case "heartbeat": + case "resync-required": + case "messages-replaced": + case "messages-rewound": + case "generic": + default: + return { kind: SEND_EVENT_KINDS.IGNORE }; + } +} + +/** + * Advance one accumulator segment. + * + * This is the piece of the bridge that is easiest to get subtly wrong, + * and getting it wrong is invisible: a missing reset makes the next + * `●` line contain every previous segment's text, which still renders + * and still looks like an answer. + * + * The rule, matching the ACP path's `lastChunkKind` discriminator: a + * delta of the SAME family appends to the buffer; a delta of a + * DIFFERENT family — or of any family after a tool call — starts a + * fresh segment. `lastKind` is the caller's own field, so this stays + * pure and the whole transition table is testable without a stream. + * + * @param {string|null} lastKind The family the buffer currently holds. + * @param {string} kind The family of the incoming delta. + * @param {string} buffer The accumulated text so far. + * @param {string} delta The incoming text. + * @returns {{reset: boolean, text: string, lastKind: string}} + */ +export function sendSegmentAdvance(lastKind, kind, buffer, delta) { + if (typeof delta !== "string" || delta === "") { + return { reset: false, text: buffer, lastKind }; + } + if (lastKind !== kind) { + return { reset: true, text: delta, lastKind: kind }; + } + return { reset: false, text: buffer + delta, lastKind }; +} + +/** + * The turn outcome a terminal event implies, in the vocabulary the + * route already reads. + * + * `aborted` is NOT a failure: the user pressed stop, and B7's + * `abortSession` is what ended the turn. The route's error branch is + * gated on `r.status === "failed" || r.error`, so reporting an abort as + * a failure would fire an error alert for a user action. This is the + * runtime transport's version of B7's "`cancelled` means sent" rule, + * and it is asserted in both directions. + * + * @param {string} status One of the terminal `session-status` values. + * @returns {{status: "succeeded"|"failed"|"aborted", errorMessage: string|null}} + */ +export function sendTerminalOutcome(status) { + if (status === "finished") return { status: "succeeded", errorMessage: null }; + if (status === "aborted" || status === "interrupted") { + return { status: "aborted", errorMessage: null }; + } + return { + status: "failed", + errorMessage: `Runtime turn ended with status "${status}"`, + }; +} + +// --------------------------------------------------------------------------- +// Pure derivations, part 2 — the line bodies +// --------------------------------------------------------------------------- + +/** + * Normalize a tool-call payload into the `u` shape + * `lib/mcode-acp.js#applyToolUpdate` already consumes. + * + * Reusing that reducer rather than writing a second one is the point: + * the indented body syntax, the `@ path` lines, the `! error` line and + * the subagent-detection wiring have exactly one home, and the runtime + * transport inherits all of them by producing the same input. A second + * implementation would be a second place for the `→ name` header to + * disagree with the body that follows it. + * + * Stage mapping, which is the part that is webui's judgement and not + * the runtime's: + * + * - Start / Preparing / Prepared → `pending`. The call is announced + * and its arguments are still moving; a body here would print a + * half-streamed argument as if it were the tool's input. + * - Finished → `completed` (the ACP path's word). + * - Failed → `error` (also the ACP path's word). + * + * The ACP path reads `u.status || "completed"`, so an absent status on + * a call that has already been announced is treated as completion — + * matching, not a new default. + * + * @param {object} toolCall A `TuiToolCall`. + * @returns {object} The `applyToolUpdate` update shape. + */ +export function sendToolUpdate(toolCall) { + const tc = toolCall && typeof toolCall === "object" ? toolCall : {}; + // The runtime sends the stage as a number; a provider that sends the + // NAME is accepted too, because the ACP path's own `u.status` is a + // string and a future merge of the two vocabularies would otherwise + // silently downgrade every stage to "completed". + const stage = + typeof tc.status === "number" && Number.isInteger(tc.status) + ? tc.status + : TOOL_STATUS[String(tc.status).toUpperCase()]; + const mapped = + stage === TOOL_STATUS.FAILED + ? "error" + : PENDING_TOOL_STAGES.includes(stage) + ? "pending" + : "completed"; + const outText = toolResultText(tc.output); + return { + ...(typeof tc.id === "string" ? { toolCallId: tc.id } : {}), + title: typeof tc.name === "string" && tc.name ? tc.name : "tool", + status: mapped, + // The ACP header is `→ name `; forwarding the + // PARSED input (rather than re-encoding it) is what lets + // `applyToolUpdate`'s own `JSON.stringify` produce the identical + // string, and it is the only place the `→` line's payload is + // decided. + ...(tc.input !== undefined && tc.input !== null ? { rawInput: tc.input } : {}), + ...(outText ? { rawOutput: { content: [{ type: "text", text: outText }] } } : {}), + ...(tc.error !== undefined && tc.error !== null + ? { error: typeof tc.error === "string" ? tc.error : JSON.stringify(tc.error) } + : {}), + // Forwarded so a future stage-aware caller can tell a Preparing call + // from a Finished one without this module having to grow a field + // for it. `applyToolUpdate` ignores what it does not know. + wireStatus: stage === undefined ? (tc.status ?? null) : stage, + }; +} + +/** + * The text of a tool result, whatever shape the runtime sent. + * + * The runtime parses `tool_call_result_data` into a value; the ACP path + * received a `rawOutput.content[]` array of typed parts. Both reduce to + * one string here, and a non-string result (a number, a bare array, a + * structured preview) is JSON-stringified rather than dropped — a tool + * whose result webui cannot render is still a tool the user ran. + * + * @param {unknown} output + * @returns {string} Empty when there is nothing to show. + */ +function toolResultText(output) { + if (output === undefined || output === null) return ""; + if (typeof output === "string") return output; + if (Array.isArray(output)) { + const parts = output + .map((part) => { + if (typeof part === "string") return part; + if (part && typeof part === "object" && typeof part.text === "string") return part.text; + return null; + }) + .filter((x) => typeof x === "string" && x !== ""); + if (parts.length > 0) return parts.join("\n"); + } + try { + return JSON.stringify(output); + } catch { + return ""; + } +} + +/** + * The `→ name ` header line, in the ACP path's exact spelling. + * + * Why this is here and not in the runner: `applyToolUpdate` only emits + * a header when it SYNTHESIZES one (a body whose header never arrived), + * and the synthesized form deliberately carries no arguments — it + * exists for a body webui attached to mid-stream, where the args are + * gone. The runtime path is the other case: the first sighting of a + * call DOES have its arguments, and the ACP path prints them + * (`streamAcpPrompt`'s `→ ${name} ${input}`). So the runner writes + * this header itself for a new id and pre-registers the index, which + * makes `applyToolUpdate` take its "header already known" branch and + * write only the body. One home for the header syntax, one home for + * the body syntax, and the two cannot disagree about spacing or the + * double space. + * + * The double space is load-bearing — it is what the ACP line looks like + * and the decoder splits on it — so it is transcribed, not tidied. + * + * @param {object} update A `sendToolUpdate` result. + * @returns {string} + */ +export function sendToolHeaderLine(update) { + const u = update && typeof update === "object" ? update : {}; + const name = typeof u.title === "string" && u.title ? u.title : "tool"; + if (u.rawInput === undefined || u.rawInput === null) return `→ ${name}`; + let input; + try { + input = JSON.stringify(u.rawInput); + } catch { + // A circular argument object is not a reason to lose the header. + return `→ ${name}`; + } + return input ? `→ ${name} ${input}` : `→ ${name}`; +} + +/** + * Project a `TuiTokenUsage` onto the `r.usage` shape the ACP finalize + * reads (`{totalTokens, inputTokens, outputTokens}`). + * + * Returns `null` — not a zeroed object — when the runtime reported + * nothing usable, because the finalize's "no usage" branch is what + * falls back to a length-based estimate. A zeroed object would take + * that branch away and leave the context panel reading zero tokens. + * + * @param {object|null|undefined} usage + * @returns {{totalTokens: number, inputTokens: number, outputTokens: number}|null} + */ +export function sendUsageTotals(usage) { + if (!usage || typeof usage !== "object") return null; + const total = num(usage.totalTokens); + const input = num(usage.inputTokens); + const output = num(usage.outputTokens); + if (total === null && input === null && output === null) return null; + return { + totalTokens: total ?? 0, + inputTokens: input ?? 0, + outputTokens: output ?? 0, + }; +} + +function num(value) { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +// --------------------------------------------------------------------------- +// Pure derivations, part 3 — the red lines the route owns +// --------------------------------------------------------------------------- + +/** + * RED LINE 1 (run-mirror) — whether `cs` is still the session this turn + * is running for. + * + * Mid-turn the user can switch conversations, which re-points `cs` at + * ANOTHER record. A cs mutation after that point stamps this turn's + * engine id, title or chat onto the session the user switched TO. The + * test is the same one the ACP runner applies at bind and at finalize, + * written as one function so the two transports cannot drift: + * + * - no owning record id → the turn never got a draft (a direct + * caller); treat as still viewing. + * - `cs.sessionId` equals the owning record id → still viewing + * (pre-promotion form). + * - `cs.sessionId` equals the engine sid → still viewing + * (post-promotion form, because the record was renamed to the + * engine id at bind time). + * - anything else → the user switched away. + * + * @param {object|null|undefined} cs The requesting client's state. + * @param {string|null|undefined} owningWebuiSessionId + * @param {string|null|undefined} sid The engine sid the turn runs on. + * @returns {boolean} + */ +export function sendStillViewing(cs, owningWebuiSessionId, sid) { + if (!owningWebuiSessionId) return true; + if (!cs) return false; + if (cs.sessionId === owningWebuiSessionId) return true; + if (sid != null && cs.sessionId === sid) return true; + return false; +} + +/** + * RED LINE 2 (finalize drain) — rewrite the last `●` line of a drained + * run buffer to the authoritative answer. + * + * Mirrors the route's own in-place rewrite, which only ever runs when + * the user is still viewing. The runtime path needs the same operation + * over a DETACHED list, because a turn that ended while the user was + * elsewhere must still record the final answer text against the run's + * own lines rather than the other session's chat. + * + * Two behaviours are load-bearing and both are pinned: + * + * - The LAST `●` line wins, scanning from the end. A turn that + * produced several answer segments (a tool call between two of + * them) has more than one, and the final answer is the last. + * - When there is none, the answer is APPENDED. The alternative — + * dropping it — loses the turn's only output on a runtime that + * streams no `●` at all. + * + * Pure: the input array is never mutated, so a caller can compare the + * before and after. + * + * @param {string[]} lines The drained run-chat lines. + * @param {string|null} oneLine The authoritative one-line answer. + * @returns {string[]} A new array; the input is untouched. + */ +export function rewriteDrainedAnswerLine(lines, oneLine) { + if (!Array.isArray(lines) || lines.length === 0) return []; + const out = lines.slice(); + if (oneLine === null || oneLine === undefined) return out; + for (let i = out.length - 1; i >= 0; i--) { + if (typeof out[i] === "string" && out[i].startsWith("● ")) { + out[i] = `● ${oneLine}`; + return out; + } + } + out.push(`● ${oneLine}`); + return out; +} + +/** + * RED LINE 3 (`promoteDraftToMcodeSid`) is NOT a derivation and is + * deliberately not re-derived here. + * + * The promotion is the route's, and its condition — "the viewed session + * has an engine id" — is already the right one for both transports: + * `promoteDraftToMcodeSid` is itself a no-op when + * `cs.sessionId === cs.mcodeSessionId`, which is the post-bind state of + * every turn. Narrowing the route's condition with a second predicate + * would be a behaviour change on the acp path — the batch's survival + * condition — in exchange for a guarantee the existing guard already + * makes. So the evidence for this red line is a ROUTE test asserting + * the promotion on the runtime path and its absence after a mid-run + * switch, not a new exported function. B8a therefore ships no predicate + * here, and the suite pins that none exists. + */ + +// --------------------------------------------------------------------------- +// KNOWN DEBT +// --------------------------------------------------------------------------- +// +// Recorded here rather than fixed, because each item is a decision that +// belongs to a human and not to a refactor: +// +// 1. THE HARD GATE IS DECLARED BUT NOT EXERCISED. It throws for a +// RESOLVED provider that declares no `streamingSend`, and it is +// unreachable on both transports today: v2 declares `streamingSend: +// full`, and `acp` has no registered provider (M4's registry). B8a +// does not even call it — #12 is unwired until B8b — so the honest +// description of the 501 is "a policy that is stated, tested in +// isolation, and not yet reachable". The suite pins both halves +// (`checked` under `runtime`, `unregistered-transport` and no +// throw under `acp`), so a provider declaration that makes it +// reachable has to be a deliberate edit rather than a surprise. +// +// 2. `resync-required`, `messages-replaced` AND `messages-rewound` ARE +// CLASSIFIED `IGNORE`. The runtime can tell webui that its view of +// the turn diverged — that is what `resync-required` means — and +// webui keeps the last rendered line buffer and says nothing to the +// user. The runner's only surface is a log line, which is the right +// minimum but not a resolution: whether webui should re-derive +// the turn from the engine's own spine on a resync is a product +// question, and it interacts with the mirror-retirement work on +// `lib/transcript.js` (#126), where the question of which lines are +// authoritative is already being re-argued. Deciding it twice, in +// two files, is how the two answers drift. +// +// 3. THE BRIDGE PRODUCES A LOSSY MIRROR, DELIBERATELY. `●` carries a +// single flattened line and `→ name` carries the call's arguments +// as they were at first sighting — the same lossy form the ACP path +// has always produced, and producing anything richer here would +// make the two transports' transcripts incomparable. The +// consequence is that the #126 mirror-retirement criterion must +// recognize the RUNTIME form of a lossy mirror as well as the ACP +// one; the two are the same fact, so the criterion should be +// written once against the line grammar rather than twice against +// the transports. +// +// 4. `/api/stop` CANNOT STOP A RUNTIME TURN, AND IT SAYS SO. The +// runtime runner registers NO active child, because the runtime has +// no subprocess for B7's kill cascade to signal and inventing a +// second interrupt protocol outside B7's family would be a worse +// answer than none. What a user pressing stop under the runtime +// transport therefore gets is B7's documented degradation: the +// gentle `session/cancel` refuses (there is no ACP client), no +// child is registered, so `hardKilled` is false — and +// `stopLeftStaleClaim` is TRUE, so the route resets the thinking +// claim and pushes an at-rest state. The panel recovers; the turn +// keeps running in the runtime. That is a truthful "I could not +// stop it", and it is strictly better than the alternative, but it +// is not "stopped". The fix is B7's family, not this one: route +// `abortSession` through the facade when the transport is +// `runtime`, the same way `checkInterruptCapability` already +// resolves the provider for that family. Until then the runtime +// transport has no user-reachable abort, and that difference +// between transports is a product decision about when `runtime` +// becomes the default, not a refactor. +// +// 5. ATTACHMENTS REACH THE RUNTIME WITHOUT A MIME TYPE. webui's +// upload pipeline (`lib/attachments.js#resolveAttachment`) keeps +// `{path, name, size}` and discards everything else, so +// `projectSendAttachments` sends `application/octet-stream` — a +// truthful default rather than a guess, and a real limitation: a +// runtime that dispatches on mime type will treat an image as a +// file. The fix is upstream of this module (retain the type at +// upload time) and changes the stored record shape, so it is a +// separate change with its own compatibility question. +// +// 6. THE CONTEXT LIMIT IS NOT BRIDGED FROM THE STREAM. The runtime's +// `TokenUsage` carries `context_window`, but the TUI projection +// (`TuiTokenUsage`) does not forward it, so `sendUsageTotals` can +// produce the three totals the finalize accumulates and nothing for +// `cs.context.limit`. The limit therefore arrives, as it does on +// ACP, only through the post-finalize mavis DB re-query. Writing a +// projection change in `packages/tui` from a webui batch would +// invert the dependency direction the M1 split established, so it is +// recorded rather than done. +// +// 7. THE RUNTIME DOES NOT RECEIVE THE USER'S MODEL PICK. The ACP +// runner pre-applies `applyRecordedModel` to a brand-new session so +// the engine runs the model the chip claims; the runtime runner does +// not, because that helper speaks ACP's `session/set_config_option` +// and the runtime's equivalent is B10's `selectSessionModel`. So +// under `runtime` a FIRST turn runs the runtime's own default and +// the chip may disagree — the exact defect `applyRecordedModel` was +// written to prevent, bounded to a session's first turn. B10 closes +// it; until then `runtime` is opt-in and the disagreement is +// visible rather than silent. +// +// 8. TRANSPORT SELECTION IS AN ENV READ, NOT A REGISTRY LOOKUP. The +// branch in `routes/chat.js` compares `MCODE_WEBUI_TRANSPORT` +// against the literal `"runtime"`, where the plan says selection +// should read the provider registry. M4 owns the registry, and +// hard-coding a second place that knows provider ids before one +// exists is the thing M4 exists to remove. This batch deliberately +// does not create a premature registry. + +// --------------------------------------------------------------------------- +// Data plane +// --------------------------------------------------------------------------- + +/** + * Open a runtime turn's event stream. + * + * The ONLY function in this file that touches the engine. Everything + * above it is pure, and everything below it is the runner's loop — so + * the whole bridge is testable without a runtime, a host, or a clock. + * + * Three things happen here, in this order, and each is a fact the route + * depends on: + * + * 1. THE SESSION IS RESOLVED OR CREATED. The runtime addresses turns + * by an engine session id, exactly like ACP; a first turn has + * none, so the runner asks the host to create one. The returned + * id is the one the whole rest of the turn (claim, buffer, bind, + * promotion) must use. + * 2. THE REQUEST IS ASSEMBLED. `content` is the text webui already + * validated; attachments are projected from webui's + * `{path, name, size}` into the runtime's `{meta, local}` pair. + * The mime type is a known gap, not an oversight — see KNOWN + * DEBT 2. + * 3. THE STREAM OPENS. The per-turn wrapper is what turns a throw + * into a `{type:"error"}` frame instead of a rejected iterator; + * the runner's loop therefore never has to distinguish "the + * engine crashed" from "the engine reported a crash", and cannot + * leave a half-drawn turn behind the first rejection. + * + * @param {object} options + * @param {string|null|undefined} options.sessionId Existing engine session id. + * @param {string} options.content Prompt text. + * @param {object[]} [options.attachments] webui attachments. + * @param {string} options.workspaceDir Working directory for the session. + * @param {AbortSignal} [options.signal] + * @param {object} [options.deps] Injection seam: `{getHost, createTurnHost}`. + * @returns {Promise<{ok: true, sessionId: string, stream: AsyncIterable}|{ok: false, sessionId: string|null, message: string}>} + */ +export async function openEngineSendStream(options = {}) { + const deps = options.deps || {}; + const [{ getEngineCatalogueHost }, runtimeHost, attachments] = await Promise.all([ + import("./host.js"), + import("../lib/runtime-host.js"), + import("../lib/attachments.js"), + ]); + const getHost = deps.getHost || getEngineCatalogueHost; + const catalogue = await getHost(); + if (!catalogue) { + return { + ok: false, + // The id the turn WAS addressed to, not null: the caller's error + // path and the anomaly alert both name the conversation, and a + // null here would drop exactly the datum that makes the failure + // diagnosable. + sessionId: options.sessionId || null, + message: "Runtime host unavailable (catalogue host did not boot)", + }; + } + // The per-turn wrapper. Created per turn on purpose — it owns the + // AbortController its `abortSession` trips, and sharing one across + // turns would let a stop on conversation A abort conversation B. + const createTurnHost = deps.createTurnHost || runtimeHost.createTurnHost; + let turn; + try { + turn = createTurnHost(catalogue); + } catch (e) { + return { ok: false, sessionId: options.sessionId || null, message: e.message }; + } + let sid = options.sessionId || null; + try { + if (!sid) { + const created = await catalogue.adapter.createSession({ + workspaceDir: options.workspaceDir, + }); + sid = created && created.sessionId ? created.sessionId : null; + if (!sid) { + turn.close(); + return { ok: false, sessionId: null, message: "Runtime createSession returned no sessionId" }; + } + } + const stream = turn.sendMessage( + { + id: sid, + content: options.content, + attachments: projectSendAttachments(options.attachments, attachments), + }, + options.signal, + ); + return { ok: true, sessionId: sid, stream, turnHost: turn }; + } catch (e) { + turn.close(); + return { ok: false, sessionId: sid, message: e.message }; + } +} + +/** + * Project webui's attachment records onto the runtime's request shape. + * + * Split out of `openEngineSendStream` and exported because it is pure + * and because it is where a shape drift would be silent: a wrong key + * does not throw, it just means the model never sees the file. + * + * @param {object[]|undefined} list + * @param {object} attachmentsLib The `lib/attachments.js` namespace (injected for the test). + * @returns {object[]} + */ +export function projectSendAttachments(list, attachmentsLib) { + if (!Array.isArray(list) || list.length === 0) return []; + const limit = + typeof attachmentsLib?.MAX_ATTACHMENTS_PER_TURN === "number" + ? attachmentsLib.MAX_ATTACHMENTS_PER_TURN + : list.length; + return list.slice(0, limit).map((a) => ({ + meta: { + attachmentType: "file", + fileName: a && a.name ? a.name : "attachment", + // webui's upload pipeline keeps no mime type, so the runtime is + // told the honest default rather than a guess. KNOWN DEBT 2. + mimeType: "application/octet-stream", + ...(typeof a?.size === "number" ? { sizeBytes: a.size } : {}), + }, + local: { ...(a && a.path ? { filePath: a.path } : {}) }, + })); +} diff --git a/packages/webui/server/lib/authorize.js b/packages/webui/server/lib/authorize.js index 77174943c..6478a8715 100644 --- a/packages/webui/server/lib/authorize.js +++ b/packages/webui/server/lib/authorize.js @@ -10,6 +10,14 @@ // silent fallback to "approved" is the root cause of accidental // destructive actions). // +// The timeout is the budget for a HUMAN who was shown a modal. It is +// not the right answer to "nobody was ever shown one": when the +// request's target has no live SSE connection, the decision channel is +// empty, the fail-closed answer is already determined, and the caller +// is told so at once (`auth.unreachable`) instead of holding a +// destructive HTTP request open for the full budget. See +// `state-bus.js#hasDecisionListener`. +// // Audit: every approve / reject / timeout writes one NDJSON event via // the static import of `server/lib/events.js`. The DECISION-OUTCOME // audit write (auth.approve/reject/timeout/cancelled) is @@ -37,9 +45,11 @@ // token.reset rotate the LAN auth token // slash.clear /clear and /new on the chat stream // startup.cleanup boot-time orphan sweep +// auth.unreachable (audit kind, not an action) the request was +// raised with no connected client to answer it import { randomUUID } from "node:crypto"; -import { pushAuthRequest, pushAuthDecision } from "./state-bus.js"; +import { pushAuthRequest, pushAuthDecision, hasDecisionListener } from "./state-bus.js"; import { pushAlert } from "./alerts.js"; import { append as _eventsAppend } from "./events.js"; import { readJson } from "./read-json.js"; @@ -68,6 +78,12 @@ function _isValidAction(action) { // requestId → { resolve, timer, action, ctx, requestedAt, expiresAt } const _pending = new Map(); +// Test-only: true while an in-process decision driver is attached. See +// `_setInProcessDeciderAttached` — it is false for the whole of a +// production process, and it can only ever turn the unreachability +// short-circuit OFF, never on. +let _inProcessDeciderAttached = false; + // ---------- audit ---------- // _tryWriteEvent — synchronous, loud-but-non-blocking audit write. @@ -165,6 +181,55 @@ export function authorize(action, ctx = {}, opts = {}) { const safeCtx = ctx && typeof ctx === "object" ? ctx : {}; return new Promise((resolve) => { + // Can this request be decided at all? The gate is a push to a live + // SSE response, so "no connected client" is not a slow answer, it is + // the absence of one: the only outcome still reachable is the + // fail-closed timeout, `timeoutMs` from now. Making the caller wait + // out the whole budget to arrive at an answer that was already + // determined is what a destructive request looks like when it hangs — + // an open socket, no status, no body, for five minutes. + // + // So reach that answer now instead. This is a wait that is removed, + // not a wait that is shortened: the resolution value, the + // `authorization_decided` frame other tabs mirror, and the fail-closed + // posture are the timeout path's, and nothing is ever approved + // without a decision. + // + // A bus that cannot answer the question — an older build, a test + // double that does not model the connection registry — is NOT + // evidence that nobody is listening, so it keeps the previous + // behaviour and waits for the timeout. Only a bus that positively + // reports an empty channel short-circuits. + let answerable = true; + try { + answerable = hasDecisionListener(cid) !== false; + } catch { + answerable = true; + } + if (!answerable && !_inProcessDeciderAttached) { + const decidedAt = Date.now(); + _tryWriteEvent({ + kind: "auth.unreachable", + target: action, + cid: cid || null, + data: { + requestId, + requestedAt, + expiresAt, + timeoutMs, + reason: "no_connected_client", + metadata: opts.metadata || null, + }, + }); + // Same frame the timeout path emits, so a tab that reconnects and + // replays its state closes any modal it may have optimistically + // opened on its own request. + try { + pushAuthDecision({ requestId, approved: false, decidedBy: "timeout" }); + } catch {} + resolve({ approved: false, decidedBy: "timeout", decidedAt }); + return; + } const timer = setTimeout(() => { const entry = _pending.get(requestId); if (!entry) return; @@ -281,6 +346,50 @@ export function getPendingRequestIds() { return Array.from(_pending.keys()); } +/** + * The pending requests with the client each one is waiting on. + * + * Exists beside `getPendingRequestIds` for one reason: a request only + * stays pending while its target has a live connection + * (`state-bus.js#hasDecisionListener`), so a caller that drives decisions + * in-process has to present a connection for the right client before it + * can decide anything. A test that models only the decision and not the + * channel is not modelling the product. + * + * @returns {Array<{requestId: string, cid: string, action: string}>} + */ +export function getPendingTargets() { + return Array.from(_pending, ([requestId, entry]) => ({ + requestId, + cid: (entry.ctx && entry.ctx.cid) || "", + action: entry.action, + })); +} + +/** + * Declare that a decision driver is attached in-process, which makes a + * pending request answerable regardless of the connection registry. + * + * The unreachability short-circuit above asks "could anybody be asked?", + * and in production the only thing that can answer is a live SSE + * connection. A test harness that drives decisions through + * `_decideForTests` IS that somebody — it just has no socket — and it + * cannot know which cid a route is about to use before the route runs. + * This flag is how it says so, and it is the same claim the harness + * already makes by deciding the request at all. + * + * It is false for the whole of a production process, and it can only + * DISABLE the short-circuit — it can never turn an empty channel into an + * approval, and it never bypasses the gate itself. A test that wants the + * short-circuit's behaviour simply does not attach a decider, which is + * how `test/routes/sessions.check.mjs` pins the unreachable DELETE. + * + * @param {boolean} attached + */ +export function _setInProcessDeciderAttached(attached) { + _inProcessDeciderAttached = !!attached; +} + export function _resetForTests() { // Resolve every pending request as "reset" so any awaiting test // does not hang; clear timers and the registry. diff --git a/packages/webui/server/lib/mcode-acp.js b/packages/webui/server/lib/mcode-acp.js index a87a12f3d..c70a0f005 100644 --- a/packages/webui/server/lib/mcode-acp.js +++ b/packages/webui/server/lib/mcode-acp.js @@ -44,6 +44,49 @@ import { isSubagentDispatch, readSubagentStatusForToolCall, } from "./agent-team-detect.js"; +// M3-B8 (engine facade): the RUNTIME transport branch's stream bridge. +// Imported from `engine/streaming-send.js` directly rather than through +// `engine/index.js` for the same reason `routes/protocol.js` imports +// `session-reads.js` directly — this file is on the boot path and the +// facade's re-export of this module would be a second name for the +// same bindings. Everything reached from here is either a pure +// derivation or a data-plane function that reaches the host through +// `await import()`, so the import adds no host-construction weight to +// an acp-only server. +// +// These names are the whole contract between the two transports: the +// ACP runner derives its line syntax from the engine's session-update +// vocabulary, and the runtime runner derives the SAME syntax from the +// engine's frame vocabulary, through these functions. +import { + SEND_EVENT_KINDS, + classifySendEvent, + openEngineSendStream, + sendSegmentAdvance, + sendStillViewing, + sendTerminalOutcome, + sendToolHeaderLine, + sendToolUpdate, + sendUsageTotals, +} from "../engine/streaming-send.js"; + +/** + * The one-line form of a multi-line answer or thinking segment. + * + * Byte-identical to the normalization the route applies to `r.answer` + * after the turn (`routes/chat.js#handleSend`) and to the one the ACP + * callback applies per chunk, because the final `●` line has to be + * rewritten with the SAME text whichever transport produced it. It + * lives here rather than in `lib/chat-line.js` because `chat-line.js` + * is the pure line writer and this is a policy about what counts as + * one line — a different question, on a different axis. + * + * @param {string|null|undefined} text + * @returns {string} + */ +function oneLineOf(text) { + return (text || "").replace(/\n+/g, " ").trim(); +} // runMcodeAcp / streamAcpPrompt — mcode acp protocol streaming. // @@ -1293,3 +1336,537 @@ function streamAcpPrompt( }); }); } + +// =========================================================================== +// M3-B8: the RUNTIME transport branch — runMcodeRuntime +// =========================================================================== +// +// Everything above this line is the ACP transport and is unchanged by +// this batch, byte for byte. That is the batch's survival condition: +// under the default `MCODE_WEBUI_TRANSPORT=acp` this file behaves +// exactly as it did at 32277c3a, and `runMcodeAcp` / `streamAcpPrompt` +// were not edited to get there. +// +// What follows is the SIBLING runner for the in-process runtime. It +// exists here rather than in a new module for two reasons, both of +// which are about not duplicating the run-mirror: it must produce the +// SAME `r` object shape (the route's finalize drain, the ● rewrite and +// the draft promotion all read that shape and none of them know which +// transport produced it), and it must write through the SAME +// per-(cid, session) run-chat buffer. Both are facts about the +// accumulator, not about the transport, so the second runner reuses +// the first one's helpers wholesale rather than re-deriving them. +// +// The one thing that IS transport-specific — which line each engine +// event produces, and which line family the accumulator is currently +// in — is NOT here. It lives in `engine/streaming-send.js` as pure +// functions, because that is the part where a regression is invisible +// (a missing `●` line does not crash; the answer just disappears) and +// a pure function is the only shape in which "invisible" is testable. +// +// What this runner deliberately does NOT do: +// +// - It does not register an active child. The runtime has no +// subprocess, and B7's `/api/stop` cascade kills `getActiveChild`. +// Registering the turn host there would be a second interrupt +// protocol invented outside B7's family; see KNOWN DEBT 1 in +// engine/streaming-send.js for the follow-up and for what the user +// sees in the meantime (a truthful "I could not stop it", not a +// fake success). +// - It does not re-implement the finalize's title read-back or the +// mavis usage re-query. Both are already transport-aware: +// `getMcodeSessionTitle` prefers the catalogue host when the +// transport is `runtime`, and `applyMavisUsageToCs` reads the +// runtime's own SQLite either way. Duplicating them here would be +// a second copy of a decision `acp-client.js` already makes. +// - It does not pre-apply the recorded model pick. `applyRecordedModel` +// pushes through ACP's `session/set_config_option`, which has no +// runtime equivalent in this batch; the runtime picks its own +// default. B10 owns set-model on the runtime transport. Recorded +// here rather than silently omitted. + +/** + * Run one turn on the in-process runtime transport. + * + * The mirror of `runMcodeAcp`, down to the parts that are not + * transport-specific: the owning webui record is captured BEFORE the + * first await, the draft→engine bind is applied through the same two + * helpers, and the first-turn session-busy guard is backfilled with + * the same `updateRunSid` call at the same point. + * + * @param {string} content Prompt text. + * @param {object} [opts] + * @param {string} [opts.label] + * @param {string|null} [opts.sessionId] Existing engine session id. + * @param {object} opts.cs + * @param {string} [opts.cid] + * @param {object[]} [opts.attachments] + * @param {string} [opts.owningWebuiSessionId] + * @returns {Promise} The same result shape `runMcodeAcp` resolves. + */ +export async function runMcodeRuntime(content, opts = {}) { + const label = opts.label || "prompt"; + const existingSid = opts.sessionId || null; + const cs = opts.cs; + const cid = opts.cid; + const owningWebuiSessionId = + (typeof opts.owningWebuiSessionId === "string" && opts.owningWebuiSessionId) || + (cs && cs.sessionId) || + null; + const attachments = Array.isArray(opts.attachments) ? opts.attachments : []; + const workspace = (cs && cs.workspace && cs.workspace.dir) || DEFAULT_WORKSPACE; + let opened; + try { + opened = await openEngineSendStream({ + sessionId: existingSid, + content, + attachments, + workspaceDir: workspace, + }); + } catch (e) { + // Opening the stream is the runtime's `client.start()` + `session/new` + // equivalent, and its failures are the same class: a start-phase + // failure that never reaches finalize. Same alert, same shape. + pushAlert({ + level: "error", + msg: `[runtime-send.start] ${e.message}`, + src: "runtime-send", + cid: cid || null, + sessionId: existingSid, + data: { phase: "open" }, + }); + return { + status: "failed", + error: { message: e.message }, + sessionId: existingSid, + answer: null, + thinking: null, + }; + } + if (!opened.ok) { + pushAlert({ + level: "error", + msg: `[runtime-send.start] ${opened.message}`, + src: "runtime-send", + cid: cid || null, + sessionId: opened.sessionId, + data: { phase: "open" }, + }); + return { + status: "failed", + error: { message: opened.message }, + sessionId: opened.sessionId, + answer: null, + thinking: null, + }; + } + const sid = opened.sessionId; + // qa (两条记录), same instant as the ACP runner: the draft is bound + // when the engine session is KNOWN, not when the turn ends. A first + // turn otherwise spends its whole run as a uuid orphan and the + // sidebar shows two records for one conversation. + const stillViewingAtBind = sendStillViewing(cs, owningWebuiSessionId, sid); + try { + if (stillViewingAtBind) { + bindDraftToMcodeSid(cs, sid); + } else { + bindRecordToMcodeSid(owningWebuiSessionId, sid); + } + } catch (e) { + console.warn(`[runtime-send] bindDraftToMcodeSid: ${e.message}`); + } + // First-turn session-busy guard, backfilled at the same moment as the + // ACP path: `handleSend` claimed the run before this turn existed, so + // on a session's first turn the claim was registered with + // `sid: null` and `runsBySid` never guarded the engine session. + updateRunSid(cid, sid, owningWebuiSessionId); + return await streamRuntimePrompt( + opened.stream, + sid, + label, + cs, + cid, + owningWebuiSessionId, + opened.turnHost, + ); +} + +/** + * Drain one runtime turn's event stream into the webui line syntax. + * + * Structurally the same machine as `streamAcpPrompt`: an accumulator + * `r`, a per-event write into the run-chat buffer, a bounded idle + * watchdog, and a `finalize()` that is idempotent and runs exactly + * once. The differences are all visible in the event handler. + * + * @param {AsyncIterable} stream Runtime events. + * @param {string} sid The engine session the turn runs on. + * @param {string} label + * @param {object} cs + * @param {string} [cid] + * @param {string|null} owningWebuiSessionId + * @param {object} [turnHost] Kept on `r` for a future abortSession wiring. + * @returns {Promise} + */ +function streamRuntimePrompt(stream, sid, label, cs, cid, owningWebuiSessionId, turnHost) { + return new Promise((resolve) => { + const r = { + answer: null, + thinking: null, + status: "unknown", + error: null, + usage: null, + sessionId: sid, + durationMs: null, + stopReason: null, + tps: null, + // session-isolation/06: the per-segment discriminator, the same + // field the ACP path keeps. A tool call breaks the chain so the + // next text chunk starts a NEW `●` line instead of appending to + // the previous segment's. + lastChunkKind: null, + // session-isolation/02 (run-mirror): identical role to the ACP + // accumulator's — see the comment there. + owningSessionId: sid, + owningWebuiSessionId, + turnHost: turnHost || null, + chatArray() { + const m = runChatLinesFor(cid, sid); + return m !== null ? m : cs.chat; + }, + }; + const t0 = Date.now(); + cs.running = { + active: true, + prompt: label, + // No pid: the runtime is in-process, and a number here would be + // a fabricated one. The field is the ACP path's shape and the + // panel reads `active`, not `pid`. + pid: null, + startedAt: t0, + model: cs.model ? cs.model.name : null, + sessionId: sid, + lastDeltaAt: t0, + tps: 0, + }; + cs.context.thinkingStatus = "Running"; + pushStateFor(cid); + // session-isolation/02 (run-mirror): the per-(cid, owning-session) + // buffer, created before the first event can arrive, scoped to THIS + // run's previous key so a sibling conversation in the same tab keeps + // its own lines. + createRunChat(cid, sid, [], owningWebuiSessionId); + const idleSeconds = Math.round(PROMPT_IDLE_TIMEOUT_MS / 1000); + const safetyTimeout = createIdleWatchdog({ + idleMs: PROMPT_IDLE_TIMEOUT_MS, + activityAt: () => cs.running.lastDeltaAt || t0, + onTimeout: () => { + if (r.status === "unknown") { + r.status = "timeout"; + r.error = { + message: `mcode runtime prompt inactive for ${idleSeconds}s (no stream events)`, + }; + pushAlert({ + level: "warn", + msg: `[runtime-send.timeout] prompt inactive for ${idleSeconds}s`, + src: "runtime-send", + cid: cid || null, + sessionId: sid || null, + data: { phase: "stream" }, + }); + finalize(); + } + }, + }); + function finalize() { + if (r._finalized) return; + r._finalized = true; + safetyTimeout.stop(); + r.durationMs = r.durationMs || Date.now() - t0; + const chatTarget = r.chatArray(); + if (typeof r.durationMs === "number" && r.durationMs > 0 && Array.isArray(chatTarget)) { + chatTarget.push(`§§ processed_duration=${Math.round(r.durationMs)}ms`); + } + if (typeof r.assistantMessageId === "string" && r.assistantMessageId && Array.isArray(chatTarget)) { + chatTarget.push(`§§ turn_msg=${r.assistantMessageId}`); + } + // The per-turn wrapper owns no resources of its own, but it holds + // an AbortController and a set of live stream references; dropping + // it here is what keeps a long-lived tab from accumulating one + // per turn. + if (r.turnHost && typeof r.turnHost.close === "function") { + try { + r.turnHost.close(); + } catch {} + } + cs.running = { + active: false, + prompt: null, + pid: null, + startedAt: null, + model: null, + sessionId: null, + lastDeltaAt: null, + tps: 0, + }; + cs.context.thinkingStatus = "Idle"; + cs.context.tps = 0; + if (Array.isArray(chatTarget)) { + for (let i = 0; i < chatTarget.length; i += 1) { + const line = chatTarget[i]; + if (typeof line === "string" && line.endsWith(" ▍")) { + chatTarget[i] = line.slice(0, -2); + } + } + } + if (r.usage) { + cs.context.tokens = (cs.context.tokens || 0) + (r.usage.totalTokens || 0); + cs.context.used = cs.context.tokens; + cs.context.percent = computeContextPercent(cs.context.tokens, cs.context.limit); + cs.context.lastUsageAt = Date.now(); + cs.usage.sessionInput = (cs.usage.sessionInput || 0) + (r.usage.inputTokens || 0); + cs.usage.sessionOutput = (cs.usage.sessionOutput || 0) + (r.usage.outputTokens || 0); + cs.usage.sessionTotal = cs.usage.sessionInput + cs.usage.sessionOutput; + cs.context.estimated = false; + } + // session-isolation/02 (run-mirror): the same still-viewing test + // as the ACP finalize, now one shared function so the two + // transports cannot drift. + const stillViewingAtFinalize = sendStillViewing(cs, owningWebuiSessionId, r.sessionId); + if (r.sessionId && stillViewingAtFinalize) { + cs.mcodeSessionId = r.sessionId; + } + if (r.sessionId) { + const finalSid = r.sessionId; + const bindTargetId = stillViewingAtFinalize ? cs.sessionId : owningWebuiSessionId; + // The same fire-and-forget DB re-query the ACP finalize does. + // `applyMavisUsageToCs` reads the runtime's own SQLite, so it + // is the same code path on this transport, not a second + // implementation of it. + setTimeout(() => { + applyMavisUsageToCs(cs, finalSid, { getMcodeModelLimit }) + .then(() => pushStateFor(cid)) + .catch((e) => { + if (process.env.MCODE_USAGE_DEBUG) { + console.warn(`[usage.mavis.runtime] cid=${cid} error: ${e.message}`); + } + pushStateFor(cid); + }); + }, 400); + // Title read-back. `getMcodeSessionTitle` is already + // transport-aware (it prefers the catalogue host under + // `runtime`), so this is the SAME call the ACP finalize makes, + // not a runtime-only one. + getMcodeSessionTitle(finalSid) + .then((title) => { + if (bindTargetId) { + try { + const all = loadSessions(); + const item = + all.find((s) => s && s.id === bindTargetId) || + all.find((s) => s && s.mcodeSessionId === finalSid); + if (item && item.mcodeSessionId !== finalSid) { + item.mcodeSessionId = finalSid; + item.updatedAt = Date.now(); + saveSessions(all); + } + } catch (e) { + console.warn(`[runtime-send] save mcodeSid failed: ${e.message}`); + } + } + if (!title) return; + const isDefault = + !cs.sessionTitle || + cs.sessionTitle === "New session" || + cs.sessionTitle === "Untitled"; + if (isDefault && cs.mcodeSessionId === finalSid) { + cs.sessionTitle = title; + } + try { + const all = loadSessions(); + const item = + all.find((s) => s && s.id === bindTargetId) || + all.find((s) => s && s.mcodeSessionId === finalSid); + if (item && !item.titleCustom && item.title !== title) { + const recordIsDefault = + !item.title || + item.title === "New session" || + item.title === "Untitled" || + item.title === "Mcode session"; + if (recordIsDefault) { + item.title = title; + item.updatedAt = Date.now(); + saveSessions(all); + } + } + } catch (e) { + console.warn(`[runtime-send] save title failed: ${e.message}`); + } + if (stillViewingAtFinalize) pushStateFor(cid); + }) + .catch((e) => console.warn(`[runtime-send] getMcodeSessionTitle: ${e.message}`)); + invalidateMcodeSessionsCache(); + getMcodeSessionsForWorkspace(cs.workspace && cs.workspace.dir) + .then(() => pushStateFor(cid)) + .catch(() => {}); + } + pushStateFor(cid); + resolve(r); + } + /** + * Fold one classified event into the accumulator and the line + * buffer. Kept as a closure so `finalize` is in scope for the + * terminal branches. + * + * @param {object} event A runtime stream event. + */ + function onEvent(event) { + const now = Date.now(); + if (cs.running.lastDeltaAt) { + const dt = (now - cs.running.lastDeltaAt) / 1000; + if (dt > 0) cs.running.tps = Math.round(1 / dt); + } + cs.running.lastDeltaAt = now; + cs.context.tps = cs.running.tps; + + const c = classifySendEvent(event); + if (c.kind === SEND_EVENT_KINDS.TOOL) { + if (!r.toolIndexById) r.toolIndexById = new Map(); + const toolChat = r.chatArray(); + for (const tc of c.toolCalls) { + const u = sendToolUpdate(tc); + // A call that was ALREADY announced contributes no second + // `→ name` header: the runtime re-sends the whole call on + // every chunk of its lifecycle (Preparing → Prepared → + // Finished), and the ACP path emitted one header per event. + // The header for a NEW id is written here — with its + // arguments, which `applyToolUpdate`'s synthesized header + // deliberately omits — and the index is pre-registered so + // the shared reducer takes its "header already known" branch + // and writes only the body. + if (typeof u.toolCallId === "string" && u.toolCallId && !r.toolIndexById.has(u.toolCallId)) { + toolChat.push(`##tc:${u.toolCallId}`); + toolChat.push(sendToolHeaderLine(u)); + r.toolIndexById.set(u.toolCallId, toolChat.length - 1); + } + applyToolUpdate(r, cs, u, { cid }); + } + r.lastChunkKind = "tool_call"; + if (typeof c.text === "string" && c.text) { + const step = sendSegmentAdvance(r.lastChunkKind, SEND_EVENT_KINDS.MESSAGE, r.answer || "", c.text); + r.answer = step.text; + r.lastChunkKind = step.lastKind; + streamUpdateLine(r.chatArray(), "●", oneLineOf(r.answer)); + } + pushStateFor(cid); + return; + } + if (c.kind === SEND_EVENT_KINDS.MESSAGE) { + const step = sendSegmentAdvance(r.lastChunkKind, SEND_EVENT_KINDS.MESSAGE, r.answer || "", c.text); + r.answer = step.text; + r.lastChunkKind = step.lastKind; + streamUpdateLine(r.chatArray(), "●", oneLineOf(r.answer)); + pushStateFor(cid); + return; + } + if (c.kind === SEND_EVENT_KINDS.THOUGHT) { + const step = sendSegmentAdvance(r.lastChunkKind, SEND_EVENT_KINDS.THOUGHT, r.thinking || "", c.text); + r.thinking = step.text; + r.lastChunkKind = step.lastKind; + streamUpdateLine(r.chatArray(), "▲", oneLineOf(r.thinking)); + pushStateFor(cid); + return; + } + if (c.kind === SEND_EVENT_KINDS.AUTHORITATIVE) { + // The settled message. It OVERWRITES the accumulated segment + // rather than appending, which is the whole reason the runtime + // can be lossless where the ACP path is approximate: whatever + // the deltas dropped or duplicated, this is the turn's real + // text. An empty settled message does not clear a good + // accumulation — a runtime that closes with a tool-only message + // would otherwise erase the answer. + if (typeof c.text === "string" && c.text.trim()) r.answer = c.text; + if (typeof c.thinking === "string" && c.thinking.trim()) r.thinking = c.thinking; + if (c.usage) { + const totals = sendUsageTotals(c.usage); + if (totals) r.usage = totals; + } + if (typeof c.finishReason === "string") r.stopReason = c.finishReason; + if (typeof c.messageId === "string" && c.messageId) r.assistantMessageId = c.messageId; + pushStateFor(cid); + return; + } + if (c.kind === SEND_EVENT_KINDS.TERMINAL) { + if (c.status) { + const outcome = sendTerminalOutcome(c.status); + r.status = outcome.status; + if (outcome.errorMessage) r.error = { message: c.errorMessage || outcome.errorMessage }; + } else { + r.status = "failed"; + r.error = { message: c.errorMessage || "Runtime stream failed" }; + pushAlert({ + level: "error", + msg: `[runtime-send.stream] ${r.error.message}`, + src: "runtime-send", + cid: cid || null, + sessionId: sid || null, + data: { phase: "stream" }, + }); + } + if (r.status === "succeeded") { + const note = buildEmptyTurnNote(r.stopReason, r.answer); + if (note) r.chatArray().push(note); + } + finalize(); + return; + } + // STREAM and IGNORE produce no line. `resync-required` is the one + // IGNORE worth an operator's attention: it means webui's view of + // the turn diverged from the runtime's, and the log line is the + // only place that fact surfaces. + if (event && event.type === "resync-required") { + console.warn( + `[runtime-send] runtime asked for a resync mid-turn (cid=${cid} sid=${sid}); the webui line buffer keeps the last rendered state`, + ); + } + pushStateFor(cid); + } + + (async () => { + try { + for await (const event of stream) { + onEvent(event); + if (r._finalized) break; + } + if (!r._finalized) { + // The stream ended without a terminal event. The runtime + // closes with `[DONE]` mapped to `{type:'done'}`, so this is + // only reachable if a transport-level truncation dropped the + // last frames; treating it as success would render an + // unfinished turn as a complete one. + r.status = "failed"; + r.error = { message: "Runtime stream ended without a terminal event" }; + finalize(); + } + } catch (e) { + // The per-turn wrapper already converts engine throws into + // `{type:'error'}` frames, so a rejection here means the + // ITERATOR itself failed (a broken async generator, a bad + // `return()`). Same class as the ACP `.catch`. + if (!r._finalized) { + r.status = "failed"; + r.error = { message: e.message }; + pushAlert({ + level: "error", + msg: `[runtime-send.stream] ${e.message}`, + src: "runtime-send", + cid: cid || null, + sessionId: sid || null, + data: { phase: "iterator" }, + }); + finalize(); + } + } + })(); + }); +} diff --git a/packages/webui/server/lib/state-bus.js b/packages/webui/server/lib/state-bus.js index f53518132..f526b9e11 100644 --- a/packages/webui/server/lib/state-bus.js +++ b/packages/webui/server/lib/state-bus.js @@ -1235,6 +1235,45 @@ export function getSseClient(cid) { return sseByCid.get(cid) || null; } +/** + * Is there a live connection that could be asked to decide a pending + * authorization request raised on behalf of `cid`? + * + * The gate is a PUSH: `pushAuthRequest` writes one `needs_authorization` + * frame into the target's SSE response, and the answer arrives on a + * different connection through `POST /api/auth/decision`. When nothing is + * listening, there is nobody who can ever answer, so the only outcome the + * request can reach is the fail-closed timeout — up to five minutes away, + * with the caller's HTTP request (a destructive one) held open and + * nothing written. `lib/authorize.js` calls this to reach that same + * fail-closed answer immediately instead of after the whole budget. The + * ANSWER is unchanged; only the wait is removed, which is the difference + * between a request that ends and one that appears to hang. + * + * A registered response only counts when its socket can still be written. + * A tab that reloaded, navigated or lost its connection leaves its + * `ServerResponse` in the map until the close handler runs, and a write to + * it is a silent no-op — the frame is dropped on the floor, so the request + * is unanswerable even though the map is non-empty. That is the case a + * size check alone would miss, and the case a user experiences as "the + * delete button stopped responding". + * + * An empty `cid` is the broadcast case — `startup.cleanup` deliberately + * asks every connected tab — and is answerable whenever ANY tab is + * connected. + * + * @param {string} cid Requesting client id; empty means broadcast. + * @returns {boolean} True when at least one live response can be written to. + */ +export function hasDecisionListener(cid) { + for (const [key, res] of sseByCid) { + if (cid && key !== cid) continue; + if (!res || res.writableEnded === true || res.destroyed === true) continue; + return true; + } + return false; +} + export function setSseClient(cid, res) { sseByCid.set(cid, res); // When an SSE client (re)connects, the previous diff cache + diff --git a/packages/webui/server/lib/transcript.js b/packages/webui/server/lib/transcript.js index 91d61f125..587b4f193 100644 --- a/packages/webui/server/lib/transcript.js +++ b/packages/webui/server/lib/transcript.js @@ -506,6 +506,16 @@ export function loadTranscriptChatLines(mcodeSid, opts = {}) { // remainder of `read` is engine content this tab has not seen yet (the // foreign-client case the poll exists for) and is appended. // +// One exception, added after that rule shipped: a `current` line that is a +// LOSSY MIRROR of the engine's own output is not authored content, it is a +// whitespace-folded copy of it, and keeping it alongside the engine's +// line-by-line version is what rendered every assistant answer, tool block and +// thinking chain twice. Such a line retires — the engine's lines take its +// place. The identification is a positive content-identity test described +// under "Retiring a lossy mirror" below; it never fires on a line the engine +// read does not already contain, so the slash-command echo this function was +// built to protect cannot be caught by it. +// // The one assumption is that the engine APPENDS — it does not rewrite an // already-emitted line. The switch path has always relied on that (it only // backfills an empty or visibly-cumulative stored chat), and the merge adds @@ -543,6 +553,133 @@ export function loadTranscriptChatLines(mcodeSid, opts = {}) { // the misplacement the comment above rules out. const ANNOTATION_LINE = /^(?:§§\s|##tc:)/; +// ============================================================ +// Retiring a lossy mirror +// ============================================================ +// +// The rule above keeps every `current` line the engine did not match. That +// is right for the lines this server authors and wrong for the lines it +// MIRRORS. While a turn streams, `mcode-acp.js` (and the runtime-transport +// twin, and `routes/chat.js` on finalize) write a lossy copy of the engine's +// own output into the very array the browser reads: +// +// · an answer or a thinking segment is flattened to ONE line +// (`r.answer.replace(/\n+/g, " ").trim()`), where the engine keeps one +// array entry per source line; +// · a tool header is written as `→ bash` when the frame carried no +// `rawInput`, where the engine writes `→ bash {"command":…}`. +// +// Those two shapes can never be byte-equal to the engine's line-by-line copy, +// so the walk above classified every one of them as "webui-authored" and kept +// it — and then appended the engine's whole spine behind it. The user saw the +// assistant answer, the tool block and the thinking chain TWICE, and +// `persistCurrentChat` made the duplicate permanent. Reproduced on a live +// session: 81 stored lines, 14 of them a second copy of engine content that +// had not been there a minute earlier. +// +// The fix is to make a mirror RETIRE when the engine read proves it is one. +// +// The proof is content identity under whitespace folding, anchored at the +// lockstep cursor — not a shape heuristic: +// +// · a prose mirror (`●`/`▲`/`›`/`○`) retires when the maximal run of engine +// lines carrying the SAME glyph, starting at the cursor, folds — their +// texts joined with a single space, all whitespace runs collapsed — to +// exactly the mirror's folded text; +// · a tool header retires when the engine line at the cursor is a header for +// the same tool name; the whole indented block on both sides goes with it, +// because the body the mirror wrote is the same lossy copy. +// +// Why this cannot mistake a genuinely short local message for a mirror: the +// engine must already contain that exact text at that exact cursor position. +// A local line the engine has never seen (`› /help`, `● 当前 model=…`, +// `● 可用命令:`, a `! [warn]` notice) has no fold to match and is kept, which +// is the #126 behaviour this function exists for. A local line that DOES fold +// onto engine text is the same sentence the engine already has, so retiring +// it removes a duplicate rather than content. +// +// Why the failure direction is safe: every judgement here is a positive +// identity test. When it misses — an answer with unusual spacing, a tool block +// the engine has not finished writing — the mirror is kept and the result is +// exactly the old double render, which is the bug we already had. No path in +// this function drops a line the engine read did not account for. + +// One conversation line, split into its role glyph and its text. The glyphs +// are the four `lib/chat-line.js` writers use for prose; a `→ name` header is +// a tool block, not prose, and is handled separately. +const PROSE_LINE = /^(›|●|▲|○) (.*)$/; +// `→ name` with an optional two-space args tail. The name is the first +// whitespace-delimited token, so a mirror that lost its args still names the +// same tool as the engine's complete header. +const TOOL_HEADER = /^→ (\S+)/; +// The indented body of a tool block. A bare whitespace-only line counts: the +// streaming writer emits one as a block terminator (`" "`), and cutting the +// body short there would leave the rest of the mirror stranded. +const INDENTED_LINE = /^\s{2,}/; + +/** Collapse every whitespace run to one space and trim — the fold a lossy + * mirror applies, and the only normalisation applied to engine text. */ +function _fold(text) { + return String(text == null ? "" : text).replace(/\s+/g, " ").trim(); +} + +/** End of the `current` line's block: the line itself plus any indented + * tool body under it. A prose line is never followed by an indented line, so + * this is the identity for the prose case. */ +function _blockEnd(lines, at) { + let k = at + 1; + while (k < lines.length && INDENTED_LINE.test(lines[k])) k += 1; + return k; +} + +/** End of the maximal run of same-glyph prose lines starting at `at`. */ +function _proseRunEnd(lines, at, glyph) { + let k = at; + while (k < lines.length) { + const m = PROSE_LINE.exec(lines[k]); + if (!m || m[1] !== glyph) break; + k += 1; + } + return k; +} + +/** + * How many `dbLines` the `current` line at `i` is a lossy mirror of, or 0. + * + * Returns a span, not a boolean: the engine's own lines in that span are what + * the caller emits, so a mirror spanning nine `▲` entries is replaced by all + * nine, and none of them is appended a second time at the tail. + */ +function _mirrorSpan(haveLines, i, dbLines, j) { + const line = haveLines[i]; + if (typeof line !== "string") return 0; + + const prose = PROSE_LINE.exec(line); + if (prose) { + const target = _fold(prose[2]); + // A bare `● ` placeholder is a blank line of a multi-line message, not a + // fold of anything; the engine emits those too and they pair by equality. + if (!target) return 0; + const end = _proseRunEnd(dbLines, j, prose[1]); + if (end === j) return 0; + const parts = []; + for (let k = j; k < end; k += 1) parts.push(PROSE_LINE.exec(dbLines[k])[2]); + return _fold(parts.join(" ")) === target ? end - j : 0; + } + + const tool = TOOL_HEADER.exec(line); + if (tool) { + const head = TOOL_HEADER.exec(typeof dbLines[j] === "string" ? dbLines[j] : ""); + // Ordered consumption: the walk is in lockstep, so "the same tool name at + // the cursor" already means the Nth `→ name` on each side are the same + // call, however many calls of that name the turn made. + if (!head || head[1] !== tool[1]) return 0; + return _blockEnd(dbLines, j) - j; + } + + return 0; +} + export function mergeEngineTranscript(read, current) { const dbLines = Array.isArray(read) ? read : []; const haveLines = Array.isArray(current) ? current : []; @@ -565,6 +702,16 @@ export function mergeEngineTranscript(read, current) { j += 1; continue; } + // A lossy mirror of engine content the cursor is sitting on. The engine's + // own lines take the mirror's place — emitted here, so the tab sees the + // complete per-line version, and not re-appended at the tail below. + const span = _mirrorSpan(haveLines, i, dbLines, j); + if (span > 0) { + for (let k = j; k < j + span; k += 1) merged.push(dbLines[k]); + j += span; + i = _blockEnd(haveLines, i); + continue; + } // Not the engine's line at this position — keep it and leave the // engine cursor alone so the next `have` line can still line up. merged.push(line); diff --git a/packages/webui/server/routes/chat.js b/packages/webui/server/routes/chat.js index 23e9d8577..96e45071e 100644 --- a/packages/webui/server/routes/chat.js +++ b/packages/webui/server/routes/chat.js @@ -34,7 +34,7 @@ import { cmdButtonCommandList, isSendSlashCommand, } from "../lib/interaction/command-registry.js"; -import { runMcodeAcp } from "../lib/mcode-acp.js"; +import { runMcodeAcp, runMcodeRuntime } from "../lib/mcode-acp.js"; import { collectExecResult, runMcodeExec } from "../lib/mcode-exec.js"; // M3-B7 (engine facade): #13 `/api/stop` no longer reaches into // `lib/mcode-rpc.js#cancelSession` and `lib/state-bus.js#getActiveChild` @@ -48,7 +48,22 @@ import { collectExecResult, runMcodeExec } from "../lib/mcode-exec.js"; // about the first decision rather than about the process, and the // escalation bound is part of the contract. import { applyEngineStop } from "../engine/interrupt.js"; -import { DEFAULT_MODEL } from "../lib/config.js"; +// M3-B8 (engine facade): the #12 send gate. `assertStreamingSendCapability` +// is the only thing this route borrows from the facade for the send +// family, and it is called at exactly one place — BEFORE the run claim +// is taken — so a provider that declares no send surface is refused +// with the shared 501 instead of being acked a turn that never runs. +// Before the claim rather than after it, because the throw would then +// land outside the `try` whose `finally` releases the claim, and a +// leaked claim refuses every later send in that conversation. See +// `engine/streaming-send.js`'s header for why this family gates HARD +// where B7's gates soft, and for the two facts that make it +// unreachable today (v2 declares `streamingSend: full`, and `acp` has +// no registered provider at all). The route does not build the 501 +// response: `app.js#invokeHandler` maps the thrown error, so no +// response code is added here. +import { assertStreamingSendCapability } from "../engine/streaming-send.js"; +import { DEFAULT_MODEL, MCODE_WEBUI_TRANSPORT } from "../lib/config.js"; import { resolveAttachments } from "../lib/attachments.js"; import { readJson } from "../lib/read-json.js"; @@ -130,6 +145,14 @@ export async function handleSend(req, res, ctx) { // ask_user modal answer — don't add to chat as a user message. const isAskAnswer = payload.isAskAnswer === true; + // M3-B8: the capability gate, before anything is claimed. Throws for + // a RESOLVED provider that declares no `streamingSend`, and + // `app.js#invokeHandler` turns that into the shared 501. On the + // default `acp` transport no provider is registered yet (M4's + // registry), so this is a no-op there and the acp path below is + // untouched — which is the batch's survival condition, not a side + // effect of it. + assertStreamingSendCapability("POST /api/send", MCODE_WEBUI_TRANSPORT); // Claim the turn BEFORE acknowledging. Every prompt spawns its own engine // subprocess, so without this a double-send (retry, two tabs, a scripted // client) silently starts a second one: measured on a running server, ten @@ -241,6 +264,22 @@ export async function handleSend(req, res, ctx) { // mcode acp is the default transport; MCODE_USE_ACP=0 falls back to // mcode exec (escape hatch if the acp protocol regresses). + // + // M3-B8 adds a third branch, the in-process `runtime` transport. Two + // ordering facts are load-bearing and neither is negotiable: + // + // - `MCODE_USE_ACP=0` STILL WINS. `lib/config.js` documents the + // precedence as "MCODE_USE_ACP=0 ⇒ transport=exec (regardless of + // MCODE_WEBUI_TRANSPORT)", because the escape hatch exists for + // exactly the moment a transport is misbehaving — an operator + // who reaches for it must not have to unset a second variable + // first. + // - The runtime branch passes the SAME options object the acp one + // does, including `owningWebuiSessionId`. That id is what makes + // the run-mirror, the finalize drain and the draft promotion + // work identically on both transports: the whole tail below this + // line is transport-agnostic because both runners return the + // same `r` and write through the same run-chat buffer. const modelToUse = (cs && cs.model && cs.model.name) || DEFAULT_MODEL; console.log( `[send] cid=${cid} content=${JSON.stringify(content.slice(0, 80))} model=${modelToUse} sessionId=${cs.mcodeSessionId} workspace=${(cs && cs.workspace && cs.workspace.dir) || "null"}`, @@ -254,9 +293,22 @@ export async function handleSend(req, res, ctx) { // directly in cs.chat; the finalize drain below flushes it. createRunChat(cid, cs && cs.mcodeSessionId, [], owningWebuiSessionId); const t0 = Date.now(); + const sendOptions = { + label: "prompt", + sessionId: cs.mcodeSessionId, + model: modelToUse, + cs, + cid, + attachments, + owningWebuiSessionId, + }; const r = process.env.MCODE_USE_ACP === "0" ? await collectExecResult( + // The exec path never had `owningWebuiSessionId` (it is a + // non-streaming runner with no run-mirror), so it is still + // not passed. Removing the shared object above is what + // guarantees that: exec's own options literal is untouched. runMcodeExec(content, { label: "prompt", sessionId: cs.mcodeSessionId, @@ -266,15 +318,9 @@ export async function handleSend(req, res, ctx) { attachments, }), ) - : await runMcodeAcp(content, { - label: "prompt", - sessionId: cs.mcodeSessionId, - model: modelToUse, - cs, - cid, - attachments, - owningWebuiSessionId, - }); + : MCODE_WEBUI_TRANSPORT === "runtime" + ? await runMcodeRuntime(content, sendOptions) + : await runMcodeAcp(content, sendOptions); console.log( `[send] result ${Date.now() - t0}ms:`, JSON.stringify({ diff --git a/packages/webui/test/helpers/_setup.js b/packages/webui/test/helpers/_setup.js index af48af8cf..3afacea61 100644 --- a/packages/webui/test/helpers/_setup.js +++ b/packages/webui/test/helpers/_setup.js @@ -93,6 +93,20 @@ const _mcodeAcpMock = { sessionId: null, }), streamAcpPrompt: async () => ({ status: "succeeded", answer: "mocked" }), + // M3-B8: the RUNTIME transport branch. `routes/chat.js` imports this + // name unconditionally, so omitting it from this mock makes every + // suite that imports the chat route fail at module-INSTANTIATION time + // with "does not provide an export named 'runMcodeRuntime'" — a + // failure that reads like a product bug and is not one (mock trap #1, + // see the engine suites' headers). The default mirrors the acp + // default byte-for-byte so no existing case changes behaviour; the + // runtime branch's own cases register their own through + // registerMcodeAcpMock(). + runMcodeRuntime: async () => ({ + status: "succeeded", + answer: "mocked", + sessionId: null, + }), }; let _lanBroadcast = false; @@ -536,6 +550,11 @@ export async function setupMocks(t, overrides = {}) { namedExports: { runMcodeAcp: (...a) => _mcodeAcpMock.runMcodeAcp(...a), streamAcpPrompt: (...a) => _mcodeAcpMock.streamAcpPrompt(...a), + // M3-B8: the runtime transport branch. Same dispatch-through + // wrapper as the two above — a spread would snapshot the function + // at setupMocks() time and a later registerMcodeAcpMock() would + // not take effect. + runMcodeRuntime: (...a) => _mcodeAcpMock.runMcodeRuntime(...a), // Ticket 09-02: routes/model.js#handleSetModel translates the // webui id to the engine wire form via `resolveModelId`. The // pure helper is also re-exported through `mcode-acp.js` for @@ -622,6 +641,15 @@ export async function setupMocks(t, overrides = {}) { // mocked) authorize module. Works in both suite modes. // - Robust to handlers that only reach authorize() after an await // (e.g. body parsing): a short interval polls while fn() runs. +// - Presents a LIVE SSE response for each pending request's client. +// authorize() only keeps a request pending while its target has a +// live connection (state-bus.js#hasDecisionListener) — with nobody +// connected the gate fails closed at once, which is correct in +// production and would make every gated test answer "declined". +// Registering the response models the tab whose modal the decision +// stands in for. When the state bus is a test double that does not +// model the connection registry, there is nothing to register and +// the gate keeps its previous behaviour. // // Usage: // const res = await withDecisions( @@ -641,6 +669,31 @@ export async function withDecisions(fn, { approve = true } = {}) { ) { return fn(); } + const bus = await import(absPath("lib/state-bus.js")); + const canHostListener = + typeof bus.setSseClient === "function" && typeof bus.endSseClient === "function"; + // A request only stays pending while its target can be reached at all. + // A live SSE response is how production reaches it; this harness IS the + // decider and has no socket, and it cannot know which cid the route is + // about to use before the route runs. So it both presents connections + // for the cids the test has already registered and declares itself as + // the in-process decider. Neither can approve anything on its own — a + // test that wants the unreachable path simply does not call this. + const setDecider = typeof mod._setInProcessDeciderAttached === "function" + ? mod._setInProcessDeciderAttached + : null; + if (setDecider) setDecider(true); + const targets = canHostListener ? mod.getPendingTargets : null; + const hosted = new Map(); // cid -> fake ServerResponse + const hostListener = (cid) => { + if (!canHostListener || !cid || hosted.has(cid)) return; + const res = { writableEnded: false, destroyed: false, write() { return true; } }; + hosted.set(cid, res); + bus.setSseClient(cid, res); + }; + if (canHostListener) { + for (const cid of bus.clients.keys()) hostListener(cid); + } // Requests that were already pending when withDecisions started // belong to someone else — only decide requests created by fn(). const preExisting = new Set(mod.getPendingRequestIds()); @@ -657,9 +710,16 @@ export async function withDecisions(fn, { approve = true } = {}) { // hold the loop). Same fix shape as lib-authorize's REF'd watchdog // (run 35493384574) — liveness only, zero assertion change. const poll = setInterval(() => { + if (canHostListener) { + for (const cid of bus.clients.keys()) hostListener(cid); + } for (const id of mod.getPendingRequestIds()) { if (decided.has(id) || preExisting.has(id)) continue; decided.add(id); + if (targets) { + const hit = targets().find((t) => t.requestId === id); + if (hit) hostListener(hit.cid); + } mod._decideForTests(id, approve); } }, 2); @@ -683,6 +743,13 @@ export async function withDecisions(fn, { approve = true } = {}) { } finally { clearTimeout(bail); clearInterval(poll); + if (setDecider) setDecider(false); + for (const [cid, res] of hosted) { + try { + bus.endSseClient(cid, res); + } catch {} + } + hosted.clear(); } } diff --git a/packages/webui/test/helpers/pin-transport.mjs b/packages/webui/test/helpers/pin-transport.mjs new file mode 100644 index 000000000..7e535c07e --- /dev/null +++ b/packages/webui/test/helpers/pin-transport.mjs @@ -0,0 +1,40 @@ +// webui/test/helpers/pin-transport.mjs +// +// Pins `MCODE_WEBUI_TRANSPORT` for ONE test file, at MODULE SCOPE, +// before any webui server module is evaluated. +// +// Why module scope and not a `before()` hook: `server/lib/config.js` +// resolves the transport into a frozen `export const` when it is first +// evaluated (the B0 lesson — the value must not be re-read per call). +// A `before()` that assigned `process.env` would run long after that +// module is in the registry, so the route would still see the ambient +// value. Mutating the environment from a module that is itself imported +// FIRST is the only injection point that is guaranteed to run before +// the first `import "../lib/config.js"` in the graph — ES module +// evaluation follows import order. +// +// Why a file would need it: a test that installs a FAKE ACP transport +// (a `t.mock.module` of `../acp.mjs` with a scripted client) is testing +// the ACP path, and saying so is more honest than letting it follow +// whatever transport the suite happens to run under. Before M3-B8 the +// send endpoint had no other transport to follow, so no test had to say +// so; B8 gave it a sibling and the declaration became necessary. +// +// The default is `acp`, which is also the server's own default, so +// pinning is a no-op under the acp gate and only does work under +// `MCODE_WEBUI_TRANSPORT=runtime`. +// +// Usage — as the FIRST import in the file: +// +// import "../helpers/pin-transport.mjs"; +// import { test } from "node:test"; +// +// The side effect is the whole API; there is nothing to import. + +const PINNED = "acp"; + +// Only write when it differs, so a suite that already runs under the +// pinned value leaves the environment exactly as it found it. +if ((process.env.MCODE_WEBUI_TRANSPORT || "") !== PINNED) { + process.env.MCODE_WEBUI_TRANSPORT = PINNED; +} diff --git a/packages/webui/test/integration/event-chain.test.js b/packages/webui/test/integration/event-chain.test.js index ec22a4824..a94e3d984 100644 --- a/packages/webui/test/integration/event-chain.test.js +++ b/packages/webui/test/integration/event-chain.test.js @@ -631,8 +631,17 @@ describe("event-chain: gate-blocking (decline / timeout / approve)", () => { // approved:false on timeout and record auth.timeout on the // isolated chain. Route-level behavior of a timeout is the same // 403 branch covered in (a) (approved:false → declined). + // The target needs a LIVE connection for the request to stay + // pending at all: with nobody to ask, authorize() answers + // fail-closed immediately as `auth.unreachable` rather than + // waiting out the budget (state-bus.js#hasDecisionListener). This + // case is specifically about the BUDGET expiring, so it presents + // the tab whose modal the user then walks away from. const eventsPath = join(server.tmpDir, "timeout-events.ndjson"); process.env.MCODE_WEBUI_EVENTS_PATH = eventsPath; + const stateBus = await import(absPath("lib/state-bus.js")); + const liveRes = { writableEnded: false, destroyed: false, write: () => true }; + stateBus.setSseClient("cid-timeout", liveRes); try { const auth = await import(absPath("lib/authorize.js")); auth._resetForTests(); @@ -660,6 +669,7 @@ describe("event-chain: gate-blocking (decline / timeout / approve)", () => { // Restore the shared env + drop any in-process pending // requests so later tests are unaffected. delete process.env.MCODE_WEBUI_EVENTS_PATH; + stateBus.endSseClient("cid-timeout", liveRes); try { const auth = await import(absPath("lib/authorize.js")); auth._resetForTests(); diff --git a/packages/webui/test/lib/authorize.check.mjs b/packages/webui/test/lib/authorize.check.mjs index 458f20322..76a951094 100644 --- a/packages/webui/test/lib/authorize.check.mjs +++ b/packages/webui/test/lib/authorize.check.mjs @@ -20,7 +20,7 @@ import { test, describe, before, beforeEach, after } from "node:test"; import assert from "node:assert/strict"; import { Readable } from "node:stream"; -import {rmSync} from "node:fs"; +import { rmSync, readFileSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; @@ -44,6 +44,11 @@ process.env.MCODE_WEBUI_EVENTS_PATH = join(_tmpAuditDir, "events.ndjson"); // ----- state-bus mock state (read by the registered module mock) ----- let _sseFrames = []; let _subscribers = new Map(); // cid -> Set +// The connection-registry probe `authorize()` consults before arming its +// timeout. It defaults to "cannot tell" (true), which is how every bus +// that does not model the registry reads — the gate then waits, exactly +// as it always did. Tests that exercise the unreachable path replace it. +let _listenerProbe = () => true; function _installStateBusMock(t) { t.mock.module(absPath("lib/state-bus.js"), { @@ -68,6 +73,7 @@ function _installStateBusMock(t) { clearActiveChild: () => {}, getCidsByMcodeSession: () => [], getSseClient: (cid) => _subscribers.get(cid) || null, + hasDecisionListener: (cid) => _listenerProbe(cid), setSseClient: (cid, res) => { if (!_subscribers.has(cid)) _subscribers.set(cid, new Set()); _subscribers.get(cid).add(res); @@ -142,6 +148,7 @@ before(async (t) => { beforeEach(() => { _sseFrames = []; _subscribers = new Map(); + _listenerProbe = () => true; _resetForTests(); }); @@ -400,6 +407,102 @@ describe("authorize — no test-mode auto-approve (static guard)", () => { }); }); +// ============================================================ +// The decision channel, not the timer +// +// The 5-minute budget is the answer to "a human saw the modal and did +// not answer". It is not the answer to "no human was ever shown one": +// with an empty connection registry the fail-closed result is already +// determined, and the caller's HTTP request — for session.delete a +// destructive one — used to sit on that promise for the full budget +// with no status and no body. These cases pin the short-circuit and, +// just as importantly, that it cannot approve anything. +// ============================================================ +describe("authorize — 不可达的裁决通道(不等满预算就失败即关闭)", () => { + test("无在线客户端时立即失败即关闭,不进入 5 分钟预算", async () => { + _listenerProbe = () => false; + const startedAt = Date.now(); + const r = await authorize("session.delete", { cid: "gone" }, {}); + assert.equal(r.approved, false, "无人可裁决 ⇒ 绝不批准"); + assert.equal(r.decidedBy, "timeout", "与超时同解,形状不变"); + assert.ok(Date.now() - startedAt < 1000, "必须在毫秒级返回,而不是预算到期"); + assert.equal(getPendingCount(), 0, "请求不进挂起表 —— 没有可被裁决的东西"); + assert.equal( + _sseFrames.filter((f) => f.event === "needs_authorization").length, + 0, + "不向虚空推送请求帧", + ); + }); + + test("不可达时同样广播 authorization_decided,让标签页收敛模态框", async () => { + _listenerProbe = () => false; + await authorize("session.delete", { cid: "gone" }, {}); + const decided = _sseFrames.find((f) => f.event === "authorization_decided"); + assert.ok(decided, "与超时路径同一条收敛帧"); + assert.equal(decided.approved, false); + assert.equal(decided.decidedBy, "timeout"); + }); + + test("不可达时写 auth.unreachable 审计,而不是 auth.pending", async () => { + // The operator log has to distinguish "nobody was there" from + // "somebody looked at it and said no" — they are different + // operational problems and the same decidedBy value. + _listenerProbe = () => false; + await authorize("session.delete", { cid: "gone" }, {}); + const events = readFileSync( + join(_tmpAuditDir, "events.ndjson"), + "utf8", + ).trim().split("\n").filter(Boolean).map((l) => JSON.parse(l)); + const last = events[events.length - 1]; + assert.equal(last.kind, "auth.unreachable"); + assert.equal(last.target, "session.delete"); + assert.equal(last.data.reason, "no_connected_client"); + assert.equal(last.data.requestId.length > 0, true); + // The audit chain is fail-closed by design: a new kind has to hash + // and verify exactly like every other line, or the operator log + // stops being trustworthy at the moment it matters most. + const eventsMod = await import(absPath("lib/events.js")); + const v = eventsMod.verify({ path: join(_tmpAuditDir, "events.ndjson") }); + assert.equal(v.ok, true, `chain must verify: ${JSON.stringify(v)}`); + }); + + test("有在线客户端时仍然挂起等人,绝不自动批准", async () => { + // The reverse half: a live listener must restore the full + // round-trip. A short-circuit that fired here would turn every + // destructive action into a silent no-op. + const p = authorize("session.delete", { cid: "tab-live" }, {}); + assert.equal(getPendingCount(), 1, "挂起等人"); + const [rid] = getPendingRequestIds(); + assert.equal( + _sseFrames.filter((f) => f.event === "needs_authorization").length, + 1, + "模态框照常推送", + ); + const res = fakeRes(); + await handleAuthDecision(fakeReq({ requestId: rid, approve: true }), res); + assert.equal(res._status, 200); + const result = await p; + assert.equal(result.approved, true); + assert.equal(result.decidedBy, "user"); + }); + + test("裁决通道探针抛错时按“可能有人”处理 —— 保守等待而非误判", async () => { + // A bus that cannot answer the question is not evidence that + // nobody is listening. Defaulting to the short-circuit here would + // make a bus bug silently deny every destructive action. + _listenerProbe = () => { + throw new Error("registry unavailable"); + }; + const p = authorize("session.delete", { cid: "tab-x" }, { timeoutMs: 20 }); + assert.equal(getPendingCount(), 1, "探针失效时保持原有等待语义"); + const watchdog = setTimeout(() => {}, 5000); + const r = await p; + clearTimeout(watchdog); + assert.equal(r.approved, false); + assert.equal(r.decidedBy, "timeout"); + }); +}); + // ============================================================ // SSE emission contract // ============================================================ diff --git a/packages/webui/test/lib/engine/streaming-send.test.js b/packages/webui/test/lib/engine/streaming-send.test.js new file mode 100644 index 000000000..d7717a86f --- /dev/null +++ b/packages/webui/test/lib/engine/streaming-send.test.js @@ -0,0 +1,1787 @@ +// webui/test/lib/engine/streaming-send.test.js +// +// M3-B8 (B8a + B8b): the STREAMING SEND family — #12 POST /api/send, +// its capability gate, its pure stream bridge, its runtime runner and +// its route branch. +// +// B8a shipped the first half of this file against a module that had no +// runner, so two of the three red lines could only be NAMED there. B8b +// replaces that placeholder with the real assertions: section 5 drives +// the actual `runMcodeRuntime` through a fake event stream and section 6 +// drives the actual route, both with the `?bust=` marker controls that +// prove the mocks took. +// +// Sections are ordered by how much user-visible damage a regression in +// each one does, not by which module the function came from: +// +// 1. THE DECLARATION AND ITS HARD GATE. The judgement call in this +// batch: #12 is the first M3 family to gate HARD, because its +// response is an ack written BEFORE the engine is called and there +// is therefore no truthful degradation to fall back to. The suite +// proves the gate is unreachable on BOTH transports today. +// 2. THE RED LINES THIS LAYER OWNS. run-mirror (the still-viewing +// test) and the finalize drain (the `●` rewrite over a detached +// buffer). One named test per line, plus the NEGATIVE half of +// each, because a red line only asserted in its happy direction is +// a red line nobody is watching. +// 3. THE STREAM BRIDGE, table-driven. The runtime's frame vocabulary +// → webui's line syntax, over the whole `TuiStreamEvent` +// taxonomy, including the shapes that must produce NO line. This is +// the half where a regression is invisible: a dropped `●` does not +// crash, it makes the answer disappear. +// 4. THE BYTE-FOR-BYTE LINE BODIES. The `→ name` header spelling and +// the usage projection's exact object. +// +// One module-mock trap applies here, and it is load-bearing rather +// than incidental: `t.mock.module` REPLACES the WHOLE NAMESPACE; it +// does not merge. A mock naming only the export under test leaves +// every other name undefined and the consumer fails at INSTANTIATION +// with `SyntaxError: … does not provide an export named …` — a failure +// that reads like a product bug and is not one. `FACADE_EXPORTS` below +// is asserted against the module's real export list so that class of +// mistake becomes one named red test rather than a cascade. + +import { test, describe } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +import { Readable } from "node:stream"; + +import { + setupMocks, + absPath, + registerMcodeAcpMock, + registerSessionsStore, + getSessionsStore, +} from "../../helpers/_setup.js"; +// Type discrimination goes through the exported predicate, never +// `err.name`. `engine/capabilities.js` is never `mock.module`d by this +// file, so the `instanceof` inside it resolves against the same class +// `assertEngineCapability` would have thrown from. +const { isEngineCapabilityNotSupportedError } = await import( + "../../../server/engine/errors.js" +); + +const RUNTIME = "runtime"; +const ACP = "acp"; +const ENDPOINT = "POST /api/send"; + +/** A syntactically valid engine sid. */ +const SID = "mvs_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +/** The other conversation the user could have switched to. */ +const OTHER_SID = "mvs_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + +/** + * Every name `engine/streaming-send.js` exports. The namespace, not a + * subset. + * + * One absence is deliberate and is asserted by a named test below, so + * the boundary between B8a and B8b stays executable rather than a + * promise in a report: + * + * - `sendShouldPromoteDraft` — a predicate the ROUTE cannot use + * without narrowing its condition, which would be a behaviour + * change on the acp path. See the module's red-line-3 note. + */ +const FACADE_EXPORTS = [ + "SEND_EVENT_KINDS", + "STREAMING_SEND_ENDPOINTS", + "assertStreamingSendCapability", + "checkStreamingSendCapability", + "classifySendEvent", + "openEngineSendStream", + "projectSendAttachments", + "resolveStreamingSendProvider", + "rewriteDrainedAnswerLine", + "sendSegmentAdvance", + "sendStillViewing", + "sendTerminalOutcome", + "sendToolHeaderLine", + "sendToolUpdate", + "sendUsageTotals", +]; + +/** A minimal `ServerResponse` stand-in that records what was written. */ +function mkRes() { + const written = []; + return { + written, + writeHead(status, headers) { + written.push({ status, headers }); + return this; + }, + end(body) { + written.push({ body }); + return this; + }, + }; +} + +/** The last `writeHead` + `end` pair, as one observation. */ +function lastResponse(res) { + const head = res.written[res.written.length - 2]; + const tail = res.written[res.written.length - 1]; + assert.ok(head && head.status !== undefined, "the handler never wrote a head"); + return { + status: head.status, + headers: head.headers, + body: tail ? tail.body : undefined, + }; +} + +/** A client state carrying only what the send path reads. */ +function mkCs(overrides = {}) { + return { + sessionId: "webui-1", + mcodeSessionId: null, + chat: [], + context: { thinkingStatus: "Idle", tps: 0 }, + usage: { sessionInput: 0, sessionOutput: 0, sessionTotal: 0 }, + model: { name: "minimax_api/MiniMax-M3" }, + workspace: { dir: "/tmp/b8" }, + plan: { active: false, planId: null, title: null, summary: "", options: [] }, + running: { active: false, prompt: null, pid: null, sessionId: null, tps: 0 }, + ...overrides, + }; +} + +/** + * A fake runtime event stream. Yields the given events in order and + * then completes — which is the case section 5's "ended without a + * terminal event" assertion needs to be able to produce. + */ +function mkStream(events, gate = null) { + return { + async *[Symbol.asyncIterator]() { + for (const e of events) { + if (e === THROW_MARKER) throw new Error("iterator exploded"); + // A `GATE` element suspends the turn until the test opens it. + // Without one, a "mid-run" switch is a FICTION: the whole + // stream drains in one microtask batch, so anything the test + // does after a `setImmediate` yield happens AFTER finalize — + // and the run-mirror assertions then pass for the wrong reason. + // Mutation injection is what surfaced that; this is the fix. + if (e === GATE) { + if (gate) { + gate.markReached(); + await gate.held; + } + continue; + } + yield e; + } + }, + close() {}, + }; +} +const THROW_MARKER = Symbol("throw"); +/** Suspends `mkStream` until the returned gate's `open()` is called. */ +const GATE = Symbol("gate"); + +/** + * A gate with EXPLICIT handles. The first cut used module-level mutable + * state and a `while (!reached) await setImmediate` spin, which leaked + * a ref'd handle whenever a case failed before opening the gate and + * hung the whole file. A returned object cannot be clobbered by a + * later case, and `await gate.reached` is a promise, not a poll. + * + * @returns {{open: () => void, held: Promise, reached: Promise}} + */ +function mkGate() { + let release; + const held = new Promise((resolve) => { + release = resolve; + }); + let markReached; + const realReached = new Promise((resolve) => { + markReached = resolve; + }); + // `reached` is BOUNDED, and that is the point. An unbounded await on + // an ordering that a sibling test file can perturb turns one bad + // ordering into a hung file, and node:test then aborts it with + // "Promise resolution is still pending but the event loop has already + // resolved" — which takes the WHOLE gate with it, not just this file. + // Racing a timeout makes the same failure one named red test. + const reached = Promise.race([ + realReached, + new Promise((_, reject) => + setTimeout( + () => reject(new Error("mkGate: the stream never reached its GATE within 2000ms")), + 2000, + ).unref?.(), + ), + ]); + return { + held, + reached, + markReached, + open: () => release(), + }; +} + +/** + * Fresh-cache counter for the route re-imports in section 6. + * + * `mock.module` re-evaluates only the MOCKED specifier: a consumer + * already in the registry keeps its old LIVE BINDING, so a second test + * in the same file would silently reuse the first test's mock and pass + * for the wrong reason. Every route re-import below carries a fresh + * `?bust=N`, and section 6 ends with two marker controls that prove it. + */ +let bust = 0; + +/** A JSON request body the real `lib/read-json.js` can consume. */ +function jsonReq(body) { + return Readable.from([Buffer.from(JSON.stringify(body), "utf8")]); +} + +/** Whole-namespace facade mock. Un-stubbed names THROW. */ +function mockFacade(t, impls) { + const namedExports = {}; + for (const name of FACADE_EXPORTS) { + namedExports[name] = () => { + throw new Error(`B8 test called engine/streaming-send.js#${name}, which this case did not stub`); + }; + } + Object.assign(namedExports, impls); + t.mock.module(absPath("engine/streaming-send.js"), { namedExports }); +} + +/** The `TuiStreamEvent` shapes the bridge has to understand. */ +const EV = { + heartbeat: { type: "heartbeat", turnId: "t1" }, + deltaText: (text) => ({ type: "delta", messageId: "m1", role: "assistant", content: text }), + deltaThinking: (text) => ({ + type: "delta", + messageId: "m1", + role: "assistant", + thinking: text, + }), + deltaTool: (toolCalls) => ({ + type: "delta", + messageId: "m1", + role: "assistant", + toolCalls, + }), + settled: (message) => ({ type: "message", message: { id: "m1", role: "assistant", ...message } }), + started: { type: "session-status", status: "started" }, + finished: { type: "session-status", status: "finished" }, + errored: (message) => ({ type: "session-status", status: "error", message }), + aborted: { type: "session-status", status: "aborted" }, + error: (message) => ({ type: "error", message }), + done: { type: "done", turnId: "t1" }, + resync: { type: "resync-required", turnId: "t1" }, + generic: { type: "generic", eventType: "session.spawned", data: { session_id: "child-1" } }, + replaced: { type: "messages-replaced", messages: [] }, + rewound: { type: "messages-rewound", messageIds: ["m1"] }, + tool: (over = {}) => ({ + id: "tc-1", + name: "read", + status: 1, + input: { path: "/tmp/a" }, + ...over, + }), +}; + +// =========================================================================== +// The facade under test. +// +// `setupMocks` needs a TEST context (`t.mock.module` does not exist on a +// suite context) AND its registry is per-context: a file-level `before` +// would leave every later `setupMocks(t, …)` in this file fighting an +// already-mocked `lib/acp-client.js` (ERR_INVALID_STATE). So each test +// boots the facade itself, the B5/B6/B7 `bootFacade` shape. +// +// The facade is NEVER `mock.module`d in this file. B8a has no route to +// prove a mock against, and a mock that nothing consumes is the exact +// trap #1 mistake this file's header describes — so the list assertion +// below is the only namespace guard B8a needs, and B8b adds the +// `?bust=` marker controls when it adds a consumer. +// =========================================================================== +async function bootFacade(t) { + await setupMocks(t, {}); + return import(absPath("engine/streaming-send.js")); +} + +// =========================================================================== +// 1. The declaration and its hard gate +// =========================================================================== +describe("the whole-namespace mock lists stay whole", () => { + test("FACADE_EXPORTS is exactly engine/streaming-send.js's export list", async (t) => { + // Mock trap #1: a list that drifts from the module's real exports + // makes a later consumer fail at INSTANTIATION with a SyntaxError + // that reads like a product bug. Asserting the list here turns that + // class of mistake into one named red test. + const real = Object.keys(await import(absPath("engine/streaming-send.js"))).sort(); + assert.deepEqual([...FACADE_EXPORTS].sort(), real); + }); + + test("the data plane is reached ONLY through `await import()`", async (t) => { + // B8a's justification for being its own batch was that this module + // had no IO at all. B8b gave it a data plane, so the claim has to + // change shape rather than be deleted: the host graph must now be + // reachable ONLY from inside the two data-plane functions, or an + // acp-only server would boot the runtime on every start — which is + // the exact regression M1 already paid for once. + const facade = await bootFacade(t); + assert.equal(typeof facade.openEngineSendStream, "function"); + assert.equal(typeof facade.projectSendAttachments, "function"); + const src = readFileSync(fileURLToPath(absPath("engine/streaming-send.js")), "utf8"); + const code = src + .replace(/\/\*[\s\S]*?\*\//g, "") + .replace(/^\s*\/\/.*$/gm, ""); + // The data plane resolves its three dependencies in ONE + // `Promise.all([...])`, so the count is three even though there is + // a single call site. What matters is that every one of them is + // dynamic: a STATIC import of any of the three would put the + // runtime host graph on the boot path of an acp-only server. + const dynamic = code.match(/(? m[1]); + assert.deepEqual( + staticFrom, + ["./index.js", "./capabilities.js"], + "a new STATIC import here is a boot-path regression on acp-only servers: " + + JSON.stringify(staticFrom), + ); + assert.equal(/\brequire\s*\(/.test(code), false, "CJS has no place in an ESM facade module"); + }); + + test("the removed `sendShouldPromoteDraft` stays removed", async (t) => { + // Red line 3 is proven by a ROUTE assertion in B8b instead of a + // predicate the route cannot use without narrowing its acp + // condition. If the function ever comes back, this test is where the + // argument for it belongs. + const facade = await bootFacade(t); + assert.equal(FACADE_EXPORTS.includes("sendShouldPromoteDraft"), false); + assert.equal("sendShouldPromoteDraft" in facade, false); + }); +}); + +describe("the send family's declaration and HARD gate", () => { + test("#12 declares `streamingSend` · `sendMessage` as HARD", async (t) => { + const facade = await bootFacade(t); + assert.deepEqual(facade.STREAMING_SEND_ENDPOINTS[ENDPOINT], { + capability: "streamingSend", + subItem: "sendMessage", + enforcement: "hard", + }); + }); + + test("the DEFAULT `acp` transport reports `unregistered-transport` and NEVER throws", async (t) => { + const facade = await bootFacade(t); + // No provider is registered for `acp` (M4's job), so this is the + // pre-M3 behaviour path — and it must not be a hole in the gate. + // This assertion IS the acp no-regression claim at the gate: when + // B8b wires the route, an acp deployment must not start answering + // 501 because a gate was added. + const d = facade.checkStreamingSendCapability(ENDPOINT, ACP); + assert.equal(d.gate, "unregistered-transport"); + assert.equal(d.provider, null); + assert.equal(d.capability, "streamingSend"); + // And the asserting form must agree, or the route would 501 an + // acp deployment. + assert.equal( + facade.assertStreamingSendCapability(ENDPOINT, ACP).gate, + "unregistered-transport", + ); + }); + + test("the `runtime` transport resolves the registered provider and reports `checked`", async (t) => { + const facade = await bootFacade(t); + // v2 declares `streamingSend: full` (providers/local-runtime-v2 + // .capabilities.js), so the gate is satisfied today and the 501 + // path is unreachable. Pinned so a declaration change to `none` or + // a `partial` without `sendMessage` has to be a deliberate edit. + const d = facade.checkStreamingSendCapability(ENDPOINT, RUNTIME); + assert.equal(d.gate, "checked"); + assert.equal(d.provider, "local-runtime-v2"); + assert.equal( + facade.assertStreamingSendCapability(ENDPOINT, RUNTIME).gate, + "checked", + ); + }); + + test("resolveStreamingSendProvider returns null for an unregistered transport and the provider for `runtime`", async (t) => { + const facade = await bootFacade(t); + assert.equal(facade.resolveStreamingSendProvider(ACP), null); + assert.equal(facade.resolveStreamingSendProvider(RUNTIME).id, "local-runtime-v2"); + }); + + test("an unknown endpoint key is a plain Error, never a capability error", async (t) => { + const facade = await bootFacade(t); + // Caller confusion must never reach a user as 501. + for (const fn of ["checkStreamingSendCapability", "assertStreamingSendCapability"]) { + let caught = null; + try { + facade[fn]("POST /api/not-a-member", RUNTIME); + } catch (e) { + caught = e; + } + assert.ok(caught, `${fn} must throw on an unknown key`); + assert.equal(isEngineCapabilityNotSupportedError(caught), false, fn); + assert.equal(caught.code, "unknown_streaming_send_endpoint", fn); + } + }); + + test("the check and assert forms AGREE for every transport the server accepts", async (t) => { + const facade = await bootFacade(t); + // `exec` is the third valid MCODE_WEBUI_TRANSPORT value and has no + // registered provider either; the gate must treat it exactly like + // `acp` rather than throwing on an unknown string. When B8b wires + // the route, this is what keeps the exec escape hatch alive. + for (const transport of [ACP, "exec", RUNTIME]) { + assert.equal( + facade.assertStreamingSendCapability(ENDPOINT, transport).gate, + facade.checkStreamingSendCapability(ENDPOINT, transport).gate, + transport, + ); + } + }); +}); + +// =========================================================================== +// 2. The red lines this layer owns +// =========================================================================== +describe("RED LINE 1 — run-mirror: the still-viewing test", () => { + test("a turn whose record is still the viewed one counts as still viewing", async (t) => { + const facade = await bootFacade(t); + assert.equal(facade.sendStillViewing(mkCs(), "webui-1", SID), true); + }); + + test("a mid-run switch to another conversation reads as NOT still viewing", async (t) => { + const facade = await bootFacade(t); + // This is the case the whole run-mirror exists for: cs now points + // at the OTHER session, so every cs mutation after this point would + // stamp this turn's engine id, title or chat onto it. + assert.equal(facade.sendStillViewing(mkCs({ sessionId: OTHER_SID }), "webui-1", SID), false); + }); + + test("a record PROMOTED mid-run still counts — the post-bind id form", async (t) => { + const facade = await bootFacade(t); + // bindDraftToMcodeSid renames the record's id to the engine sid, so + // after promotion `cs.sessionId === sid`. Recognising only the + // pre-promotion form would make every post-bind turn look switched + // away and the answer would never reach the live view. + assert.equal(facade.sendStillViewing(mkCs({ sessionId: SID }), "webui-1", SID), true); + }); + + test("REVERSE: a turn with no owning record is treated as still viewing", async (t) => { + const facade = await bootFacade(t); + // A direct caller (a unit test, a future non-turn helper) has no + // draft at all. Treating that as "switched away" would silently + // disable every cs mutation for those callers. + assert.equal(facade.sendStillViewing(mkCs({ sessionId: "whatever" }), null, SID), true); + assert.equal(facade.sendStillViewing(mkCs(), undefined, SID), true); + }); + + test("REVERSE: a missing client state is NOT still viewing", async (t) => { + const facade = await bootFacade(t); + // The mirror of the case above. There is nothing to write through, + // so the honest answer is "no" — the caller falls back to the + // record-by-id path, which is the correct destination for a turn + // with no live view. + assert.equal(facade.sendStillViewing(null, "webui-1", SID), false); + assert.equal(facade.sendStillViewing(undefined, "webui-1", SID), false); + }); + + test("REVERSE: a turn with NO engine sid still tests on the record id alone", async (t) => { + const facade = await bootFacade(t); + // The pre-session window: the engine id is not known yet, so the + // `sid` clause cannot fire and the record id is the only evidence. + assert.equal(facade.sendStillViewing(mkCs(), "webui-1", null), true); + assert.equal( + facade.sendStillViewing(mkCs({ sessionId: OTHER_SID }), "webui-1", null), + false, + "without an sid, a switched-away view must still read as switched away", + ); + }); +}); + +describe("RED LINE 2 — the finalize drain's `●` rewrite", () => { + test("the LAST `●` line is the one rewritten, scanning from the end", async (t) => { + const facade = await bootFacade(t); + // A turn that produced two answer segments (a tool call between + // them) has two `●` lines; the final answer is the last one. + const lines = ["› hi", "● first segment", "→ read", "● second segm"]; + assert.deepEqual(facade.rewriteDrainedAnswerLine(lines, "final answer"), [ + "› hi", + "● first segment", + "→ read", + "● final answer", + ]); + }); + + test("the input array is never mutated — the caller compares before and after", async (t) => { + const facade = await bootFacade(t); + const lines = ["● old"]; + const copy = [...lines]; + facade.rewriteDrainedAnswerLine(lines, "new"); + assert.deepEqual(lines, copy, "pure function, or the drain double-writes the record"); + }); + + test("an answer with NO `●` line is APPENDED, not dropped", async (t) => { + const facade = await bootFacade(t); + // The alternative — dropping it — loses the turn's only output on a + // runtime that streams no `●` at all (a tool-only turn, or one + // whose deltas were all classified IGNORE). + assert.deepEqual(facade.rewriteDrainedAnswerLine(["› hi", "→ read"], "only output"), [ + "› hi", + "→ read", + "● only output", + ]); + }); + + test("REVERSE: a null answer rewrites nothing but still copies", async (t) => { + const facade = await bootFacade(t); + // The non-success path: the drained lines are the owning session's + // content and are flushed exactly as they were. + assert.deepEqual(facade.rewriteDrainedAnswerLine(["● partial", "→ read"], null), [ + "● partial", + "→ read", + ]); + }); + + test("REVERSE: an empty or non-array buffer yields an empty list, not a throw", async (t) => { + const facade = await bootFacade(t); + for (const input of [[], null, undefined, "not-an-array"]) { + assert.deepEqual(facade.rewriteDrainedAnswerLine(input, "x"), [], JSON.stringify(input)); + } + }); + + test("REVERSE: a non-string line is not mistaken for an answer line", async (t) => { + const facade = await bootFacade(t); + // The decoder's history is a `string[]`, but a rich-text object + // surviving in a buffer must not make `startsWith` throw — a + // finalize that throws would skip the whole drain. + assert.deepEqual(facade.rewriteDrainedAnswerLine([{ text: "hi" }, "→ read"], "answer"), [ + { text: "hi" }, + "→ read", + "● answer", + ]); + }); +}); + +describe("RED LINES 3 + 4 — the promotion and the 409 claim are the ROUTE's", () => { + // Both are structural: they live in the route tail after the + // transport branch, so they cannot differ between transports. What + // matters is proving that against the real runner, which needs the + // route — section 6 does exactly that, end to end, with the real + // `runMcodeRuntime` and a fake host. This block states only the + // negative that section 6 depends on: neither red line has a + // derivation here, so the runner cannot re-derive one and drift. + test("neither red line has a derivation in this layer", async (t) => { + const facade = await bootFacade(t); + assert.equal("sendShouldPromoteDraft" in facade, false); + assert.equal( + FACADE_EXPORTS.some((n) => /claim|promote/i.test(n)), + false, + "a claim or promotion predicate here would be a second answer to a question the route already answers", + ); + }); +}); + +// =========================================================================== +// 3. The stream bridge +// =========================================================================== +describe("the stream bridge, table-driven over the whole event taxonomy", () => { + test("every TuiStreamEvent family classifies to a known kind", async (t) => { + const facade = await bootFacade(t); + const K = facade.SEND_EVENT_KINDS; + const TABLE = [ + ["delta with text", EV.deltaText("hi"), K.MESSAGE], + ["delta with thinking", EV.deltaThinking("hmm"), K.THOUGHT], + ["delta with a tool call", EV.deltaTool([EV.tool()]), K.TOOL], + ["settled message", EV.settled({ content: "done" }), K.AUTHORITATIVE], + ["session-status started", EV.started, K.STREAM], + ["session-status finished", EV.finished, K.TERMINAL], + ["session-status error", EV.errored("boom"), K.TERMINAL], + ["session-status aborted", EV.aborted, K.TERMINAL], + ["error event", EV.error("boom"), K.TERMINAL], + ["done", EV.done, K.TERMINAL], + ["heartbeat", EV.heartbeat, K.IGNORE], + ["resync-required", EV.resync, K.IGNORE], + ["generic", EV.generic, K.IGNORE], + ["messages-replaced", EV.replaced, K.IGNORE], + ["messages-rewound", EV.rewound, K.IGNORE], + ]; + for (const [name, event, expected] of TABLE) { + assert.equal(facade.classifySendEvent(event).kind, expected, name); + } + }); + + test("the taxonomy is a frozen literal the runner switches on", async (t) => { + const facade = await bootFacade(t); + // A plain object would let a caller add a kind at runtime and make + // the switch in the runner silently incomplete. Frozen is the + // cheap half of the guarantee; the named-test table above is the + // expensive half, and neither replaces the other. + assert.equal(Object.isFrozen(facade.SEND_EVENT_KINDS), true); + assert.deepEqual(Object.keys(facade.SEND_EVENT_KINDS).sort(), [ + "AUTHORITATIVE", + "IGNORE", + "MESSAGE", + "STREAM", + "TERMINAL", + "THOUGHT", + "TOOL", + ]); + }); + + test("the text is carried through, not summarized", async (t) => { + const facade = await bootFacade(t); + const K = facade.SEND_EVENT_KINDS; + const d = facade.classifySendEvent(EV.deltaText("改好了")); + assert.equal(d.kind, K.MESSAGE); + assert.equal(d.text, "改好了"); + const th = facade.classifySendEvent(EV.deltaThinking("先读文件")); + assert.equal(th.kind, K.THOUGHT); + assert.equal(th.text, "先读文件"); + }); + + test("a tool call wins over text that rode along in the same delta", async (t) => { + const facade = await bootFacade(t); + const K = facade.SEND_EVENT_KINDS; + // The runtime re-sends the whole tool call on every chunk of its + // lifecycle, sometimes next to a text fragment. Classifying that as + // MESSAGE would append the text to the answer segment that the + // tool call is supposed to have broken. + const c = facade.classifySendEvent(EV.deltaTool([EV.tool()])); + assert.equal(c.kind, K.TOOL); + assert.equal(Array.isArray(c.toolCalls), true); + assert.equal(c.toolCalls.length, 1); + }); + + test("text that rode along with a tool call is still carried, for the runner to place", async (t) => { + const facade = await bootFacade(t); + const c = facade.classifySendEvent({ + type: "delta", + role: "assistant", + content: "reading the file", + toolCalls: [EV.tool()], + }); + // The classification says TOOL; the text is not discarded, it is + // the runner's job to put it in the answer segment that the tool + // call just broke. Dropping it here would lose a real message. + assert.equal(c.kind, facade.SEND_EVENT_KINDS.TOOL); + assert.equal(c.text, "reading the file"); + }); + + test("an empty delta produces NO line", async (t) => { + const facade = await bootFacade(t); + // A `finish: true` chunk with no payload is the segment terminator. + // Treating it as an empty MESSAGE would push a `● ` line. + assert.equal( + facade.classifySendEvent(EV.deltaText("")).kind, + facade.SEND_EVENT_KINDS.IGNORE, + ); + assert.equal( + facade.classifySendEvent(EV.deltaThinking("")).kind, + facade.SEND_EVENT_KINDS.IGNORE, + ); + assert.equal(facade.classifySendEvent({ type: "delta" }).kind, facade.SEND_EVENT_KINDS.IGNORE); + }); + + test("a settled message with finishReason 'error' is a TERMINAL failure, not content", async (t) => { + const facade = await bootFacade(t); + // The runtime reports a mid-turn crash as a settled message with an + // error finish reason. Rendering it as an answer would show the user + // an error as if the model had written it. + const c = facade.classifySendEvent(EV.settled({ content: "tool crashed", finishReason: "error" })); + assert.equal(c.kind, facade.SEND_EVENT_KINDS.TERMINAL); + assert.equal(c.errorMessage, "tool crashed"); + assert.equal(c.text, undefined, "an error is not an answer"); + }); + + test("an error finish reason with no text still names something", async (t) => { + const facade = await bootFacade(t); + const c = facade.classifySendEvent(EV.settled({ finishReason: "error" })); + assert.equal(c.kind, facade.SEND_EVENT_KINDS.TERMINAL); + assert.ok(typeof c.errorMessage === "string" && c.errorMessage.length > 0); + }); + + test("the settled message carries usage, stop reason and the turn coordinate", async (t) => { + const facade = await bootFacade(t); + const c = facade.classifySendEvent( + EV.settled({ + content: "answer", + usage: { totalTokens: 120, inputTokens: 100, outputTokens: 20 }, + finishReason: "stop", + id: "msg-42", + }), + ); + assert.equal(c.kind, facade.SEND_EVENT_KINDS.AUTHORITATIVE); + assert.equal(c.text, "answer"); + assert.equal(c.finishReason, "stop"); + assert.equal(c.messageId, "msg-42", "the turn_msg coordinate has to survive the bridge"); + assert.deepEqual(c.usage, { totalTokens: 120, inputTokens: 100, outputTokens: 20 }); + }); + + test("a settled message with no fields is still classified, not dropped", async (t) => { + const facade = await bootFacade(t); + // The runner's "an empty settled message does not clear a good + // accumulation" rule only works if this classifies as AUTHORITATIVE + // rather than IGNORE. + assert.equal( + facade.classifySendEvent(EV.settled({})).kind, + facade.SEND_EVENT_KINDS.AUTHORITATIVE, + ); + }); + + test("a settled message whose payload is not an object is IGNORE, not a throw", async (t) => { + const facade = await bootFacade(t); + // `message: null` is what a malformed frame looks like, and the + // classification must not reach for `.finishReason` on it. + for (const message of [null, undefined, "text", 7, []]) { + assert.equal( + facade.classifySendEvent({ type: "message", message }).kind, + facade.SEND_EVENT_KINDS.IGNORE, + JSON.stringify(message), + ); + } + }); + + test("UNKNOWN and malformed events are IGNORE, never a throw", async (t) => { + const facade = await bootFacade(t); + // The whole point: a bridge that throws on an unrecognised frame + // turns every future runtime addition into an outage of the chat + // endpoint. Ignoring it costs one line; throwing costs the turn. + for (const input of [null, undefined, 0, "", "nonsense", [], { type: "a-brand-new-frame" }]) { + assert.equal( + facade.classifySendEvent(input).kind, + facade.SEND_EVENT_KINDS.IGNORE, + JSON.stringify(input), + ); + } + }); +}); + +describe("the segment accumulator", () => { + test("same family appends, different family starts fresh — the full transition table", async (t) => { + const facade = await bootFacade(t); + const M = facade.SEND_EVENT_KINDS.MESSAGE; + const H = facade.SEND_EVENT_KINDS.THOUGHT; + const TOOL = facade.SEND_EVENT_KINDS.TOOL; + const TABLE = [ + // [lastKind, kind, buffer, delta, expectedReset, expectedText] + [null, M, "", "a", true, "a"], + [M, M, "a", "b", false, "ab"], + [M, H, "a", "b", true, "b"], + [H, M, "a", "b", true, "b"], + // The tool case is the one that bites in production: a tool call + // breaks the chain even though the accumulator is otherwise a + // text segment, so the next text chunk starts a NEW `●` line + // instead of appending to the previous segment. + [M, TOOL, "a", "x", true, "x"], + [TOOL, M, "a", "b", true, "b"], + ]; + for (const [last, kind, buf, delta, reset, text] of TABLE) { + const step = facade.sendSegmentAdvance(last, kind, buf, delta); + assert.equal(step.reset, reset, `${last}→${kind}`); + assert.equal(step.text, text, `${last}→${kind}`); + } + }); + + test("the returned lastKind is the family the caller must store", async (t) => { + const facade = await bootFacade(t); + const M = facade.SEND_EVENT_KINDS.MESSAGE; + // A runner that used the CALLER's old `lastKind` instead of the + // returned one would never leave a segment, and every answer would + // be one endlessly growing line. + assert.equal(facade.sendSegmentAdvance(null, M, "", "a").lastKind, M); + assert.equal(facade.sendSegmentAdvance(M, M, "a", "b").lastKind, M); + }); + + test("an empty delta never resets the segment", async (t) => { + const facade = await bootFacade(t); + const M = facade.SEND_EVENT_KINDS.MESSAGE; + // A `finish: true` chunk with no payload must not blank the line the + // user is watching. + const step = facade.sendSegmentAdvance(M, M, "already written", ""); + assert.equal(step.reset, false); + assert.equal(step.text, "already written"); + assert.equal(step.lastKind, M, "and the family is unchanged, so the next delta still appends"); + }); + + test("a non-string delta is ignored rather than concatenated as 'undefined'", async (t) => { + const facade = await bootFacade(t); + const M = facade.SEND_EVENT_KINDS.MESSAGE; + for (const delta of [null, undefined, 0, {}]) { + const step = facade.sendSegmentAdvance(M, M, "kept", delta); + assert.equal(step.text, "kept", JSON.stringify(delta)); + } + }); +}); + +describe("terminal outcomes", () => { + test("`finished` is success and `done` means the same thing", async (t) => { + const facade = await bootFacade(t); + assert.deepEqual(facade.sendTerminalOutcome("finished"), { + status: "succeeded", + errorMessage: null, + }); + }); + + test("an ABORT is not a failure — the user pressed stop", async (t) => { + const facade = await bootFacade(t); + // The route's error branch is gated on `r.status === "failed"`, so + // reporting an abort as a failure would fire an error alert for a + // user action. This is the runtime transport's version of B7's + // "`cancelled` means sent" rule. + for (const status of ["aborted", "interrupted"]) { + const o = facade.sendTerminalOutcome(status); + assert.equal(o.status, "aborted", status); + assert.equal(o.errorMessage, null, status); + } + }); + + test("REVERSE: `error` IS a failure, and says so even without a message", async (t) => { + const facade = await bootFacade(t); + const o = facade.sendTerminalOutcome("error"); + assert.equal(o.status, "failed"); + assert.ok(o.errorMessage.length > 0, "a failure with no text still has to name itself"); + }); + + test("REVERSE: an unrecognised terminal status fails closed, not open", async (t) => { + const facade = await bootFacade(t); + // A status nobody has seen must not be optimistically read as + // success — that would be a truncated turn rendered as a complete + // one, which is #110's fake success in a new costume. + for (const status of ["finished-ish", "", null, undefined, 7]) { + assert.equal(facade.sendTerminalOutcome(status).status, "failed", JSON.stringify(status)); + } + }); +}); + +// =========================================================================== +// 4. The byte-for-byte line bodies +// =========================================================================== +describe("the tool-call projection", () => { + test("stages map onto the ACP path's own status words", async (t) => { + const facade = await bootFacade(t); + // Reusing `applyToolUpdate`'s vocabulary is the point: the indented + // body syntax and the `→ name` header have exactly one home. + assert.equal(facade.sendToolUpdate(EV.tool({ status: 1 })).status, "pending"); + assert.equal(facade.sendToolUpdate(EV.tool({ status: 4 })).status, "pending"); + assert.equal(facade.sendToolUpdate(EV.tool({ status: 5 })).status, "pending"); + assert.equal(facade.sendToolUpdate(EV.tool({ status: 2 })).status, "completed"); + assert.equal(facade.sendToolUpdate(EV.tool({ status: 3 })).status, "error"); + }); + + test("a stage sent as a NAME is understood, not downgraded to completed", async (t) => { + const facade = await bootFacade(t); + // The ACP path's own `u.status` is a string; if the two vocabularies + // ever merge, an unrecognized string silently becomes "completed" + // and prints a half-streamed argument as a result. + assert.equal(facade.sendToolUpdate(EV.tool({ status: "failed" })).status, "error"); + assert.equal(facade.sendToolUpdate(EV.tool({ status: "FINISHED" })).status, "completed"); + assert.equal(facade.sendToolUpdate(EV.tool({ status: "preparing" })).status, "pending"); + }); + + test("the id, name, args and stage are forwarded to the shared reducer", async (t) => { + const facade = await bootFacade(t); + const u = facade.sendToolUpdate(EV.tool({ status: 2, output: "file contents" })); + assert.equal(u.toolCallId, "tc-1"); + assert.equal(u.title, "read"); + assert.equal(u.status, "completed"); + // `rawInput` is forwarded PARSED, so `applyToolUpdate`'s own + // `JSON.stringify` produces the identical string the ACP path does. + assert.deepEqual(u.rawInput, { path: "/tmp/a" }); + assert.deepEqual(u.rawOutput, { content: [{ type: "text", text: "file contents" }] }); + assert.equal(u.wireStatus, 2); + }); + + test("a failed call carries its error, and a nameless one still names itself", async (t) => { + const facade = await bootFacade(t); + const failed = facade.sendToolUpdate(EV.tool({ status: 3, error: "ENOENT" })); + assert.equal(failed.status, "error"); + assert.equal(failed.error, "ENOENT"); + const anon = facade.sendToolUpdate({}); + assert.equal(anon.title, "tool", "a header the decoder can still attribute"); + }); + + test("a structured result is stringified rather than dropped", async (t) => { + const facade = await bootFacade(t); + // A tool webui cannot render is still a tool the user ran. + const u = facade.sendToolUpdate(EV.tool({ status: 2, output: { rows: [1, 2] } })); + assert.equal(u.rawOutput.content[0].text, '{"rows":[1,2]}'); + }); + + test("a typed content array is joined, not JSON-stringified whole", async (t) => { + const facade = await bootFacade(t); + // The ACP path delivered `rawOutput.content[]` as typed parts; the + // runtime delivers a parsed value. Both must reduce to the same + // text or the two transports' tool bodies differ. + const u = facade.sendToolUpdate( + EV.tool({ status: 2, output: [{ type: "text", text: "a" }, { type: "text", text: "b" }] }), + ); + assert.equal(u.rawOutput.content[0].text, "a\nb"); + }); + + test("REVERSE: a result that cannot be stringified yields no body, not a throw", async (t) => { + const facade = await bootFacade(t); + const circular = {}; + circular.self = circular; + const u = facade.sendToolUpdate(EV.tool({ status: 2, output: circular })); + assert.equal(u.rawOutput, undefined, "no body is a truthful rendering; a throw is not"); + }); + + test("REVERSE: a missing call is a total function", async (t) => { + const facade = await bootFacade(t); + for (const input of [null, undefined, "nonsense"]) { + const u = facade.sendToolUpdate(input); + assert.equal(u.title, "tool", JSON.stringify(input)); + assert.equal(u.toolCallId, undefined, JSON.stringify(input)); + } + }); + + test("REVERSE: an id-less call is not given a fabricated one", async (t) => { + const facade = await bootFacade(t); + // Inventing an id would make two unrelated calls collide on one + // header and one of them would vanish. + const u = facade.sendToolUpdate({ name: "ls", status: 1 }); + assert.equal("toolCallId" in u, false); + }); +}); + +describe("the tool-call header line", () => { + test("the header carries the args, in the ACP path's exact spelling", async (t) => { + const facade = await bootFacade(t); + // The double space is the ACP line's, and the decoder splits on it. + // `applyToolUpdate`'s own synthesized header has no args at all — + // that form exists for a body attached mid-stream — which is why + // the runner writes this one itself. + const u = facade.sendToolUpdate(EV.tool({ status: 1 })); + assert.equal(facade.sendToolHeaderLine(u), '→ read {"path":"/tmp/a"}'); + }); + + test("a call with no args is a bare `→ name`", async (t) => { + const facade = await bootFacade(t); + assert.equal( + facade.sendToolHeaderLine(facade.sendToolUpdate({ id: "x", name: "ls" })), + "→ ls", + ); + }); + + test("REVERSE: a circular argument object loses the args, not the header", async (t) => { + const facade = await bootFacade(t); + const circular = {}; + circular.self = circular; + const u = facade.sendToolUpdate({ id: "x", name: "ls", input: circular }); + assert.equal(facade.sendToolHeaderLine(u), "→ ls"); + }); + + test("REVERSE: a missing update is a total function", async (t) => { + const facade = await bootFacade(t); + assert.equal(facade.sendToolHeaderLine(null), "→ tool"); + assert.equal(facade.sendToolHeaderLine(undefined), "→ tool"); + }); +}); + +describe("usage projection", () => { + test("the three totals the finalize accumulates come through", async (t) => { + const facade = await bootFacade(t); + assert.deepEqual( + facade.sendUsageTotals({ totalTokens: 120, inputTokens: 100, outputTokens: 20 }), + { totalTokens: 120, inputTokens: 100, outputTokens: 20 }, + ); + }); + + test("REVERSE: no usage yields null, NOT a zeroed object", async (t) => { + const facade = await bootFacade(t); + // The finalize's "no usage" branch is what falls back to a + // length-based estimate. A zeroed object takes that branch away and + // the context panel reads zero tokens for the rest of the session. + for (const input of [null, undefined, {}, { reasoningTokens: 5 }, "nonsense"]) { + assert.equal(facade.sendUsageTotals(input), null, JSON.stringify(input)); + } + }); + + test("a partial usage is completed with zeros, not with undefined", async (t) => { + const facade = await bootFacade(t); + // `undefined` in the total would make `+ (r.usage.totalTokens || 0)` + // work by accident, but the shape is also asserted by consumers; + // a number is the honest zero here. + assert.deepEqual(facade.sendUsageTotals({ inputTokens: 7 }), { + totalTokens: 0, + inputTokens: 7, + outputTokens: 0, + }); + }); + + test("a non-finite number is not a number", async (t) => { + const facade = await bootFacade(t); + assert.equal(facade.sendUsageTotals({ totalTokens: NaN }), null); + assert.equal(facade.sendUsageTotals({ totalTokens: Infinity }), null); + }); + + test("the runtime's snake_case spelling is not accepted by accident", async (t) => { + const facade = await bootFacade(t); + // The TUI projection already renamed these to camelCase. If a + // future change lets the raw wire shape through, the totals would + // silently read as absent and the estimate branch would win — which + // looks like a working turn with a wrong context panel. + assert.equal( + facade.sendUsageTotals({ total_tokens: 120, input_tokens: 100 }), + null, + ); + }); +}); + +// =========================================================================== +// 5. The data plane (B8b) +// =========================================================================== + +describe("the attachment projection", () => { + test("webui's `{path, name, size}` becomes the runtime's `{meta, local}` pair", async (t) => { + const facade = await bootFacade(t); + const out = facade.projectSendAttachments([{ path: "/u/a.png", name: "a.png", size: 12 }], { + MAX_ATTACHMENTS_PER_TURN: 16, + }); + assert.deepEqual(out, [ + { + meta: { + attachmentType: "file", + fileName: "a.png", + mimeType: "application/octet-stream", + sizeBytes: 12, + }, + local: { filePath: "/u/a.png" }, + }, + ]); + }); + + test("the mime type is the honest default, never a guess (KNOWN DEBT 2)", async (t) => { + const facade = await bootFacade(t); + const [one] = facade.projectSendAttachments([{ path: "/u/a.png", name: "a.png" }], {}); + // webui's upload pipeline discards the type, so the runtime is told + // octet-stream rather than a value invented here. + assert.equal(one.meta.mimeType, "application/octet-stream"); + }); + + test("REVERSE: an empty, missing or oversized list is bounded, never a throw", async (t) => { + const facade = await bootFacade(t); + assert.deepEqual(facade.projectSendAttachments([], {}), []); + assert.deepEqual(facade.projectSendAttachments(null, {}), []); + assert.deepEqual(facade.projectSendAttachments(undefined, {}), []); + const many = Array.from({ length: 5 }, (_, i) => ({ path: `/u/${i}`, name: `${i}` })); + assert.equal(facade.projectSendAttachments(many, { MAX_ATTACHMENTS_PER_TURN: 2 }).length, 2); + }); + + test("REVERSE: a nameless attachment still gets a filename", async (t) => { + const facade = await bootFacade(t); + const [one] = facade.projectSendAttachments([{ path: "/u/x" }], {}); + assert.equal(one.meta.fileName, "attachment"); + }); +}); + +describe("opening a runtime turn", () => { + test("a first turn CREATES the session and sends the content on it", async (t) => { + const facade = await bootFacade(t); + const seen = { created: null, sent: null }; + const catalogue = { + adapter: { + createSession: async (input) => { + seen.created = input; + return { sessionId: SID }; + }, + }, + }; + const stream = mkStream([]); + const turn = { sendMessage: (req) => { seen.sent = req; return stream; }, close() {} }; + const r = await facade.openEngineSendStream({ + sessionId: null, + content: "hello", + workspaceDir: "/w", + deps: { getHost: async () => catalogue, createTurnHost: () => turn }, + }); + assert.equal(r.ok, true); + assert.equal(r.sessionId, SID); + assert.deepEqual(seen.created, { workspaceDir: "/w" }); + assert.equal(seen.sent.id, SID); + assert.equal(seen.sent.content, "hello"); + }); + + test("an EXISTING session is reused — no createSession call at all", async (t) => { + const facade = await bootFacade(t); + let created = 0; + const catalogue = { adapter: { createSession: async () => { created += 1; return {}; } } }; + const turn = { sendMessage: () => mkStream([]), close() {} }; + const r = await facade.openEngineSendStream({ + sessionId: SID, + content: "x", + workspaceDir: "/w", + deps: { getHost: async () => catalogue, createTurnHost: () => turn }, + }); + assert.equal(r.ok, true); + assert.equal(r.sessionId, SID); + assert.equal(created, 0, "a second turn must not fork a new engine conversation"); + }); + + test("a host that never booted is a failure, NOT a silent empty stream", async (t) => { + const facade = await bootFacade(t); + const r = await facade.openEngineSendStream({ + sessionId: SID, + content: "x", + deps: { getHost: async () => null, createTurnHost: () => ({}) }, + }); + assert.equal(r.ok, false); + assert.equal(r.sessionId, SID, "the existing session is still reported for the error path"); + assert.ok(r.message.includes("unavailable")); + }); + + test("a createSession that returns no id fails loudly rather than sending nowhere", async (t) => { + const facade = await bootFacade(t); + let sent = 0; + const catalogue = { adapter: { createSession: async () => ({}) } }; + const turn = { sendMessage: () => { sent += 1; return mkStream([]); }, close() {} }; + const r = await facade.openEngineSendStream({ + sessionId: null, + content: "x", + deps: { getHost: async () => catalogue, createTurnHost: () => turn }, + }); + assert.equal(r.ok, false); + assert.equal(sent, 0, "a turn with no session has nowhere to go"); + }); + + test("a throwing host wrapper is a failure, and the turn host is not leaked", async (t) => { + const facade = await bootFacade(t); + const r = await facade.openEngineSendStream({ + sessionId: SID, + content: "x", + deps: { + getHost: async () => ({ adapter: {} }), + createTurnHost: () => { + throw new Error("adapter missing"); + }, + }, + }); + assert.equal(r.ok, false); + assert.equal(r.message, "adapter missing"); + }); +}); + + +// =========================================================================== +// 6. The runtime runner writes webui lines +// =========================================================================== + +// =========================================================================== +// 5. The route and the runner, with the proof that each mock took +// =========================================================================== + +/** + * The real `lib/mcode-acp.js` runtime runner, driven by a fake host. + * + * `openEngineSendStream` is the ONLY seam, so replacing it is enough to + * drive the entire imperative half — the line writes, the segment + * accumulation, the tool headers, the finalize, the run-mirror writes + * — without a runtime, a host, or a clock. This is the coverage the + * pure layer cannot give: the bridge being right is not the same as + * the runner USING it right. + */ +async function bootRuntimeRunner(t, { events, sid = SID, turnHost, gate = null }) { + // `mavis` is mocked for a reason that is not hygiene: the runner's + // finalize fires a 400 ms post-turn re-query through + // `applyMavisUsageToCs`, and the REAL one spawns a `sqlite3` + // subprocess against a real data dir. Every runner case would + // otherwise leave a live child behind, and one leaked handle hangs + // the whole file (it did — 280 s, reported as one failure). + await setupMocks(t, { mavis: { applyMavisUsageToCs: async () => ({}) } }); + let sent = null; + const facade = await import(absPath("engine/streaming-send.js")); + const opened = { + ok: true, + sessionId: sid, + stream: mkStream(events, gate), + turnHost: turnHost || { close() {} }, + }; + t.mock.module(absPath("engine/streaming-send.js"), { + namedExports: { + ...facade, + openEngineSendStream: async (req) => { + sent = req; + return opened; + }, + }, + }); + const acp = await import(`${absPath("lib/mcode-acp.js")}?bust=${bust++}`); + return { acp, facade, getSent: () => sent, opened }; +} + +/** The lines the run-chat buffer holds after a turn, plus the drain. */ +function drainedLines(cid, sid) { + const bus = require_bus(); + return bus.drainRunChat(cid, sid); +} +let _bus = null; +function require_bus() { + return _bus; +} + +describe("the runtime runner writes webui lines", () => { + test("a text turn renders `▲` then `●`, strips the cursor, and answers", async (t) => { + const { acp } = await bootRuntimeRunner(t, { + events: [ + EV.started, + EV.deltaThinking("先读文件"), + EV.deltaThinking("再改"), + EV.deltaText("改好"), + EV.deltaText("了"), + EV.settled({ content: "改好了", finishReason: "stop", id: "msg-1" }), + EV.finished, + ], + }); + const bus = (await import(absPath("lib/state-bus.js"))); + _bus = bus; + const cs = mkCs(); + const r = await acp.runMcodeRuntime("改一下文件", { + label: "prompt", + sessionId: null, + cs, + cid: "b8-lines", + owningWebuiSessionId: "webui-1", + }); + assert.equal(r.status, "succeeded"); + // The accumulator is per-segment: two thinking deltas concatenate + // into one `▲` line, and the tool-free text segment starts its own + // `●` line. + assert.equal(r.thinking, "先读文件再改"); + assert.equal(r.answer, "改好了", "the settled message overwrites the accumulated segment"); + assert.equal(r.stopReason, "stop"); + assert.equal(r.assistantMessageId, "msg-1"); + const lines = bus.drainRunChat("b8-lines", SID); + assert.ok(lines.some((l) => l.startsWith("▲ 先读文件再改")), JSON.stringify(lines)); + assert.ok(lines.some((l) => l.startsWith("● 改好了")), JSON.stringify(lines)); + // finalize strips every streaming cursor and writes the two + // transcript markers the ACP runner writes. + assert.equal( + lines.some((l) => typeof l === "string" && l.endsWith(" ▍")), + false, + "an un-stripped cursor leaves the block flickering forever", + ); + assert.ok(lines.some((l) => l.startsWith("§§ processed_duration="))); + assert.ok(lines.some((l) => l === "§§ turn_msg=msg-1")); + // And the panel is back at rest. + assert.equal(cs.running.active, false); + assert.equal(cs.context.thinkingStatus, "Idle"); + }); + + test("a tool call emits the `##tc:` marker and a `→ name` header ONCE per call", async (t) => { + // The runtime re-sends the whole tool call on every lifecycle chunk. + // A header per chunk would be a different tool block per stage. + const { acp } = await bootRuntimeRunner(t, { + events: [ + EV.deltaTool([EV.tool({ status: 4 })]), + EV.deltaTool([EV.tool({ status: 5 })]), + EV.deltaTool([EV.tool({ status: 1 })]), + EV.deltaText("reading"), + EV.deltaTool([EV.tool({ status: 2, output: "file body" })]), + EV.settled({ content: "reading" }), + EV.done, + ], + }); + const bus = (await import(absPath("lib/state-bus.js"))); + const cs = mkCs(); + await acp.runMcodeRuntime("读文件", { + sessionId: null, + cs, + cid: "b8-tool", + owningWebuiSessionId: "webui-1", + }); + const lines = bus.drainRunChat("b8-tool", SID); + const headers = lines.filter((l) => typeof l === "string" && l.startsWith("→ ")); + const markers = lines.filter((l) => typeof l === "string" && l.startsWith("##tc:")); + assert.equal(headers.length, 1, JSON.stringify(lines)); + assert.equal(headers[0], "→ read {\"path\":\"/tmp/a\"}"); + assert.equal(markers.length, 1, JSON.stringify(lines)); + assert.equal(markers[0], "##tc:tc-1"); + // The finished stage's body lands under the header. + assert.ok(lines.some((l) => l === " [completed]"), JSON.stringify(lines)); + assert.ok(lines.some((l) => l === " file body"), JSON.stringify(lines)); + }); + + test("a tool call breaks the text segment — the next `●` starts a new line", async (t) => { + const { acp } = await bootRuntimeRunner(t, { + events: [ + EV.deltaText("first"), + EV.deltaTool([EV.tool({ status: 1 })]), + EV.deltaText("second"), + EV.settled({ content: "second" }), + EV.done, + ], + }); + const bus = (await import(absPath("lib/state-bus.js"))); + await acp.runMcodeRuntime("x", { + sessionId: null, + cs: mkCs(), + cid: "b8-seg", + owningWebuiSessionId: "webui-1", + }); + const lines = bus.drainRunChat("b8-seg", SID); + const answers = lines.filter((l) => typeof l === "string" && l.startsWith("● ")); + // Two segments means two lines, and the second does NOT contain + // "first" — that is the session-isolation/06 bug this mirrors. + assert.equal(answers.length, 2, JSON.stringify(lines)); + assert.equal(answers[0], "● first"); + assert.equal(answers[1], "● second"); + }); + + test("an error turn resolves `failed` with the runtime's own message", async (t) => { + const { acp } = await bootRuntimeRunner(t, { + events: [EV.deltaText("partial"), EV.error("runtime exploded"), EV.done], + }); + const cs = mkCs(); + const r = await acp.runMcodeRuntime("x", { + sessionId: null, + cs, + cid: "b8-err", + owningWebuiSessionId: "webui-1", + }); + assert.equal(r.status, "failed"); + assert.equal(r.error.message, "runtime exploded"); + assert.equal(cs.running.active, false, "a failed turn still finalizes; the claim is released"); + }); + + test("an ABORT resolves `aborted`, not `failed` — a stop is a user action", async (t) => { + const { acp } = await bootRuntimeRunner(t, { + events: [EV.deltaText("partial"), EV.aborted], + }); + const r = await acp.runMcodeRuntime("x", { + sessionId: null, + cs: mkCs(), + cid: "b8-abort", + owningWebuiSessionId: "webui-1", + }); + // The route's error branch is gated on `status === "failed"`, so + // this is what keeps a stop from firing an error alert. + assert.equal(r.status, "aborted"); + assert.equal(r.error, null); + }); + + test("a stream that ends with no terminal event is a FAILURE, not a silent success", async (t) => { + // Truncation would otherwise render an unfinished turn as a + // complete one, which is #110's fake success with a different + // vocabulary. + const { acp } = await bootRuntimeRunner(t, { events: [EV.deltaText("half")] }); + const r = await acp.runMcodeRuntime("x", { + sessionId: null, + cs: mkCs(), + cid: "b8-trunc", + owningWebuiSessionId: "webui-1", + }); + assert.equal(r.status, "failed"); + assert.ok(r.error.message.includes("without a terminal event")); + }); + + test("a throwing iterator is a failure, and the turn host is closed", async (t) => { + let closed = 0; + const { acp } = await bootRuntimeRunner(t, { + events: [EV.deltaText("x"), THROW_MARKER], + turnHost: { close: () => { closed += 1; } }, + }); + const r = await acp.runMcodeRuntime("x", { + sessionId: null, + cs: mkCs(), + cid: "b8-throw", + owningWebuiSessionId: "webui-1", + }); + assert.equal(r.status, "failed"); + assert.equal(r.error.message, "iterator exploded"); + assert.equal(closed, 1, "a per-turn AbortController left open accumulates one per turn"); + }); + + test("an open that fails resolves `failed` rather than throwing at the route", async (t) => { + // The runtime's `client.start()` + `session/new` equivalent. Its + // failures are the same class: a start-phase failure that never + // reaches finalize, so the route's error branch is what resets the + // thinking claim. + await setupMocks(t, {}); + const facade = await import(absPath("engine/streaming-send.js")); + t.mock.module(absPath("engine/streaming-send.js"), { + namedExports: { + ...facade, + openEngineSendStream: async () => ({ + ok: false, + sessionId: null, + message: "Runtime host unavailable", + }), + }, + }); + const acp = await import(`${absPath("lib/mcode-acp.js")}?bust=${bust++}`); + const r = await acp.runMcodeRuntime("x", { sessionId: null, cs: mkCs(), cid: "b8-open" }); + assert.equal(r.status, "failed"); + assert.equal(r.error.message, "Runtime host unavailable"); + }); + + test("a mid-run switch stops the turn from stamping the NEW view", async (t) => { + // RED LINE 1, imperative half: the run-chat buffer is keyed by the + // engine sid, so a switch cannot move the lines — and the finalize's + // still-viewing test is what stops the `cs` mutation. The bind + // already ran (legitimately, while the turn's own record was still + // viewed), so this sets the id back to null to model "the user + // opened a different conversation that has no engine id" and + // asserts finalize leaves it alone. + const gate = mkGate(); + const { acp } = await bootRuntimeRunner(t, { + gate, + events: [ + EV.deltaText("belongs to the old session"), + GATE, + EV.settled({ content: "belongs to the old session" }), + EV.done, + ], + }); + const bus = (await import(absPath("lib/state-bus.js"))); + const cs = mkCs({ sessionId: "webui-1" }); + const p = acp.runMcodeRuntime("x", { + sessionId: null, + cs, + cid: "b8-switch", + owningWebuiSessionId: "webui-1", + }); + // The switch lands WHILE the turn is streaming. `await gate.reached` + // is the load-bearing line: it is what makes the ordering true + // rather than merely intended, and it is a promise rather than a + // poll so a failure here can never leak a ref'd handle. + await gate.reached; + cs.sessionId = OTHER_SID; + cs.mcodeSessionId = null; + gate.open(); + const r = await p; + assert.equal(r.sessionId, SID); + assert.equal( + cs.mcodeSessionId, + null, + "the finalize stamped this turn's engine sid onto the conversation the user switched TO", + ); + const lines = bus.drainRunChat("b8-switch", SID); + assert.ok( + lines.some((l) => typeof l === "string" && l.startsWith("● belongs to the old session")), + JSON.stringify(lines), + ); + }); + + test("a PRE-BIND switch binds the OWNING record, never the switched-to view", async (t) => { + // The other half of the run-mirror, and the one the bind-time test + // had to earn: the user switched away BEFORE the engine session was + // even known, so the bind must go through the record-by-id helper + // and leave `cs` alone. Binding through `cs` here would rename the + // session the user switched TO onto this turn's engine id — the + // permanent split the qa note records. + const { acp } = await bootRuntimeRunner(t, { + events: [EV.deltaText("x"), EV.settled({ content: "x" }), EV.done], + }); + // The owning draft has to EXIST for the bind to have something to + // target — in production `routes/chat.js#handleSend` creates it + // before the runner is called. Seeding it here is what makes the + // second assertion mean "the OWNING record was bound" rather than + // "nothing was bound". + registerSessionsStore({ + initial: [{ id: "webui-1", title: "New session", chat: [], workspace: null }], + }); + const cs = mkCs({ sessionId: OTHER_SID }); + await acp.runMcodeRuntime("x", { + sessionId: null, + cs, + cid: "b8-prebind", + owningWebuiSessionId: "webui-1", + }); + assert.equal( + cs.mcodeSessionId, + null, + "the bind went through the SWITCHED-TO client state instead of the owning record", + ); + const store = getSessionsStore(); + assert.equal( + store.filter((r) => r && r.mcodeSessionId === SID).length, + 1, + "the owning record is the one that got the engine identity", + ); + }); + + test("a turn with reported usage lands it in the context panel", async (t) => { + // The finalize's accumulation branch. Without this, dropping the + // usage capture entirely would be invisible: the panel would read + // zero tokens forever and nothing would fail. + const { acp } = await bootRuntimeRunner(t, { + events: [ + EV.deltaText("x"), + EV.settled({ + content: "x", + usage: { totalTokens: 120, inputTokens: 100, outputTokens: 20 }, + }), + EV.done, + ], + }); + const cs = mkCs(); + await acp.runMcodeRuntime("x", { + sessionId: null, + cs, + cid: "b8-usage", + owningWebuiSessionId: "webui-1", + }); + assert.equal(cs.context.tokens, 120); + assert.equal(cs.usage.sessionInput, 100); + assert.equal(cs.usage.sessionOutput, 20); + assert.equal(cs.usage.sessionTotal, 120); + assert.equal(cs.context.estimated, false, "a reported total is not an estimate"); + }); + + test("REVERSE: with no switch, the same turn DOES stamp the viewed session", async (t) => { + // The positive half of the line above. Without it the test above + // would pass for the wrong reason — a runner that never wrote + // anything would satisfy it too. + const { acp } = await bootRuntimeRunner(t, { + events: [EV.deltaText("x"), EV.settled({ content: "x" }), EV.done], + }); + const cs = mkCs({ sessionId: "webui-1" }); + await acp.runMcodeRuntime("x", { + sessionId: null, + cs, + cid: "b8-noswitch", + owningWebuiSessionId: "webui-1", + }); + assert.equal(cs.mcodeSessionId, SID); + }); +}); + +describe("routes/chat.js#handleSend on the runtime transport", () => { + /** Load the route with a chosen transport value baked into config.js. */ + async function loadRoute(t, transport) { + await setupMocks(t, {}); + const config = await import(absPath("lib/config.js")); + t.mock.module(absPath("lib/config.js"), { + namedExports: { ...config, MCODE_WEBUI_TRANSPORT: transport }, + }); + return import(`${absPath("routes/chat.js")}?bust=${bust++}`); + } + + test("the runtime transport calls the runtime runner and the ack is byte-identical", async (t) => { + const route = await loadRoute(t, RUNTIME); + let seen = null; + registerMcodeAcpMock({ + runMcodeRuntime: async (content, opts) => { + seen = { content, opts }; + return { status: "succeeded", answer: "ok", sessionId: SID }; + }, + }); + const cs = mkCs(); + const res = mkRes(); + await route.handleSend(jsonReq({ content: "hello" }), res, { cs, cid: "b8-route" }); + const seenRes = lastResponse(res); + assert.equal(seenRes.status, 200); + assert.equal(seenRes.headers["Content-Type"], "application/json; charset=utf-8"); + assert.equal(seenRes.body, '{"ok":true}', "the ack is the pre-M3 body, byte for byte"); + assert.equal(seen.content, "hello"); + // The options object is the one the acp branch passes too, minus the + // model (the runtime does not take one yet — KNOWN DEBT 4) — so the + // run-mirror id is present, which is what makes the tail identical. + assert.equal(seen.opts.owningWebuiSessionId, "webui-1"); + }); + + test("the ACP transport still calls the ACP runner — byte-for-byte unchanged", async (t) => { + // The survival condition, asserted at the branch itself: with the + // default transport the runtime runner is never reached. + const route = await loadRoute(t, ACP); + let acpCalls = 0; + let runtimeCalls = 0; + registerMcodeAcpMock({ + runMcodeAcp: async () => { + acpCalls += 1; + return { status: "succeeded", answer: "ok", sessionId: null }; + }, + runMcodeRuntime: async () => { + runtimeCalls += 1; + return { status: "succeeded", answer: "ok", sessionId: null }; + }, + }); + const res = mkRes(); + await route.handleSend(jsonReq({ content: "hello" }), mkResPlaceholder(res), { + cs: mkCs(), + cid: "b8-acp", + }); + assert.equal(acpCalls, 1); + assert.equal(runtimeCalls, 0); + }); + + test("MCODE_USE_ACP=0 still wins over the runtime transport", async (t) => { + // lib/config.js documents the precedence as "MCODE_USE_ACP=0 ⇒ + // transport=exec (regardless of MCODE_WEBUI_TRANSPORT)". The escape + // hatch exists for exactly the moment a transport misbehaves, so an + // operator must not have to unset a second variable first. + const prior = process.env.MCODE_USE_ACP; + process.env.MCODE_USE_ACP = "0"; + try { + const route = await loadRoute(t, RUNTIME); + let runtimeCalls = 0; + registerMcodeAcpMock({ + runMcodeRuntime: async () => { + runtimeCalls += 1; + return { status: "succeeded", answer: "ok", sessionId: null }; + }, + }); + await route.handleSend(jsonReq({ content: "hello" }), mkRes(), { + cs: mkCs(), + cid: "b8-exec", + }); + assert.equal(runtimeCalls, 0, "the exec escape hatch outranks the new branch"); + } finally { + if (prior === undefined) delete process.env.MCODE_USE_ACP; + else process.env.MCODE_USE_ACP = prior; + } + }); + + test("the 409 claim is taken BEFORE the runner and held for the whole turn", async (t) => { + // Sequential sends are useless here: the first one releases the + // claim in its `finally` before the second arrives. The claim's + // whole job is refusing a send that arrives WHILE a turn is + // running, so the runner has to still be in flight. + const route = await loadRoute(t, RUNTIME); + let release; + const held = new Promise((resolve) => { release = resolve; }); + let siblingSeen = null; + registerMcodeAcpMock({ + runMcodeRuntime: async () => { + const bus = (await import(absPath("lib/state-bus.js"))); + // A SIBLING conversation of the same tab must NOT be blocked — + // that is the whole difference between the (cid, sessionId) + // claim key and a cid-wide one, and the new branch must not + // change it. + const sibling = bus.beginRun("b8-409", null, "other-conversation"); + try { + siblingSeen = sibling; + } finally { + bus.endRun("b8-409", "other-conversation"); + } + await held; + return { status: "succeeded", answer: "ok", sessionId: SID }; + }, + }); + const cs = mkCs(); + const first = route.handleSend(jsonReq({ content: "one" }), mkRes(), { cs, cid: "b8-409" }); + // Give the first send time to reach the runner and take the claim. + await new Promise((resolve) => setImmediate(resolve)); + await new Promise((resolve) => setImmediate(resolve)); + // Now the duplicate arrives, mid-turn. Every assertion is inside the + // try because a failed one must still release the held turn — an + // un-resolved runner promise hangs the whole file, not just the + // test. + try { + const res = mkRes(); + await route.handleSend(jsonReq({ content: "two" }), res, { cs, cid: "b8-409" }); + const seen = lastResponse(res); + assert.equal(seen.status, 409); + assert.equal( + seen.body, + '{"ok":false,"error":"a turn is already running for this session","reason":"cid-busy"}', + "the pre-M3 409 body, byte for byte", + ); + assert.equal( + siblingSeen && siblingSeen.ok, + true, + `a sibling conversation in the same tab was blocked: ${siblingSeen && siblingSeen.reason}`, + ); + } finally { + release(); + } + await first; + }); + + test("the 409 claim is RELEASED after a runtime turn, so the next send is accepted", async (t) => { + // The negative half of the claim red line, and the reason the gate + // was placed before `beginRun` rather than after it: a claim leaked + // by a throwing gate would refuse every later send in this + // conversation forever. + const route = await loadRoute(t, RUNTIME); + let calls = 0; + registerMcodeAcpMock({ + runMcodeRuntime: async () => { + calls += 1; + return { status: "succeeded", answer: "ok", sessionId: SID }; + }, + }); + const cs = mkCs(); + await route.handleSend(jsonReq({ content: "one" }), mkRes(), { cs, cid: "b8-release" }); + const res = mkRes(); + await route.handleSend(jsonReq({ content: "two" }), res, { cs, cid: "b8-release" }); + assert.equal(lastResponse(res).status, 200); + assert.equal(calls, 2, "the second send reached the runner, so the claim was released"); + }); + + test("PROOF: a marker error from the gate escapes the route as a capability error", async (t) => { + // Without a fresh `?bust=` re-import, `mock.module` would leave the + // route holding the PREVIOUS test's live binding, the marker would + // never be thrown, and this assertion would fail — which is the + // point: it is the only assertion here that cannot pass by + // accident. + await setupMocks(t, {}); + const { EngineCapabilityNotSupportedError } = await import(absPath("engine/errors.js")); + const marker = new EngineCapabilityNotSupportedError({ + capability: "streamingSend", + provider: "local-runtime-v2", + }); + mockFacade(t, { + assertStreamingSendCapability: () => { + throw marker; + }, + }); + const route = await import(`${absPath("routes/chat.js")}?bust=${bust++}`); + let caught = null; + const cs = mkCs(); + try { + await route.handleSend(jsonReq({ content: "hello" }), mkRes(), { cs, cid: "b8-gate" }); + } catch (err) { + caught = err; + } + assert.ok(caught, "the route swallowed the gate error — either the mock did not take, or the route grew a catch"); + assert.equal(caught, marker, "the error is the mock's, by identity"); + assert.equal(isEngineCapabilityNotSupportedError(caught), true); + // And no claim was taken, so the NEXT send in this conversation is + // not refused by one this batch leaked. Asked of the state bus + // directly rather than by issuing a second send: the gate mock + // throws unconditionally, so a second send would throw too and say + // nothing about the claim. + const bus = (await import(absPath("lib/state-bus.js"))); + const probe = bus.beginRun("b8-gate", null, "webui-1"); + try { + assert.equal( + probe.ok, + true, + `a refused gate left a claim behind: ${probe.reason}`, + ); + } finally { + bus.endRun("b8-gate", "webui-1"); + } + }); + + test("PROOF: a marker error from the runtime runner reaches the route's own finally", async (t) => { + // The other live-binding proof, on the other mock: if + // `runMcodeRuntime` re-imports were not honoured, this would + // resolve normally and the assertion below would fail. + const route = await loadRoute(t, RUNTIME); + registerMcodeAcpMock({ + runMcodeRuntime: async () => { + throw new Error("B8-RUNTIME-MOCK-WAS-NOT-HONOURED"); + }, + }); + const reloaded = await import(`${absPath("routes/chat.js")}?bust=${bust++}`); + let caught = null; + try { + await reloaded.handleSend(jsonReq({ content: "hello" }), mkRes(), { + cs: mkCs(), + cid: "b8-mock", + }); + } catch (err) { + caught = err; + } + assert.ok(caught, "the route swallowed the runner error"); + assert.equal(caught.message, "B8-RUNTIME-MOCK-WAS-NOT-HONOURED"); + }); +}); + +/** `handleSend` needs a fresh response object per call in some cases. */ +function mkResPlaceholder(res) { + return res; +} diff --git a/packages/webui/test/lib/state-bus.check.mjs b/packages/webui/test/lib/state-bus.check.mjs index 616666693..b3a783fd5 100644 --- a/packages/webui/test/lib/state-bus.check.mjs +++ b/packages/webui/test/lib/state-bus.check.mjs @@ -19,6 +19,7 @@ import { let pushStateFor, mcodeSessionsSnapshotFields; let clients, sseByCid, makeClientState; let acpFetchCalls, cachedByWs; +let hasDecisionListener; before(async (t) => { await setupMocks(t); @@ -28,6 +29,7 @@ before(async (t) => { clients = mod.clients; sseByCid = mod.sseByCid; makeClientState = mod.makeClientState; + hasDecisionListener = mod.hasDecisionListener; }); // Mock acp-client to track fetch calls and serve cache from in-memory map @@ -382,3 +384,47 @@ describe("v1.0 push fields — mcodeSessions 永不缺失、永不空占位", () assert.equal(payload.mcodeSessionsPending, true); }); }); + +// =========================================================================== +// hasDecisionListener — "could this authorization request be decided?" +// +// The gate pushes a `needs_authorization` frame into the target's SSE +// response. Whether a request is DECIDABLE is a property of that +// connection registry, not of the timer: authorize() asks this before it +// arms the 5-minute budget, so a request nobody can answer fails closed +// at once instead of holding a destructive HTTP request open until the +// budget expires. +// =========================================================================== +describe("hasDecisionListener — 可否被裁决(决定请求是否挂起的那个事实)", () => { + test("无连接时返回 false — 空通道没有可交付的裁决人", () => { + assert.equal(hasDecisionListener("cid-x"), false); + }); + + test("目标 cid 有活连接时返回 true", () => { + sseByCid.set("cid-x", fakeSse()); + assert.equal(hasDecisionListener("cid-x"), true); + }); + + test("别的 cid 连着不算 — 单播请求不会被送进不相干的标签页", () => { + sseByCid.set("cid-other", fakeSse()); + assert.equal(hasDecisionListener("cid-x"), false); + }); + + test("响应已结束(writableEnded)不算活 — 帧会静默丢弃", () => { + const dead = { ...fakeSse(), writableEnded: true }; + sseByCid.set("cid-x", dead); + assert.equal(hasDecisionListener("cid-x"), false); + }); + + test("socket 已销毁不算活 — 标签页崩了/断网了", () => { + const dead = { ...fakeSse(), destroyed: true }; + sseByCid.set("cid-x", dead); + assert.equal(hasDecisionListener("cid-x"), false); + }); + + test("空 cid 是广播(startup.cleanup 形态)— 任一标签页在线即可裁决", () => { + assert.equal(hasDecisionListener(""), false); + sseByCid.set("cid-anyone", fakeSse()); + assert.equal(hasDecisionListener(""), true); + }); +}); diff --git a/packages/webui/test/lib/transcript-merge.test.js b/packages/webui/test/lib/transcript-merge.test.js index b564263ca..dc043f9b1 100644 --- a/packages/webui/test/lib/transcript-merge.test.js +++ b/packages/webui/test/lib/transcript-merge.test.js @@ -31,6 +31,7 @@ import { mkTmpDir } from "../helpers/tmp.js"; const MVS = "mvs_aaaa1111bbbb2222cccc3333dddd4444"; let mergeEngineTranscript; +let messagesToChatLines; let syncTranscriptsOnce; let transcriptChanged; let stateBus; @@ -54,6 +55,7 @@ before(async (t) => { }); transcriptLib = await import(absPath("lib/transcript.js")); mergeEngineTranscript = transcriptLib.mergeEngineTranscript; + messagesToChatLines = transcriptLib.messagesToChatLines; const ts = await import(absPath("lib/transcript-sync.js")); syncTranscriptsOnce = ts.syncTranscriptsOnce; transcriptChanged = ts.transcriptChanged; @@ -247,7 +249,407 @@ describe("mergeEngineTranscript — the engine read is a spine, not a replacemen }); // ============================================================ -// 2. The poller is wired to the merge +// 2. Lossy streaming mirrors retire +// ============================================================ +// +// The defect this section pins. The merge contract above was written for +// lines the webui AUTHORS. It was then applied to lines the webui MIRRORS, +// and the mirror is lossy by construction: +// +// mcode-acp.js (stream `message`/`thought` chunk) and routes/chat.js:341 +// (finalize) write ONE line, `prefix + r.answer.replace(/\n+/g," ").trim()`, +// where the engine's own mapper (`_proseLines`) keeps ONE ARRAY ENTRY PER +// SOURCE LINE. The two can never be byte-equal, so the lockstep walk called +// every mirror "webui-authored", kept it, and then appended the engine's +// whole spine behind it. A tool header lost its args the same way (`→ bash` +// against the engine's `→ bash {json}`), so the tool block doubled too. +// +// Observed on a live UAT session: 81 stored lines, of which 14 were a second +// copy of engine content that was not in the transcript a minute earlier — +// and `persistCurrentChat` wrote the duplicate back to disk, so a reload kept +// showing it. The mirrored shapes are reproduced structurally below; the +// numbers are that session's (16 mirror lines against a 67-line engine read), +// the prose is synthetic. + +describe("mergeEngineTranscript — a lossy streaming mirror retires", () => { + // The engine's source text for one turn. The shape is what matters: + // blank lines (which become `● ` placeholders on the engine side and a + // single collapsed space in the mirror), a fenced code block with real + // indentation, and a markdown table. All three are places a naive + // "same line" compare diverges from a "same content" compare. + const THINKING = [ + "The directory is empty.", + "", + "Now let me provide the results.", + "", + "Let me pick a topic for the table.", + ].join("\n"); + const ANSWER = [ + "All three items are done, results below.", + "", + "## 1) directory listing", + "", + "```", + "total 44", + "drwxr-xr-x 2 u u 4096 Oct 3 09:25 .", + "drwxrwxr-x 501 u u 4096 Oct 3 09:25 ..", + "```", + "", + "## 2) table", + "", + "| lang | complexity | use |", + "|---|---|---|", + "| Python | O(n²) | teaching |", + "| Java | O(n log n) | backend |", + "", + "## 3) code", + "", + "```python", + "def bubble_sort(arr):", + " n = len(arr)", + " for i in range(n - 1):", + " if arr[i] > arr[i + 1]:", + " arr[i], arr[i + 1] = arr[i + 1], arr[i]", + " return arr", + "```", + ].join("\n"); + + // The engine's own view: exactly the rows the runtime DB returned, fed + // through the production mapper so the fixture cannot drift from it. + const ENGINE_MESSAGES = [ + { role: "user", content: "list the directory, then a table, then code", turnId: "t1" }, + { + role: "assistant", + content: "I'll start by checking the workspace directory.", + turnId: "t1", + tool_calls: [ + { + name: "bash", + arguments: '{"command":"ls -la"}', + status: "completed", + result: "total 44\ndrwxr-xr-x 2 u u 4096 Oct 3 09:25 .", + }, + ], + }, + { role: "assistant", thinking: THINKING, content: ANSWER, turnId: "t1", msgId: "M3" }, + ]; + + /** The engine side of the merge, byte-for-byte what the poller reads. */ + const engineLines = () => messagesToChatLines(ENGINE_MESSAGES).lines; + + /** + * The webui side: what the streaming writers actually put in `cs.chat`. + * `fold` is the flatten the answer/thinking branches apply; the tool header + * is written bare because the frame carried no `rawInput`. + */ + const mirrorLines = ({ fold = true, toolArgs = false } = {}) => { + const lines = [ + "› list the directory, then a table, then code", + "● I'll start by checking the workspace directory.", + "##tc:call_1", + toolArgs ? '→ bash {"command":"ls -la"}' : "→ bash", + " [completed]", + " total 44", + " drwxr-xr-x 2 u u 4096 Oct 3 09:25 .", + " drwxrwxr-x 501 u u 4096 Oct 3 09:25 ..", + " ", + " [in_progress]", + " [completed]", + fold ? `▲ ${THINKING.replace(/\n+/g, " ").trim()}` : "▲ The directory is empty.", + fold ? `● ${ANSWER.replace(/\n+/g, " ").trim()}` : "● All three items are done, results below.", + "§§ processed_duration=11071ms", + "§§ turn_msg=M3", + ]; + return lines; + }; + + const countOf = (lines, needle) => lines.filter((l) => l === needle).length; + + test("the flattened mirror answer retires: one rendered copy, the engine's per-line one", () => { + const read = engineLines(); + const have = mirrorLines(); + const merged = mergeEngineTranscript(read, have); + + // The mirror line itself is gone, and so is the flattened thinking line. + assert.equal(countOf(merged, `● ${ANSWER.replace(/\n+/g, " ").trim()}`), 0); + assert.equal(countOf(merged, `▲ ${THINKING.replace(/\n+/g, " ").trim()}`), 0); + // What is left is the engine's line-by-line version — every source line + // once, in order. The whole answer is present exactly once. + const answerLines = merged.filter((l) => l.startsWith("● ")); + assert.equal(answerLines.length, 1 + ANSWER.split("\n").length); + assert.ok(merged.includes("● ## 3) code"), "the folded answer is rendered per line"); + assert.ok(merged.includes("● for i in range(n - 1):"), "indentation survives"); + }); + + test("the lossy `→ bash` header retires: one tool block, carrying the engine's args", () => { + const read = engineLines(); + const have = mirrorLines(); + const merged = mergeEngineTranscript(read, have); + + assert.equal(countOf(merged, "→ bash"), 0, "the arg-less mirror header is retired"); + assert.equal(countOf(merged, '→ bash {"command":"ls -la"}'), 1, "exactly one tool header"); + // The mirror's redundant body lines go with it; the engine's body is the + // one that renders, and the streaming-only `[in_progress]` marker is not + // carried over into a finished turn. + assert.equal(countOf(merged, " [in_progress]"), 0); + assert.equal(countOf(merged, " [completed]"), 1); + assert.equal(countOf(merged, " total 44"), 1); + }); + + test("no engine line is lost and the webui's own annotations stay", () => { + const read = engineLines(); + const merged = mergeEngineTranscript(read, mirrorLines()); + for (const line of read) { + assert.ok(merged.includes(line), "engine line dropped: " + JSON.stringify(line)); + } + // Neither of these ever reaches the engine DB, so neither can be retired. + assert.ok(merged.includes("##tc:call_1"), "the tool-call correlation marker stays"); + assert.ok( + merged.includes("§§ processed_duration=11071ms"), + "the per-turn duration marker this webui writes stays", + ); + }); + + test("a webui-local `› /help` echo still survives the merge that retires mirrors", () => { + // The reverse half, and the guard on over-reach: the retirement runs in + // this merge (both mirrors above did retire) and the local echo is + // still there. This is the #126 behaviour the merge exists for. + const read = engineLines(); + const have = ["› /help", "● available commands:", " /new", ...mirrorLines()]; + const merged = mergeEngineTranscript(read, have); + + assert.ok(merged.includes("› /help"), "local echo kept: " + JSON.stringify(merged.slice(0, 4))); + assert.ok(merged.includes("● available commands:")); + assert.ok(merged.includes(" /new")); + // …and the retirement still happened in the same pass. + assert.equal(countOf(merged, `● ${ANSWER.replace(/\n+/g, " ").trim()}`), 0); + assert.equal(countOf(merged, '→ bash {"command":"ls -la"}'), 1); + }); + + test("a short local answer the engine never saw is not mistaken for a mirror", () => { + // The `● pong` of a `/status` echo, sitting at a cursor that holds + // different engine text. Folding cannot match text the engine does not + // have, so the line stays — the case a "looks like a mirror" heuristic + // gets wrong. + const read = ["› ping", "● the engine answered something else"]; + const have = ["› ping", "● pong", "● current model=X"]; + const merged = mergeEngineTranscript(read, have); + assert.ok(merged.includes("● pong"), "the local answer is kept: " + JSON.stringify(merged)); + assert.ok(merged.includes("● current model=X")); + // The engine's own answer still lands (it is content this tab has not + // seen), behind the local lines — unchanged from before. + assert.ok(merged.includes("● the engine answered something else")); + }); + + test("a same-glyph run that folds to something else keeps the mirror", () => { + // The engine's `●` run is real, but it is not what the local line says. + // Retirement is an identity test, so a miss keeps the line (the old + // double render) — it never drops content on a shape resemblance. + const read = ["› ping", "● alpha", "● beta"]; + const have = ["› ping", "● alpha beta gamma"]; + const merged = mergeEngineTranscript(read, have); + assert.ok(merged.includes("● alpha beta gamma"), "mirror kept: " + JSON.stringify(merged)); + }); + + test("a tool header for a different tool does not retire", () => { + // Ordered consumption by name: `→ other_tool` is not a lossy copy of + // `→ bash`, however similar the two blocks look, so the mirror header + // and the body streamed under it both stay. + const read = ["→ bash {\"command\":\"ls\"}"]; + const have = ["→ other_tool", " streamed body the engine has not stored"]; + const merged = mergeEngineTranscript(read, have); + assert.ok(merged.includes("→ other_tool"), "mirror header kept: " + JSON.stringify(merged)); + assert.ok(merged.includes(" streamed body the engine has not stored")); + }); + + test("the merge stays pure: retirement does not touch either input", () => { + const read = engineLines(); + const have = mirrorLines(); + const readCopy = [...read]; + const haveCopy = [...have]; + mergeEngineTranscript(read, have); + assert.deepEqual(read, readCopy); + assert.deepEqual(have, haveCopy); + }); +}); + +// ============================================================ +// 3. The UAT shape: 81 stored lines → no duplicated answer +// ============================================================ +// +// The line counts are the real ones from the UAT session whose stored chat +// was 81 lines against a 67-line engine read; 14 of the 81 were a second copy +// of engine content. The prose is synthetic — only the STRUCTURE is quoted +// (one flattened `▲`, one flattened `●`, a bare ` ` block terminator, a +// `[in_progress]` status line, a `##tc:` marker and a `§§` pair the engine +// never stores). This is the regression assertion in its end-to-end shape: +// merge the live mirror against the engine read and count the copies. + +describe("the UAT 81-line shape loses its duplicate answer", () => { + // A 9-line thinking chain and a 25-line answer: the UAT turn's engine read + // was 67 lines, its mirror 16. + const UAT_THINKING = [ + "The directory is empty.", + "", + "Now let me provide the results.", + "", + "Let me think about the report.", + "", + "Note: no file deliverables are needed here.", + "", + "Let me pick a topic for the table.", + ].join("\n"); + const UAT_ANSWER = [ + "All three items are done, results below.", + "", + "## 1) directory listing", + "", + "the actual output of the listing command:", + "", + "```", + "total 44", + "drwxr-xr-x 2 u u 4096 Oct 3 09:25 .", + "drwxrwxr-x 501 u u 4096 Oct 3 09:25 ..", + "```", + "", + "Conclusion: the working directory is empty — only . and .. are present. The owner and the group are the current user, and the link count on .. reflects the 501 entries of the parent.", + "", + "## 2) markdown table (3 columns, header included)", + "", + "| language | average complexity | typical use |", + "|---|---|---|", + "| Python | O(n²) | teaching, small inputs |", + "| Java | O(n log n) | enterprise backends |", + "| Rust | O(n log n) | systems, embedded |", + "| Go | O(n log n) | microservices |", + "", + "## 3) python bubble sort", + "", + "```python", + "def bubble_sort(arr):", + ' """In-place bubble sort: swap adjacent out-of-order pairs."""', + " n = len(arr)", + " for i in range(n - 1):", + " swapped = False", + " # the right edge shrinks every round", + " for j in range(n - 1 - i):", + " if arr[j] > arr[j + 1]:", + " arr[j], arr[j + 1] = arr[j + 1], arr[j]", + " swapped = True", + " if not swapped:", + " break", + " return arr", + "", + "", + 'if __name__ == "__main__":', + ' data = [64, 34, 25, 12, 22, 11, 90]', + ' print("before:", data)', + ' print("after: ", bubble_sort(data))', + "```", + "", + "The early exit costs nothing on average and keeps the best case at O(n); the worst case is O(n²) with O(1) extra space, and the sort is stable, so equal elements keep their relative order.", + "", + "Tell me if you want the table extended.", + ].join("\n"); + + const UAT_MESSAGES = [ + { role: "user", content: "do the three things", turnId: "t9" }, + { + role: "assistant", + content: "I'll start by checking the workspace directory.", + turnId: "t9", + tool_calls: [ + { + name: "bash", + arguments: '{"command":"ls -la","description":"list the working directory"}', + status: "completed", + result: "total 44\ndrwxr-xr-x 2 u u 4096 Oct 3 09:25 .\ndrwxrwxr-x 501 u u 4096 Oct 3 09:25 ..", + }, + ], + }, + { role: "assistant", thinking: UAT_THINKING, content: UAT_ANSWER, turnId: "t9", msgId: "M3" }, + ]; + + // Built per test rather than at collection time: `messagesToChatLines` is + // bound in the `before` hook, which has not run when the describe body is + // evaluated. + const uatRead = () => messagesToChatLines(UAT_MESSAGES).lines; + + // What the streaming writers had put in `cs.chat` before the poll: 16 lines, + // the mirror. This is the array that grew to 81 once the merge kept it and + // appended the 67-line engine spine behind it. + const UAT_MIRROR = [ + "› do the three things", + "● I'll start by checking the workspace directory.", + "##tc:call_function_1", + "→ bash", + " [completed]", + " total 44", + " drwxr-xr-x 2 u u 4096 Oct 3 09:25 .", + " drwxrwxr-x 501 u u 4096 Oct 3 09:25 ..", + " ", + " [in_progress]", + " [completed]", + " [completed]", + `▲ ${UAT_THINKING.replace(/\n+/g, " ").trim()}`, + `● ${UAT_ANSWER.replace(/\n+/g, " ").trim()}`, + "§§ processed_duration=11071ms", + "§§ turn_msg=M3", + ]; + + test("the engine read is 67 lines and the mirror is 16", () => { + // The shape is the point of the fixture; if the engine mapper changes + // these numbers, this test says so instead of silently re-baselining. + assert.equal(uatRead().length, 67); + assert.equal(UAT_MIRROR.length, 16); + }); + + test("the merged transcript renders the answer once, not twice", () => { + const UAT_READ = uatRead(); + const merged = mergeEngineTranscript(UAT_READ, UAT_MIRROR); + + // The UAT figure the user saw: 81 lines, 14 of them a duplicate. The + // merge now lands on the engine's 67 plus the three lines only this + // webui authors. + assert.equal(merged.length, 67 + 3); + + // Nothing the engine holds is dropped … + for (const line of UAT_READ) { + assert.ok(merged.includes(line), "engine line dropped: " + JSON.stringify(line)); + } + + // … and the two folded mirrors are gone, so the answer and the thinking + // chain each render once — this is the assertion that fails on the + // pre-fix code, where all three are present twice. + assert.ok( + !merged.includes(`● ${UAT_ANSWER.replace(/\n+/g, " ").trim()}`), + "the folded answer mirror survived", + ); + assert.ok( + !merged.includes(`▲ ${UAT_THINKING.replace(/\n+/g, " ").trim()}`), + "the folded thinking mirror survived", + ); + assert.equal(merged.filter((l) => l === "→ bash").length, 0, "the arg-less tool header survived"); + assert.equal(merged.filter((l) => l.startsWith('→ bash {')).length, 1); + + // The lines that survive without an engine counterpart are exactly the + // ones this webui authors and the engine never stores — the tool-call + // correlation marker and the per-turn duration. + const webuiOnly = merged.filter((l) => !UAT_READ.includes(l)); + assert.deepEqual(webuiOnly, ["##tc:call_function_1", "§§ processed_duration=11071ms"]); + }); + + test("a second poll over the already-merged transcript is a no-op", () => { + // The poller runs every 4s. Once the mirrors are gone there is nothing + // left to retire, so the result must be stable — otherwise every tick + // would re-push a full-state frame forever. + const once = mergeEngineTranscript(uatRead(), UAT_MIRROR); + assert.deepEqual(mergeEngineTranscript(uatRead(), once), once); + }); +}); + +// ============================================================ +// 4. The poller is wired to the merge // ============================================================ describe("syncTranscriptsOnce — the /api/cmd echo survives a poll tick", () => { diff --git a/packages/webui/test/routes/chat-failed-send.check.mjs b/packages/webui/test/routes/chat-failed-send.check.mjs index 7747f3a26..6e8a8c5d1 100644 --- a/packages/webui/test/routes/chat-failed-send.check.mjs +++ b/packages/webui/test/routes/chat-failed-send.check.mjs @@ -24,6 +24,15 @@ // registered twice for the same specifier, and chat.js binds // runMcodeAcp at its first dynamic import. +// M3-B8: this file is an ACP-TRANSPORT test — it installs a scripted +// fake of `../acp.mjs` and drives `session/new` + `session/prompt`, so +// it must not follow the suite's ambient transport now that #12 has a +// runtime sibling. The pin is a module-scope side effect and MUST stay +// the first import: `lib/config.js` freezes the transport into an +// `export const` at evaluation time, so anything later is too late. +// See test/helpers/pin-transport.mjs for the full argument. +import "../helpers/pin-transport.mjs"; + import { test, describe, diff --git a/packages/webui/test/routes/chat-first-turn-session-guard.check.mjs b/packages/webui/test/routes/chat-first-turn-session-guard.check.mjs index 581e2ed94..49093c304 100644 --- a/packages/webui/test/routes/chat-first-turn-session-guard.check.mjs +++ b/packages/webui/test/routes/chat-first-turn-session-guard.check.mjs @@ -28,6 +28,15 @@ // 3. Regression: cross-cid parallel turns on DIFFERENT sessions are // not blocked and each turn gets its own engine session. +// M3-B8: this file is an ACP-TRANSPORT test — it installs a scripted +// fake of `../acp.mjs` and drives `session/new` + `session/prompt`, so +// it must not follow the suite's ambient transport now that #12 has a +// runtime sibling. The pin is a module-scope side effect and MUST stay +// the first import: `lib/config.js` freezes the transport into an +// `export const` at evaluation time, so anything later is too late. +// See test/helpers/pin-transport.mjs for the full argument. +import "../helpers/pin-transport.mjs"; + import { test, describe, before, beforeEach, afterEach, after } from "node:test"; import assert from "node:assert/strict"; import { Readable } from "node:stream"; diff --git a/packages/webui/test/routes/chat-run-mirror.check.mjs b/packages/webui/test/routes/chat-run-mirror.check.mjs index a6468f8c7..6f531c73d 100644 --- a/packages/webui/test/routes/chat-run-mirror.check.mjs +++ b/packages/webui/test/routes/chat-run-mirror.check.mjs @@ -39,6 +39,15 @@ // record (captured owning webui id) is promoted and receives the // turn — never the record the user switched to. +// M3-B8: this file is an ACP-TRANSPORT test — it installs a scripted +// fake of `../acp.mjs` and drives `session/new` + `session/prompt`, so +// it must not follow the suite's ambient transport now that #12 has a +// runtime sibling. The pin is a module-scope side effect and MUST stay +// the first import: `lib/config.js` freezes the transport into an +// `export const` at evaluation time, so anything later is too late. +// See test/helpers/pin-transport.mjs for the full argument. +import "../helpers/pin-transport.mjs"; + import { test, describe, before, beforeEach, afterEach, after } from "node:test"; import assert from "node:assert/strict"; import { Readable } from "node:stream"; diff --git a/packages/webui/test/routes/send-runtime-e2e.test.js b/packages/webui/test/routes/send-runtime-e2e.test.js new file mode 100644 index 000000000..d24c88b4c --- /dev/null +++ b/packages/webui/test/routes/send-runtime-e2e.test.js @@ -0,0 +1,244 @@ +// webui/test/routes/send-runtime-e2e.test.js +// +// M3-B8b: the ONE test that drives the whole runtime send chain — +// `routes/chat.js#handleSend` → the REAL `runMcodeRuntime` → the state +// bus → the route's finalize drain → the draft promotion. +// +// WHY THIS IS A SEPARATE FILE. Every other B8b case mocks +// `lib/mcode-acp.js` and drives the runner directly. This one needs +// the real runner, which means its real static imports resolve BEFORE +// the test's mocks exist — mock trap #2, in its purest form. Getting +// that boot order right is incompatible with `setupMocks`, which +// always claims `lib/acp-client.js`, so this file registers its own +// namespaces (mocks first, real runner, then the route). +// +// It also has to be its own FILE. node's runner reuses a process +// across files, and in a shared process this case's module-state +// ordering interacts with the facade suite's and the turn never +// settles: 147 tests pass, the loop drains, and node then aborts the +// WHOLE FILE with "Promise resolution is still pending but the event +// loop has already resolved". A one-file scope is the fix that does +// not depend on load order. +// +// The fixtures the case needs are spelled out here rather than +// imported, so a reader never has to go looking for which shared +// helper happened to be in scope. + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { Readable } from "node:stream"; +import { fileURLToPath } from "node:url"; + +import { absPath } from "../helpers/_setup.js"; + +const RUNTIME = "runtime"; +const SID = "mvs_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + +/** Fresh-cache counter for the route re-import. See the file header. */ +let bust = 0; + +/** A JSON request body the real `lib/read-json.js` can consume. */ +function jsonReq(body) { + return Readable.from([Buffer.from(JSON.stringify(body), "utf8")]); +} + +/** A minimal `ServerResponse` stand-in that records what was written. */ +function mkRes() { + const written = []; + return { + written, + writeHead(status, headers) { + written.push({ status, headers }); + return this; + }, + end(body) { + written.push({ body }); + return this; + }, + }; +} + +function lastResponse(res) { + const head = res.written[res.written.length - 2]; + const tail = res.written[res.written.length - 1]; + assert.ok(head && head.status !== undefined, "the handler never wrote a head"); + return { status: head.status, headers: head.headers, body: tail ? tail.body : undefined }; +} + +function mkCs(overrides = {}) { + return { + sessionId: "webui-1", + mcodeSessionId: null, + chat: [], + context: { thinkingStatus: "Idle", tps: 0 }, + usage: { sessionInput: 0, sessionOutput: 0, sessionTotal: 0 }, + model: { name: "minimax_api/MiniMax-M3" }, + workspace: { dir: "/tmp/b8-e2e" }, + running: { active: false, prompt: null, pid: null, sessionId: null, tps: 0 }, + ...overrides, + }; +} + +/** + * A fake runtime event stream. Yields the given events in order and + * then completes. + */ +function mkStream(events) { + return { + async *[Symbol.asyncIterator]() { + for (const e of events) yield e; + }, + close() {}, + }; +} + +const EV = { + deltaText: (text) => ({ type: "delta", messageId: "m1", role: "assistant", content: text }), + settled: (message) => ({ type: "message", message: { id: "m1", role: "assistant", ...message } }), + done: { type: "done", turnId: "t1" }, +}; + +test("RED LINE 3, END TO END: route → real runner → drain → promotion", async (t) => { + // The promotion is the ROUTE's and the drain is the ROUTE's, but + // their INPUT comes from the runner — so the only honest way to + // prove the red line on the runtime transport is to drive the whole + // chain with the REAL `runMcodeRuntime`. A test that mocked the + // runner would be asserting that a mock's return value reaches a + // mock's promotion: true of a fake, worth nothing. + // + // ORDER IS LOAD-BEARING, twice over. The `openEngineSendStream` mock must be + // registered BEFORE `lib/mcode-acp.js` is imported, because + // `mock.module` does not reach a consumer that is already in the + // registry (trap #2) — importing the runner first would bind it to + // the real function, which would then try to boot a real host and + // fail the turn. The facade's pure exports are read from a plain + // import first so the mock namespace is whole (trap #1). + const facade = await import(absPath("engine/streaming-send.js")); + t.mock.module(absPath("engine/streaming-send.js"), { + namedExports: { + ...facade, + openEngineSendStream: async () => ({ + ok: true, + sessionId: SID, + stream: mkStream([ + EV.deltaText("the answer"), + EV.settled({ content: "the answer", finishReason: "stop", id: "msg-1" }), + EV.done, + ]), + turnHost: { close() {} }, + }), + }, + }); + // BOOT ORDER IS THE WHOLE PROBLEM, and it is mock trap #2 in its + // purest form. This case needs the REAL runner, so the real + // `lib/mcode-acp.js` has to be imported — and if that happens + // before `setupMocks` registers the `lib/acp-client.js` mock, the + // real module keeps the REAL binding, and its finalize's + // `getMcodeSessionTitle` spawns a real `mcode acp` subprocess that + // outlives the test and hangs the whole file. It did: one leaked + // child turned 90 passing cases into a 280 s timeout. + // + // So this case does NOT use `setupMocks`. It registers the same + // namespaces itself, in the one order that works: the mocks first, + // then the real runner, then the route. `mock.module` re-registration + // is ERR_INVALID_STATE, so there is no way to have both — which is + // exactly why the helper exists rather than a flag on setupMocks. + const acpNs = await import(absPath("lib/acp-client.js")); + const sessionsNs = await import(absPath("lib/sessions.js")); + t.mock.module(absPath("lib/acp-client.js"), { + namedExports: { + // The whole namespace FIRST, filled with throwers so an + // unexpected call is loud — and then the real stubs override + // them. The order is load-bearing, and getting it backwards was + // the bug that made this case fail on its first run: with the + // spread last, every thrower wins and every genuine call + // reports "not stubbed". + ...Object.fromEntries( + Object.keys(acpNs).map((k) => [k, () => { + throw new Error(`B8 test reached acp-client.js#${k}, which it did not stub`); + }]), + ), + getMcodeSessionTitle: async () => null, + invalidateMcodeSessionsCache: () => {}, + getMcodeSessionsForWorkspace: async () => [], + getMcodeSessionsCacheSync: () => null, + getCachedMcodeCommands: () => [], + getCatalogueHost: async () => null, + listAllMcodeSessions: async () => [], + getMcodeAcpClient: async () => null, + getMcodeServerInfo: () => null, + ensureMcodeCommands: async () => ({ mcode: [], webui: [], fetchedAt: 0, source: "test" }), + getMcodeSessionsStaleSync: () => null, + deleteMcodeSessionFromDb: () => ({ ok: true }), + shutdownMcodeAcpSingleton: () => {}, + dropMcodeSessionFromCache: () => {}, + }, + }); + t.mock.module(absPath("lib/sessions.js"), { + namedExports: Object.fromEntries( + Object.keys(sessionsNs).map((k) => [k, (...a) => sessionsNs[k](...a)]), + ), + }); + t.mock.module(absPath("lib/mavis-usage.js"), { + namedExports: { + getMavisTokenUsage: async () => null, + getMavisTokenUsageModel: async () => null, + applyMavisUsageToCs: async () => ({ applied: false }), + }, + }); + t.mock.module(absPath("lib/mcode-rpc.js"), { + namedExports: { + cancelSession: async () => ({ ok: true, data: {} }), + mcodePermissionToWebui: () => "Full access", + webuiPermissionToMcode: () => "bypassPermissions", + }, + }); + t.mock.module(absPath("lib/models.js"), { + namedExports: { getMcodeModelLimit: async () => ({ context: 512000 }) }, + }); + t.mock.module(absPath("lib/mcode-exec.js"), { + namedExports: { runMcodeExec: async () => ({ status: "succeeded", answer: "x" }), collectExecResult: async (p) => p }, + }); + t.mock.module(absPath("lib/slash.js"), { + namedExports: { + handleLocalSlash: async () => ({ handled: false, continueMcode: false }), + handleCmdCommand: async () => ({ handled: true, continueMcode: false }), + }, + }); + const realAcp = await import(absPath("lib/mcode-acp.js")); + const config = await import(absPath("lib/config.js")); + t.mock.module(absPath("lib/config.js"), { + namedExports: { ...config, MCODE_WEBUI_TRANSPORT: RUNTIME }, + }); + const route = await import(`${absPath("routes/chat.js")}?bust=${bust++}`); + const cs = mkCs({ sessionId: null }); + const res = mkRes(); + await route.handleSend(jsonReq({ content: "hello" }), res, { cs, cid: "b8-e2e" }); + assert.equal(lastResponse(res).status, 200); + + // 1. The runner bound the engine sid onto the turn's draft. + assert.equal(cs.mcodeSessionId, SID); + // 2. ONE identity, not a uuid orphan beside an engine entry — the + // double-sidebar the qa note records. Asserted on `cs` rather + // than on a store dump: this file deliberately uses the REAL + // session store (it needs the real runner, and mocking the store + // would mean a second mock namespace for the same case), so the + // shared in-memory `getSessionsStore()` helper is empty here and + // reading it would assert nothing. `cs.sessionId === cs.mcodeSessionId` + // is the post-promotion state the single-identity rule produces, + // and it is the same fact the store would show. + assert.equal( + cs.sessionId, + SID, + "the draft record was not promoted to the engine sid — two records would exist for one conversation", + ); + // 3. The drained lines reached the live view with the `●` rewrite + // applied, and the user message the route persisted up front. + assert.ok(cs.chat.some((l) => l === "› hello"), JSON.stringify(cs.chat)); + assert.ok(cs.chat.some((l) => l === "● the answer"), JSON.stringify(cs.chat)); + assert.equal( + cs.context.assistantLast, + "the answer", + "the route's success tail reads the same accumulator on both transports", + ); +}); diff --git a/packages/webui/test/routes/sessions.check.mjs b/packages/webui/test/routes/sessions.check.mjs index 8281a7ca2..17867061d 100644 --- a/packages/webui/test/routes/sessions.check.mjs +++ b/packages/webui/test/routes/sessions.check.mjs @@ -714,6 +714,149 @@ describe("handleDeleteSession — v1.0 anti-resurrection", () => { assert.equal(cs3.sessionId, "webui-B", "unrelated client untouched"); assert.deepEqual(cs3.chat, ["keep"]); }); + + // ------------------------------------------------------------------- + // The two halves of "delete", and the wait that used to sit in front + // of both of them. + // + // A webui-only record (no `mcodeSessionId` — it never reached the + // engine) and an engine-linked one take deliberately different paths: + // the first is webui's own store splice, the second also kills the ACP + // child, drops the sid from the push cache and deletes the engine's own + // `local_runtime_*` rows. Neither half ever waits on the OTHER one. + // + // What used to sit in front of both was `authorize()`, which holds the + // caller's request open for the whole human round-trip. With no client + // connected to receive the modal that wait had no possible end other + // than the 5-minute fail-closed timeout — a destructive request that + // looks exactly like a hang. These cases pin both halves: the + // unanswerable one now ends at once with the unchanged decline body, + // and the answerable one still waits for a real human decision. + // ------------------------------------------------------------------- + test("webui-only 会话 DELETE 在无人可裁决时立即返回正确响应,且不触引擎", async () => { + const calls = trackAntiResurrection(); + registerSessionsStore({ + initial: [ + ...initialSessions, + { + id: "webui-only", + title: "never reached the engine", + workspace: WS_A, + createdAt: 9, + updatedAt: 9, + chat: [], + }, + ], + }); + const cs = makeClientState(); + cs.workspace = { dir: WS_A, branch: null, tree: null }; + // No SSE response is registered for this cid — nothing can answer. + const res = fakeRes(); + const ctx = { cs, cid: "cid-offline", pathname: "/api/sessions/webui-only" }; + const startedAt = Date.now(); + await handleDeleteSession(fakeReq({}), res, ctx); + const elapsed = Date.now() - startedAt; + + assert.ok(elapsed < 2000, `挂起复现:不可达的裁决通道等了 ${elapsed}ms(预算上限 300000ms)`); + assert.equal(res._status, 403); + const body = JSON.parse(res._body); + assert.deepEqual(Object.keys(body), [ + "ok", + "error", + "decidedBy", + "decidedAt", + ], "拒绝体逐键不变 —— 既有响应形状不能因为修挂起而变"); + assert.equal(body.ok, false); + assert.equal(body.error, "authorize declined"); + assert.equal(body.decidedBy, "timeout", "与超时同解:失败即关闭,绝不放行破坏性操作"); + assert.equal(typeof body.decidedAt, "number"); + + // 门禁仍然有效:没人裁决就没有删除。 + assert.ok( + getSessionsStore().some((s) => s.id === "webui-only"), + "未获裁决不得删除任何一行", + ); + // webui-only 半边:引擎侧完全没有记录,也就完全没有触碰。 + assert.equal(calls.shutdown, 0, "webui-only 会话不得牵动 ACP 子进程"); + assert.deepEqual(calls.drop, [], "webui-only 会话不得动引擎会话缓存"); + }); + + test("webui-only 会话 DELETE 在标签页在线时走 webui-only 支路,200 且不触引擎(反向半边的前置)", async () => { + const calls = trackAntiResurrection(); + registerSessionsStore({ + initial: [ + ...initialSessions, + { + id: "webui-only-2", + title: "still webui-only", + workspace: WS_A, + createdAt: 9, + updatedAt: 9, + chat: [], + }, + ], + }); + const cs = makeClientState(); + cs.workspace = { dir: WS_A, branch: null, tree: null }; + const res = fakeRes(); + clients.set("cid-live", cs); + const ctx = { cs, cid: "cid-live", pathname: "/api/sessions/webui-only-2" }; + await withDecisions(() => handleDeleteSession(fakeReq({}), res, ctx)); + + assert.equal(res._status, 200); + const body = JSON.parse(res._body); + assert.equal(body.ok, true); + assert.equal(body.deleted, "webui-only-2"); + assert.equal(body.matchKind, "webuiId"); + assert.equal(body.mcodeDbDel, null, "无引擎 sid ⇒ 没有任何引擎行需要删"); + assert.equal( + getSessionsStore().some((s) => s.id === "webui-only-2"), + false, + "webui 侧的会话条目确实被摘掉", + ); + assert.equal(calls.shutdown, 0, "webui-only 支路不牵动 ACP 子进程"); + assert.deepEqual(calls.drop, []); + }); + + test("反向半边:带 mcodeSessionId 的会话 DELETE 会触引擎(kill + 缓存 + 引擎行)", async () => { + // The same request against a record that DOES have an engine sid. + // Paired with the two cases above on purpose: the asymmetry is the + // contract. A "fix" that made both halves wait on the engine, or + // both skip it, would pass either test alone. + const calls = trackAntiResurrection(); + registerSessionsStore({ + initial: [ + ...initialSessions, + { + id: "webui-linked", + mcodeSessionId: MVS_SID, + title: "engine-linked", + workspace: WS_A, + createdAt: 9, + updatedAt: 9, + chat: [], + }, + ], + }); + const cs = makeClientState(); + cs.workspace = { dir: WS_A, branch: null, tree: null }; + const res = fakeRes(); + clients.set("cid-live", cs); + const ctx = { cs, cid: "cid-live", pathname: "/api/sessions/" + MVS_SID }; + await withDecisions(() => handleDeleteSession(fakeReq({}), res, ctx)); + + assert.equal(res._status, 200); + const body = JSON.parse(res._body); + assert.equal(body.ok, true); + assert.equal(body.matchKind, "mcodeSessionId"); + assert.equal(calls.shutdown, 1, "引擎关联会话必须先杀掉会写回注册表的子进程"); + assert.deepEqual(calls.drop, [MVS_SID]); + assert.equal( + getSessionsStore().some((s) => s.id === "webui-linked"), + false, + "webui 包装条目同样被摘掉", + ); + }); }); // ============================================================ diff --git a/release/public-source.json b/release/public-source.json index f83cd2969..83ffe902d 100644 --- a/release/public-source.json +++ b/release/public-source.json @@ -3463,6 +3463,7 @@ "packages/webui/server/engine/session-switch.js", "packages/webui/server/engine/session-tree-reads.js", "packages/webui/server/engine/session-writes.js", + "packages/webui/server/engine/streaming-send.js", "packages/webui/server/engine/usage-reads.js", "packages/webui/server/lib/acp-client.js", "packages/webui/server/lib/agent-team-detect.js", @@ -3571,6 +3572,7 @@ "packages/webui/test/helpers/_setup.js", "packages/webui/test/helpers/free-port.js", "packages/webui/test/helpers/mavis-sources.mjs", + "packages/webui/test/helpers/pin-transport.mjs", "packages/webui/test/helpers/tmp.js", "packages/webui/test/helpers/turn-drain.mjs", "packages/webui/test/integration/chat-wiring.test.js", @@ -3612,6 +3614,7 @@ "packages/webui/test/lib/engine/session-switch.test.js", "packages/webui/test/lib/engine/session-tree-reads.test.js", "packages/webui/test/lib/engine/session-writes.test.js", + "packages/webui/test/lib/engine/streaming-send.test.js", "packages/webui/test/lib/engine/usage-reads.test.js", "packages/webui/test/lib/events-concurrency.test.js", "packages/webui/test/lib/events-hash.test.js", @@ -3691,6 +3694,7 @@ "packages/webui/test/routes/protocol.check.mjs", "packages/webui/test/routes/provider-presets.check.mjs", "packages/webui/test/routes/providers.check.mjs", + "packages/webui/test/routes/send-runtime-e2e.test.js", "packages/webui/test/routes/session-reads.check.mjs", "packages/webui/test/routes/sessions-search.check.mjs", "packages/webui/test/routes/sessions-switch-workspace-follow.check.mjs",