返回 CodeWhale
runtime_web_client.test.mjs
根目录 / crates / tui / tests / runtime_web_client.test.mjs
1 import test from "node:test";
2 import assert from "node:assert/strict";
3 import { readFile } from "node:fs/promises";
4
5 import {
6 NO_TARGET,
7 STREAM_EVENT_NAMES,
8 answersForUserInput,
9 applyRuntimeEvent,
10 applySnapshot,
11 buildCreateThreadRequest,
12 claimInFlight,
13 createThreadState,
14 eventStreamUrl,
15 formatRuntimeProvenance,
16 imageInputPresentation,
17 isComposerSubmitKey,
18 groupThreadSummaries,
19 modeLabel,
20 modelOptionLabel,
21 newThreadDefaults,
22 pendingAttentionCount,
23 pendingAttentionLabel,
24 providerOptionLabel,
25 receiptPresentation,
26 recoverSnapshotAndSubscribe,
27 renderRuntimeProvenance,
28 resolveUserInputTarget,
29 restoreDraft,
30 runtimeEventContinuity,
31 saveDraft,
32 sessionTarget,
33 setSafeText,
34 snapshotThenSubscribe,
35 threadTarget,
36 threadProviderLabel,
37 } from "../src/runtime_web/app.mjs";
38
39 function snapshot(threadId = "thread-a", latestSeq = 7) {
40 return {
41 thread: { id: threadId, title: "Test", model: "test", mode: "agent" },
42 turns: [{ id: "turn-1", status: "in_progress" }],
43 items: [
44 {
45 id: "item-1",
46 turn_id: "turn-1",
47 kind: "agent_message",
48 status: "in_progress",
49 summary: "",
50 detail: "Hello",
51 },
52 ],
53 latest_seq: latestSeq,
54 };
55 }
56
57 function runtimeEvent(sequence, event, payload = {}, overrides = {}) {
58 return {
59 schema_version: 1,
60 seq: sequence,
61 event,
62 kind: event,
63 thread_id: "thread-a",
64 turn_id: "turn-1",
65 item_id: null,
66 payload,
67 ...overrides,
68 };
69 }
70
71 function cssDeclarations(styles, selectorPattern) {
72 const match = styles.match(new RegExp(`${selectorPattern}\\s*\\{([^}]*)\\}`));
73 assert.ok(match, `missing CSS rule matching ${selectorPattern}`);
74 return match[1];
75 }
76
77 test("embedded web client uses the Ocean Blue Stage semantic palette", async () => {
78 const [styles, html] = await Promise.all([
79 readFile(new URL("../src/runtime_web/styles.css", import.meta.url), "utf8"),
80 readFile(new URL("../src/runtime_web/index.html", import.meta.url), "utf8"),
81 ]);
82
83 for (const token of [
84 "--bg: #020711",
85 "--sidebar: #050b16",
86 "--surface: #0e1a30",
87 "--surface-raised: #172945",
88 "--stage-surface: #142747",
89 "--text: #f6f2e8",
90 "--action: #6aaef2",
91 "--status-human: #f6c453",
92 "--status-live: #4fd1c5",
93 "--status-warning: #ff7a59",
94 "--status-danger: #ff86b2",
95 "--ok: #9bd66f",
96 "--radius-control: 6px",
97 "--radius-card: 12px",
98 "--radius-composer: 16px",
99 "--rail: 256px",
100 ]) {
101 assert.match(styles, new RegExp(token.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
102 }
103 assert.match(
104 cssDeclarations(styles, "\\.primary-button,\\s*\\.send-button"),
105 /background: var\(--action\)/,
106 );
107 assert.match(
108 cssDeclarations(styles, "\\.status-pip\\.running"),
109 /background: var\(--live\)/,
110 );
111 assert.match(
112 cssDeclarations(styles, "\\.message\\.user \\.message-body"),
113 /background: var\(--plate\)/,
114 );
115 assert.match(
116 cssDeclarations(styles, "\\.attention-card"),
117 /border: 1px solid rgba\(246, 196, 83/,
118 );
119 assert.match(
120 cssDeclarations(styles, "\\.status-banner"),
121 /color: var\(--warning\)/,
122 );
123 assert.match(
124 cssDeclarations(styles, "\\.connection-dot\\.ready"),
125 /background: var\(--ok\)/,
126 );
127 assert.match(html, /name="theme-color" content="#020711"/);
128 });
129
130 test("embedded web client keeps the CWC stage, transcript, and receipt hierarchy quiet", async () => {
131 const [styles, html, source] = await Promise.all([
132 readFile(new URL("../src/runtime_web/styles.css", import.meta.url), "utf8"),
133 readFile(new URL("../src/runtime_web/index.html", import.meta.url), "utf8"),
134 readFile(new URL("../src/runtime_web/app.mjs", import.meta.url), "utf8"),
135 ]);
136
137 assert.match(cssDeclarations(styles, "\\.session"), /background: var\(--stage-surface\)/);
138 assert.match(cssDeclarations(styles, "\\.transcript"), /background: var\(--stage-surface\)/);
139 assert.match(cssDeclarations(styles, "\\.receipt"), /display:\s*flex/);
140 assert.match(cssDeclarations(styles, "\\.receipt-dot"), /background: var\(--live\)/);
141 assert.match(
142 cssDeclarations(styles, "\\.message\\.user \\.message-label"),
143 /display:\s*none/,
144 );
145 assert.match(html, /id="transcript" role="log"[^>]+aria-relevant="additions"/);
146 assert.doesNotMatch(html, /id="transcript" role="log"[^>]+aria-relevant="[^"]*text/);
147 assert.match(source, /card\.append\(element\("span", "receipt-dot"\)\)/);
148 });
149
150 test("thread rail groups typed pending requests without disturbing server order", async () => {
151 const summaries = [
152 { id: "newest-recent", pending_attention_count: 0 },
153 { id: "newest-needs-you", pending_attention_count: 2 },
154 { id: "older-needs-you", pending_attention_count: 1 },
155 { id: "older-recent", pending_attention_count: 0 },
156 ];
157 const groups = groupThreadSummaries(summaries);
158
159 assert.deepEqual(groups.needsYou.map(({ id }) => id), ["newest-needs-you", "older-needs-you"]);
160 assert.deepEqual(groups.recent.map(({ id }) => id), ["newest-recent", "older-recent"]);
161 assert.deepEqual(summaries.map(({ id }) => id), [
162 "newest-recent",
163 "newest-needs-you",
164 "older-needs-you",
165 "older-recent",
166 ]);
167 assert.equal(pendingAttentionCount({ pending_attention_count: -1 }), 0);
168 assert.equal(pendingAttentionLabel({ pending_attention_count: 1 }), "1 item needs your attention");
169 assert.equal(pendingAttentionLabel({ pending_attention_count: 2 }), "2 items need your attention");
170
171 const [html, source] = await Promise.all([
172 readFile(new URL("../src/runtime_web/index.html", import.meta.url), "utf8"),
173 readFile(new URL("../src/runtime_web/app.mjs", import.meta.url), "utf8"),
174 ]);
175 assert.match(html, /id="thread-list" aria-label="Live threads"/);
176 assert.match(source, /group\.setAttribute\("aria-labelledby", headingId\)/);
177 assert.match(source, /attention\.setAttribute\("aria-label", pendingAttentionLabel\(summary\)\)/);
178 });
179
180 test("mobile drawer owns focus and background interaction while it is open", async () => {
181 const [html, source] = await Promise.all([
182 readFile(new URL("../src/runtime_web/index.html", import.meta.url), "utf8"),
183 readFile(new URL("../src/runtime_web/app.mjs", import.meta.url), "utf8"),
184 ]);
185
186 assert.match(html, /id="rail-open"[^>]+aria-controls="thread-rail"[^>]+aria-expanded="false"/);
187 assert.match(html, /id="rail-scrim"[^>]+tabindex="-1" hidden/);
188 assert.match(source, /function openRail\(\)[\s\S]*dom\.railClose\.focus/);
189 assert.match(source, /dom\.session\.setAttribute\("aria-hidden", "true"\)[\s\S]*setInert\(dom\.session, true\)/);
190 assert.match(source, /function closeRail[\s\S]*returnTarget\.focus[\s\S]*applyClosedMobileRailAccessibility/);
191 assert.match(source, /function trapFocusWithin[\s\S]*event\.key !== "Tab"[\s\S]*first\.focus/);
192 assert.match(source, /function trapRailFocus[\s\S]*trapFocusWithin\(event, dom\.rail\)/);
193 assert.match(source, /document\.addEventListener\("keydown", \(event\) => \{\s+if \(dom\.newThreadDialog\.open\) return;\s+if \(trapRailFocus\(event\)\) return;/);
194 assert.match(source, /event\.key === "Escape"[\s\S]*closeRail\(\)/);
195 });
196
197 test("mobile viewport and truth controls survive the software keyboard and coarse input", async () => {
198 const [styles, source] = await Promise.all([
199 readFile(new URL("../src/runtime_web/styles.css", import.meta.url), "utf8"),
200 readFile(new URL("../src/runtime_web/app.mjs", import.meta.url), "utf8"),
201 ]);
202
203 assert.match(styles, /height: var\(--visual-viewport-height\)/);
204 assert.match(source, /globalThis\.visualViewport\?\.addEventListener\("resize", syncVisualViewport\)/);
205 assert.match(styles, /@media \(pointer: coarse\)[\s\S]*min-height: 44px/);
206 assert.match(
207 styles,
208 /@media \(max-width: 800px\)[\s\S]*\.composer textarea,[\s\S]*font-size: 16px/,
209 );
210 assert.match(styles, /@media \(max-width: 430px\)[\s\S]*\.session-facts \{[\s\S]*display: flex/);
211 assert.match(styles, /\.session-facts \.fact-chip\[data-fact="workspace"\][\s\S]*display: none/);
212 assert.match(source, /chip\.dataset\.fact = String\(label \|\| ""\)\.toLowerCase\(\)/);
213 });
214
215 test("stream reconciliation preserves live controls, disclosures, and selected transcript text", async () => {
216 const [html, source] = await Promise.all([
217 readFile(new URL("../src/runtime_web/index.html", import.meta.url), "utf8"),
218 readFile(new URL("../src/runtime_web/app.mjs", import.meta.url), "utf8"),
219 ]);
220
221 assert.match(html, /id="attention" role="region"[^>]+aria-live="assertive"[^>]+aria-relevant="additions"/);
222 assert.equal(source.includes("dom.transcript.replaceChildren"), false);
223 assert.equal(source.includes("dom.attention.replaceChildren"), false);
224 assert.match(source, /function reconcileChildren\(/);
225 assert.match(source, /captureTranscriptSelection\(\)[\s\S]*restoreTranscriptSelection\(selection\)/);
226 assert.match(source, /card\.dataset\.attentionKey = key/);
227 assert.equal(source.includes("focusPendingAttention"), false);
228 assert.doesNotMatch(source, /card\.tabIndex = -1/);
229 });
230
231 test("attention requests stay single-flight without stealing the active control", async () => {
232 const requests = new Set();
233 assert.equal(claimInFlight(requests, "approval:one"), true);
234 assert.equal(claimInFlight(requests, "approval:one"), false);
235 requests.delete("approval:one");
236 assert.equal(claimInFlight(requests, "approval:one"), true);
237
238 const source = await readFile(
239 new URL("../src/runtime_web/app.mjs", import.meta.url),
240 "utf8",
241 );
242 assert.match(source, /if \(!claimInFlight\(app\.inFlightActions, action\)\) return;/);
243 assert.match(source, /setAttentionCardBusy\(action, true\)/);
244 assert.match(source, /finally \{\s+app\.inFlightActions\.delete\(action\);\s+setAttentionCardBusy\(action, false\);/);
245 });
246
247 test("degraded workflow receipts surface rejected dispatches as attention", () => {
248 const detail = JSON.stringify({
249 status: "degraded",
250 dispatch_failure_count: 2,
251 dispatch_failures: [{ label: "review", message: "profile unavailable" }],
252 });
253 assert.deepEqual(receiptPresentation({
254 kind: "tool_call",
255 status: "completed",
256 summary: "workflow: degraded",
257 detail,
258 metadata: { status: "degraded", dispatch_failure_count: 2 },
259 }), {
260 label: "Workflow · Needs attention",
261 summary: "2 task dispatches were rejected",
262 raw: `workflow: degraded\n\n${detail}`,
263 failed: true,
264 });
265 });
266
267 test("rail New thread cannot paint over the session fact chips", async () => {
268 const styles = await readFile(
269 new URL("../src/runtime_web/styles.css", import.meta.url),
270 "utf8",
271 );
272 assert.match(cssDeclarations(styles, "\\.rail"), /overflow:\s*hidden/);
273 assert.match(cssDeclarations(styles, "\\.new-thread"), /max-width:\s*100%/);
274 assert.match(cssDeclarations(styles, "\\.session-header"), /overflow:\s*hidden/);
275 assert.match(cssDeclarations(styles, "\\.session-facts"), /flex-wrap:\s*nowrap/);
276 });
277
278 test("production shell keeps readable type, controls, focus, and motion contracts", async () => {
279 const [styles, html] = await Promise.all([
280 readFile(new URL("../src/runtime_web/styles.css", import.meta.url), "utf8"),
281 readFile(new URL("../src/runtime_web/index.html", import.meta.url), "utf8"),
282 ]);
283
284 assert.match(cssDeclarations(styles, "\\.thread-row"), /min-height:\s*62px/);
285 assert.match(cssDeclarations(styles, "\\.thread-title"), /font-size:\s*14px/);
286 assert.match(cssDeclarations(styles, "\\.message-body"), /font-size:\s*15\.5px/);
287 assert.match(cssDeclarations(styles, "\\.composer textarea"), /font-size:\s*15\.5px/);
288 assert.match(cssDeclarations(styles, "\\.composer textarea"), /max-height:\s*220px/);
289 assert.match(
290 styles,
291 /\.primary-button,[\s\S]*?\.icon-button\s*\{[\s\S]*?min-height:\s*36px/,
292 );
293 assert.match(
294 styles,
295 /button:focus-visible,[\s\S]*?outline:\s*3px solid var\(--action\)/,
296 );
297 assert.match(
298 styles,
299 /@media \(prefers-reduced-motion: reduce\)[\s\S]*scroll-behavior: auto !important/,
300 );
301 assert.match(html, /Enter send · Shift\+Enter newline/);
302 });
303
304 test("composer Enter sends without interrupting newlines or IME composition", () => {
305 assert.equal(isComposerSubmitKey({ key: "Enter" }), true);
306 assert.equal(isComposerSubmitKey({ key: "Enter", metaKey: true }), true);
307 assert.equal(isComposerSubmitKey({ key: "Enter", ctrlKey: true }), true);
308 assert.equal(isComposerSubmitKey({ key: "Enter", shiftKey: true }), false);
309 assert.equal(isComposerSubmitKey({ key: "Enter", isComposing: true }), false);
310 assert.equal(isComposerSubmitKey({ key: "a" }), false);
311 });
312
313 test("new thread selection keeps provider and model scoped to the create request", () => {
314 const catalog = {
315 current: "deepseek",
316 providers: [
317 { id: "openai", default_model: "gpt-5.6" },
318 { id: "deepseek", default_model: "deepseek-v4-pro" },
319 ],
320 };
321 assert.deepEqual(newThreadDefaults(catalog), {
322 providerId: "deepseek",
323 modelProviderId: "",
324 model: "deepseek-v4-pro",
325 });
326 assert.deepEqual(
327 buildCreateThreadRequest(" deepseek ", " deepseek-v4-flash-vision-exp "),
328 {
329 model_provider: "deepseek",
330 model: "deepseek-v4-flash-vision-exp",
331 },
332 );
333 assert.throws(() => buildCreateThreadRequest("deepseek", ""), /provider and a model/);
334
335 const namedCustom = {
336 current: "custom",
337 providers: [{
338 id: "custom",
339 model_provider_id: "lm-studio",
340 default_model: "local-vision-model",
341 }],
342 };
343 assert.deepEqual(newThreadDefaults(namedCustom), {
344 providerId: "custom",
345 modelProviderId: "lm-studio",
346 model: "local-vision-model",
347 });
348 assert.deepEqual(
349 buildCreateThreadRequest("custom", "local-vision-model", "lm-studio"),
350 {
351 model_provider: "custom",
352 model: "local-vision-model",
353 model_provider_id: "lm-studio",
354 },
355 );
356 assert.equal(
357 providerOptionLabel({
358 id: "custom",
359 display_name: "Custom",
360 model_provider_id: "lm-studio",
361 }),
362 "Custom · lm-studio",
363 );
364 assert.equal(threadProviderLabel({
365 model_provider: "custom",
366 model_provider_id: "lm-studio",
367 }), "lm-studio");
368 assert.equal(threadProviderLabel({ model_provider: "deepseek" }), "deepseek");
369 });
370
371 test("thread facts keep exact named provider identity visible after creation", async () => {
372 const source = await readFile(
373 new URL("../src/runtime_web/app.mjs", import.meta.url),
374 "utf8",
375 );
376 assert.match(source, /const provider = threadProviderLabel\(thread\)/);
377 assert.match(source, /factChip\("Provider", provider\)/);
378 });
379
380 test("composer send guard survives rerenders until the request settles", async () => {
381 const source = await readFile(
382 new URL("../src/runtime_web/app.mjs", import.meta.url),
383 "utf8",
384 );
385 assert.match(source, /const sending = app\.inFlightActions\.has\(composerSendAction\)/);
386 assert.match(source, /dom\.composerInput\.disabled = sending \|\| !ready/);
387 assert.match(source, /dom\.send\.disabled = sending \|\| !ready/);
388 assert.match(source, /if \(!claimInFlight\(app\.inFlightActions, composerSendAction\)\) return;/);
389 assert.match(source, /finally \{\s+app\.inFlightActions\.delete\(composerSendAction\);\s+renderComposer\(\);/);
390 });
391
392 test("new thread dialog labels exact vision capability without exposing attachments", async () => {
393 const [html, source] = await Promise.all([
394 readFile(new URL("../src/runtime_web/index.html", import.meta.url), "utf8"),
395 readFile(new URL("../src/runtime_web/app.mjs", import.meta.url), "utf8"),
396 ]);
397 const vision = { id: "deepseek-v4-flash-vision-exp", image_input: "supported" };
398 assert.equal(modelOptionLabel(vision), "deepseek-v4-flash-vision-exp · Vision");
399 assert.equal(imageInputPresentation("supported").label, "Vision");
400 assert.equal(imageInputPresentation("unsupported").label, "Text only");
401 assert.equal(imageInputPresentation("unknown").state, "unknown");
402
403 assert.match(html, /id="new-thread-dialog"[^>]+tabindex="-1"[^>]+aria-labelledby="new-thread-title"/);
404 assert.match(html, /id="new-thread-cancel"[^>]+autofocus/);
405 assert.match(html, /id="new-thread-provider" required disabled/);
406 assert.match(html, /id="new-thread-model" required disabled/);
407 assert.match(html, /does not change your Runtime defaults/);
408 assert.doesNotMatch(html, /type="file"/);
409 assert.match(source, /api\("\/v1\/providers"\)/);
410 // The dialog loads the catalog through the bounded, paginated collector
411 // keyed by provider.id; the wire endpoint stays /v1/providers/<id>/models.
412 assert.match(source, /collectProviderModelPages\(provider\.id/);
413 assert.match(source, /\/v1\/providers\/\$\{encodeURIComponent\(provider\)\}\/models\?\$\{query\.toString\(\)\}/);
414 assert.match(source, /body: JSON\.stringify\(request\)/);
415 assert.match(source, /function trapFocusWithin\(event, container\)/);
416 assert.match(source, /dom\.newThreadCancel\.focus\(\{ preventScroll: true \}\)/);
417 assert.match(source, /trapFocusWithin\(event, dom\.newThreadDialog\)/);
418 assert.doesNotMatch(source, /\/v1\/providers\/[^`"']+\/switch/);
419 });
420
421 test("uses the v0.9.6 Work vocabulary for the agent wire mode", () => {
422 assert.equal(modeLabel("agent"), "Work");
423 assert.equal(modeLabel("plan"), "Plan");
424 assert.equal(modeLabel("operate"), "Operate");
425 });
426
427 test("formats and renders exact Runtime build provenance with honest fallbacks", () => {
428 const exactCommit = "abcdef0123456789abcdef0123456789abcdef01";
429 const stamped = {
430 codewhale_version: "0.9.6",
431 codewhale_commit: exactCommit,
432 };
433 assert.equal(formatRuntimeProvenance(stamped), "0.9.6 · abcdef012345");
434
435 const rendered = { textContent: "" };
436 renderRuntimeProvenance(rendered, stamped);
437 assert.equal(rendered.textContent, "0.9.6 · abcdef012345");
438
439 assert.equal(
440 formatRuntimeProvenance({ codewhale_version: "0.9.6", codewhale_commit: "unknown" }),
441 "0.9.6 · source unknown",
442 );
443 assert.equal(
444 formatRuntimeProvenance({ version: "0.9.6", codewhale_commit: "too-short" }),
445 "0.9.6 · source unknown",
446 );
447 assert.equal(formatRuntimeProvenance(null), "version unknown · source unknown");
448 });
449
450 test("loads a consistent snapshot before subscribing from latest_seq", async () => {
451 const state = createThreadState("thread-a");
452 const order = [];
453 const subscribed = await snapshotThenSubscribe({
454 state,
455 threadId: "thread-a",
456 loadSnapshot: async () => {
457 order.push("snapshot");
458 return snapshot("thread-a", 42);
459 },
460 subscribe: (threadId, sequence) => order.push(`subscribe:${threadId}:${sequence}`),
461 });
462
463 assert.equal(subscribed, true);
464 assert.deepEqual(order, ["snapshot", "subscribe:thread-a:42"]);
465 assert.equal(state.latestSeq, 42);
466 });
467
468 test("snapshot recovery waits for the replacement stream to open", async () => {
469 const state = createThreadState("thread-a");
470 let finishOpening;
471 let settled = false;
472 const opening = new Promise((resolve) => {
473 finishOpening = resolve;
474 });
475 const recovery = snapshotThenSubscribe({
476 state,
477 threadId: "thread-a",
478 loadSnapshot: async () => snapshot("thread-a", 43),
479 subscribe: () => opening,
480 }).then((result) => {
481 settled = true;
482 return result;
483 });
484
485 await Promise.resolve();
486 assert.equal(settled, false, "snapshot success alone must not finish recovery");
487 finishOpening();
488 assert.equal(await recovery, true);
489 assert.equal(settled, true);
490 });
491
492 test("a failed replacement stream keeps the gap until a later stream opens", async () => {
493 const state = createThreadState("thread-a");
494 let gap = true;
495 let attempts = 0;
496 const recover = () => recoverSnapshotAndSubscribe({
497 state,
498 threadId: "thread-a",
499 loadSnapshot: async () => snapshot("thread-a", 44 + attempts),
500 subscribe: async () => {
501 attempts += 1;
502 if (attempts === 1) throw new Error("replacement stream did not reopen");
503 },
504 }, () => {
505 gap = false;
506 });
507
508 await assert.rejects(recover(), /did not reopen/);
509 assert.equal(gap, true, "snapshot success must not hide a failed stream handshake");
510 assert.equal(await recover(), true);
511 assert.equal(gap, false, "a later snapshot plus open stream clears the gap");
512 });
513
514 test("drops a stale snapshot selection without opening an event stream", async () => {
515 const state = createThreadState("thread-a");
516 let current = true;
517 let subscribed = false;
518 const result = await snapshotThenSubscribe({
519 state,
520 threadId: "thread-a",
521 loadSnapshot: async () => {
522 current = false;
523 return snapshot();
524 },
525 subscribe: () => {
526 subscribed = true;
527 },
528 isCurrent: () => current,
529 });
530 assert.equal(result, false);
531 assert.equal(subscribed, false);
532 });
533
534 test("reconnect cursor advances monotonically and duplicate or stale-thread events are ignored", () => {
535 const state = createThreadState("thread-a");
536 assert.equal(applySnapshot(state, snapshot("thread-a", 7)), true);
537
538 assert.equal(
539 applyRuntimeEvent(
540 state,
541 runtimeEvent(8, "item.delta", { delta: " world", kind: "agent_message" }, { item_id: "item-1" }),
542 ),
543 true,
544 );
545 assert.equal(
546 applyRuntimeEvent(
547 state,
548 runtimeEvent(8, "item.delta", { delta: " duplicate", kind: "agent_message" }, { item_id: "item-1" }),
549 ),
550 false,
551 );
552 assert.equal(
553 applyRuntimeEvent(state, runtimeEvent(99, "turn.completed", {}, { thread_id: "thread-b" })),
554 false,
555 );
556 assert.equal(state.items.get("item-1").detail, "Hello world");
557 assert.equal(state.latestSeq, 8);
558 assert.equal(eventStreamUrl("thread-a", state.latestSeq), "/v1/threads/thread-a/events?since_seq=8");
559 });
560
561 test("uses the stream predecessor cursor to detect real gaps without assuming global sequences are contiguous", () => {
562 const state = createThreadState("thread-a");
563 applySnapshot(state, snapshot("thread-a", 7));
564
565 const interleaved = runtimeEvent(
566 12,
567 "item.delta",
568 { delta: " after other threads", kind: "agent_message" },
569 { item_id: "item-1", previous_seq: 7 },
570 );
571 assert.equal(runtimeEventContinuity(state, interleaved), "next");
572 assert.equal(applyRuntimeEvent(state, interleaved), true);
573 assert.equal(state.latestSeq, 12);
574
575 const gap = runtimeEvent(
576 15,
577 "approval.required",
578 { approval_id: "approval-missed", tool_name: "exec_shell" },
579 { previous_seq: 14 },
580 );
581 assert.equal(runtimeEventContinuity(state, gap), "gap");
582 assert.equal(applyRuntimeEvent(state, gap), false);
583 assert.equal(state.latestSeq, 12);
584 assert.equal(state.approvals.has("approval-missed"), false);
585 });
586
587 test("registers the full emitted Runtime vocabulary and advances continuity for every event", async () => {
588 const runtimeSource = await readFile(
589 new URL("../src/runtime_threads.rs", import.meta.url),
590 "utf8",
591 );
592 const emittedNames = new Set(
593 [...runtimeSource.matchAll(
594 /"((?:thread|turn|item|approval|user_input|sandbox|agent|tool_call)\.[a-z_]+)"/g,
595 )].map((match) => match[1]),
596 );
597 assert.deepEqual(new Set(STREAM_EVENT_NAMES), emittedNames);
598 assert.equal(STREAM_EVENT_NAMES.includes("thread.created"), false);
599
600 const state = createThreadState("thread-a");
601 applySnapshot(state, snapshot("thread-a", 7));
602 let previousSeq = 7;
603 for (const eventName of STREAM_EVENT_NAMES) {
604 const sequence = previousSeq + 2;
605 const turnBefore = state.turns.get("turn-1");
606 const payload = eventName === "turn.usage"
607 ? { usage: { input_tokens: 100, output_tokens: 20 } }
608 : {};
609 const envelope = runtimeEvent(sequence, eventName, payload, { previous_seq: previousSeq });
610 assert.equal(runtimeEventContinuity(state, envelope), "next", eventName);
611 assert.equal(applyRuntimeEvent(state, envelope), true, eventName);
612 assert.equal(state.latestSeq, sequence, eventName);
613 if (eventName === "turn.usage") {
614 // Request diagnostics advance continuity; only the settled turn owns totals.
615 assert.equal(state.turns.get("turn-1"), turnBefore);
616 assert.equal(applyRuntimeEvent(state, envelope), false);
617 }
618 previousSeq = sequence;
619 }
620 const settledTurn = {
621 id: "turn-1",
622 status: "completed",
623 usage: { input_tokens: 300, output_tokens: 60 },
624 };
625 assert.equal(applyRuntimeEvent(state, runtimeEvent(
626 previousSeq + 1,
627 "turn.completed",
628 { turn: settledTurn },
629 { previous_seq: previousSeq },
630 )), true);
631 assert.deepEqual(state.turns.get("turn-1"), settledTurn);
632 });
633
634 test("gap recovery snapshot restores approval and user-input attention before resubscribing", async () => {
635 const state = createThreadState("thread-a");
636 applySnapshot(state, snapshot("thread-a", 7));
637 const subscriptions = [];
638
639 const recovered = await snapshotThenSubscribe({
640 state,
641 threadId: "thread-a",
642 loadSnapshot: async () => ({
643 ...snapshot("thread-a", 15),
644 pending_approvals: [{
645 id: "approval-recovered",
646 turn_id: "turn-1",
647 tool_name: "exec_command",
648 description: "Run a local check",
649 }],
650 pending_user_inputs: [{
651 id: "input-recovered",
652 turn_id: "turn-1",
653 request: { questions: [{ id: "choice", question: "Continue?", options: [] }] },
654 }],
655 pending_dynamic_tool_calls: [{
656 thread_id: "thread-a",
657 turn_id: "turn-1",
658 call_id: "call-recovered",
659 namespace: "bench",
660 tool: "lookup",
661 arguments: { id: "7" },
662 }],
663 }),
664 subscribe: (threadId, sequence) => subscriptions.push([threadId, sequence]),
665 });
666
667 assert.equal(recovered, true);
668 assert.equal(state.approvals.size, 1);
669 assert.equal(state.approvals.has("approval-recovered"), true);
670 assert.equal(state.userInputs.size, 1);
671 assert.equal(state.userInputs.has("input-recovered"), true);
672 assert.equal(state.dynamicToolCalls.size, 1);
673 assert.equal(state.dynamicToolCalls.get("call-recovered").tool, "lookup");
674 assert.deepEqual(subscriptions, [["thread-a", 15]]);
675
676 const duplicate = runtimeEvent(
677 15,
678 "approval.required",
679 { approval_id: "approval-recovered", tool_name: "exec_command" },
680 { previous_seq: 14 },
681 );
682 assert.equal(applyRuntimeEvent(state, duplicate), false);
683 assert.equal(state.approvals.size, 1);
684 });
685
686 test("browser clears its surfaced gap only after a replacement snapshot subscribes", async () => {
687 const source = await readFile(new URL("../src/runtime_web/app.mjs", import.meta.url), "utf8");
688 assert.match(
689 source,
690 /async function recoverProjection[\s\S]*?connectStream\(id, sequence, generation, true\)/,
691 );
692 assert.match(
693 source,
694 /async function recoverProjection[\s\S]*?recoverSnapshotAndSubscribe\([\s\S]*?app\.streamGap = false;[\s\S]*?if \(!subscribed\) return;\s+renderAll\(\);/,
695 );
696 });
697
698 test("user-input answers stay bound to the selected live thread and pending request", () => {
699 const state = createThreadState("thread-a");
700 state.userInputs.set("input-1", {});
701
702 assert.deepEqual(
703 resolveUserInputTarget("input-1", threadTarget("thread-a"), state),
704 { ok: true, threadId: "thread-a", inputId: "input-1" },
705 );
706 assert.deepEqual(
707 resolveUserInputTarget("input-1", sessionTarget("session-a"), state),
708 { ok: false, reason: "session-not-live" },
709 );
710 assert.deepEqual(
711 resolveUserInputTarget("input-1", NO_TARGET, state),
712 { ok: false, reason: "no-target" },
713 );
714 assert.deepEqual(
715 resolveUserInputTarget("input-2", threadTarget("thread-a"), state),
716 { ok: false, reason: "stale-user-input" },
717 );
718 assert.deepEqual(
719 resolveUserInputTarget("input-1", threadTarget("thread-b"), state),
720 { ok: false, reason: "stale-target" },
721 );
722 });
723
724 test("user-input payloads preserve TUI custom-answer parity and single-select cardinality", () => {
725 const single = {
726 questions: [{
727 id: "path",
728 header: "Path",
729 question: "Which path?",
730 options: [{ label: "A" }, { label: "B" }],
731 allow_free_text: false,
732 multi_select: false,
733 }],
734 };
735 assert.deepEqual(answersForUserInput(single, {}, { path: "A different path" }), {
736 ok: true,
737 answers: [{ id: "path", label: "Other", value: "A different path" }],
738 });
739 assert.equal(
740 answersForUserInput(single, { path: ["A"] }, { path: "also B" }).reason,
741 "multiple-answers",
742 );
743 assert.equal(
744 answersForUserInput(single, { path: ["forged"] }, {}).reason,
745 "invalid-option",
746 );
747 assert.equal(answersForUserInput(single, {}, {}).reason, "missing-answer");
748
749 const multi = {
750 questions: [{
751 ...single.questions[0],
752 id: "checks",
753 multi_select: true,
754 }],
755 };
756 assert.deepEqual(
757 answersForUserInput(multi, { checks: ["A", "B"] }, { checks: "C" }),
758 {
759 ok: true,
760 answers: [
761 { id: "checks", label: "A", value: "A" },
762 { id: "checks", label: "B", value: "B" },
763 { id: "checks", label: "Other", value: "C" },
764 ],
765 },
766 );
767 });
768
769 test("assembles deltas and replaces the live item with its settled receipt", () => {
770 const state = createThreadState("thread-a");
771 applySnapshot(state, { ...snapshot(), items: [], latest_seq: 1 });
772 applyRuntimeEvent(
773 state,
774 runtimeEvent(2, "item.delta", { delta: "one", kind: "agent_message" }, { item_id: "item-new" }),
775 );
776 applyRuntimeEvent(
777 state,
778 runtimeEvent(3, "item.delta", { delta: " two", kind: "agent_message" }, { item_id: "item-new" }),
779 );
780 assert.equal(state.items.get("item-new").detail, "one two");
781
782 applyRuntimeEvent(
783 state,
784 runtimeEvent(
785 4,
786 "item.completed",
787 {
788 item: {
789 id: "item-new",
790 turn_id: "turn-1",
791 kind: "agent_message",
792 status: "completed",
793 summary: "one two",
794 detail: "one two",
795 },
796 },
797 { item_id: "item-new" },
798 ),
799 );
800 assert.equal(state.items.get("item-new").status, "completed");
801 assert.deepEqual(state.itemOrder, ["item-new"]);
802
803 applyRuntimeEvent(
804 state,
805 runtimeEvent(5, "item.delta", { delta: "partial", kind: "tool_call" }, { item_id: "item-stop" }),
806 );
807 applyRuntimeEvent(
808 state,
809 runtimeEvent(
810 6,
811 "item.interrupted",
812 {
813 item: {
814 id: "item-stop",
815 turn_id: "turn-1",
816 kind: "tool_call",
817 status: "interrupted",
818 summary: "Interrupted",
819 detail: "partial",
820 },
821 },
822 { item_id: "item-stop" },
823 ),
824 );
825 assert.equal(state.items.get("item-stop").status, "interrupted");
826
827 applyRuntimeEvent(
828 state,
829 runtimeEvent(
830 7,
831 "item.canceled",
832 {
833 item: {
834 id: "item-compact",
835 turn_id: "turn-1",
836 kind: "compaction",
837 status: "canceled",
838 summary: "Compaction canceled",
839 detail: "Compaction canceled",
840 },
841 },
842 { item_id: "item-compact" },
843 ),
844 );
845 assert.equal(state.items.get("item-compact").status, "canceled");
846 assert.deepEqual(state.itemOrder, ["item-new", "item-stop", "item-compact"]);
847 });
848
849 test("projects agent lifecycle receipts live and settles them without a snapshot reload", () => {
850 const state = createThreadState("thread-a");
851 applySnapshot(state, { ...snapshot(), items: [], latest_seq: 1 });
852
853 const agentItem = (status, summary) => ({
854 id: "item-agent",
855 turn_id: "turn-1",
856 kind: "status",
857 status,
858 summary,
859 detail: summary,
860 });
861 applyRuntimeEvent(
862 state,
863 runtimeEvent(2, "agent.spawned", { item: agentItem("in_progress", "Agent spawned") }),
864 );
865 assert.equal(state.items.get("item-agent").status, "in_progress");
866 assert.deepEqual(state.itemOrder, ["item-agent"]);
867
868 applyRuntimeEvent(
869 state,
870 runtimeEvent(3, "agent.progress", { item: agentItem("in_progress", "Agent checking") }),
871 );
872 applyRuntimeEvent(
873 state,
874 runtimeEvent(4, "agent.completed", { item: agentItem("completed", "Agent completed") }),
875 );
876 assert.equal(state.items.get("item-agent").status, "completed");
877 assert.equal(state.items.get("item-agent").summary, "Agent completed");
878 assert.deepEqual(state.itemOrder, ["item-agent"]);
879
880 applyRuntimeEvent(
881 state,
882 runtimeEvent(5, "agent.list", {
883 item: {
884 id: "item-agent-list",
885 turn_id: "turn-1",
886 kind: "status",
887 status: "completed",
888 summary: "Agent list refreshed",
889 detail: "Agent list refreshed",
890 },
891 }),
892 );
893 assert.equal(state.items.get("item-agent-list").status, "completed");
894 assert.deepEqual(state.itemOrder, ["item-agent", "item-agent-list"]);
895 assert.equal(state.latestSeq, 5);
896 });
897
898 test("tracks approval and user-input attention until each is resolved", () => {
899 const state = createThreadState("thread-a");
900 applySnapshot(state, snapshot());
901 applyRuntimeEvent(
902 state,
903 runtimeEvent(8, "approval.required", { approval_id: "approval-1", tool_name: "exec_shell" }),
904 );
905 applyRuntimeEvent(
906 state,
907 runtimeEvent(9, "user_input.required", {
908 id: "input-1",
909 request: { questions: [{ id: "choice", question: "Choose?", options: [] }] },
910 }),
911 );
912 assert.equal(state.approvals.has("approval-1"), true);
913 assert.equal(state.userInputs.has("input-1"), true);
914
915 applyRuntimeEvent(
916 state,
917 runtimeEvent(10, "approval.decided", { approval_id: "approval-1", decision: "allow" }),
918 );
919 assert.equal(state.approvals.has("approval-1"), false);
920 assert.equal(state.userInputs.has("input-1"), true);
921
922 applyRuntimeEvent(
923 state,
924 runtimeEvent(11, "user_input.answered", { input_id: "input-1" }),
925 );
926 assert.equal(state.userInputs.has("input-1"), false);
927 });
928
929 test("hydrates pending attention from a reload snapshot and clears cancellation events", () => {
930 const state = createThreadState("thread-a");
931 const detail = {
932 ...snapshot(),
933 pending_approvals: [{
934 id: "approval-reload",
935 turn_id: "turn-1",
936 tool_name: "exec_command",
937 description: "Run a local check",
938 }],
939 pending_user_inputs: [{
940 id: "input-reload",
941 turn_id: "turn-1",
942 request: { questions: [{ id: "choice", question: "Continue?", options: [] }] },
943 }],
944 };
945
946 assert.equal(applySnapshot(state, detail), true);
947 assert.equal(state.approvals.get("approval-reload").tool_name, "exec_command");
948 assert.equal(state.userInputs.get("input-reload").turn_id, "turn-1");
949
950 applyRuntimeEvent(
951 state,
952 runtimeEvent(8, "user_input.canceled", { id: "input-reload", terminal: true }),
953 );
954 assert.equal(state.userInputs.has("input-reload"), false);
955 });
956
957 test("turn completion defensively clears attention owned by that turn", () => {
958 const state = createThreadState("thread-a");
959 assert.equal(applySnapshot(state, {
960 ...snapshot(),
961 pending_approvals: [{ id: "approval-terminal", turn_id: "turn-1" }],
962 pending_user_inputs: [{ id: "input-terminal", turn_id: "turn-1", request: { questions: [] } }],
963 pending_dynamic_tool_calls: [{ call_id: "call-terminal", turn_id: "turn-1", tool: "lookup" }],
964 }), true);
965 state.approvals.set("approval-other", { id: "approval-other", turn_id: "turn-other" });
966 state.userInputs.set("input-other", { id: "input-other", turn_id: "turn-other" });
967 state.dynamicToolCalls.set("call-other", { call_id: "call-other", turn_id: "turn-other" });
968
969 assert.equal(applyRuntimeEvent(
970 state,
971 runtimeEvent(8, "turn.completed", { turn: { id: "turn-1", status: "completed" } }),
972 ), true);
973 assert.equal(state.approvals.has("approval-terminal"), false);
974 assert.equal(state.userInputs.has("input-terminal"), false);
975 assert.equal(state.dynamicToolCalls.has("call-terminal"), false);
976 assert.equal(state.approvals.has("approval-other"), true);
977 assert.equal(state.userInputs.has("input-other"), true);
978 assert.equal(state.dynamicToolCalls.has("call-other"), true);
979 });
980
981 test("dynamic tool calls hydrate and disappear exactly once across terminal variants", () => {
982 const state = createThreadState("thread-a");
983 assert.equal(applySnapshot(state, {
984 ...snapshot(),
985 pending_dynamic_tool_calls: [{
986 thread_id: "thread-a",
987 turn_id: "turn-1",
988 call_id: "call-snapshot",
989 tool: "snapshot_lookup",
990 arguments: { id: "snapshot" },
991 }],
992 }), true);
993 assert.equal(state.dynamicToolCalls.get("call-snapshot").tool, "snapshot_lookup");
994
995 assert.equal(applyRuntimeEvent(
996 state,
997 runtimeEvent(8, "tool_call.requested", {
998 thread_id: "thread-a",
999 turn_id: "turn-1",
1000 call_id: "call-live",
1001 tool: "live_lookup",
1002 arguments: { id: "live" },
1003 }),
1004 ), true);
1005 assert.equal(state.dynamicToolCalls.size, 2);
1006
1007 assert.equal(applyRuntimeEvent(
1008 state,
1009 runtimeEvent(9, "tool_call.resolved", { call_id: "call-snapshot", status: "resolved" }),
1010 ), true);
1011 assert.equal(state.dynamicToolCalls.has("call-snapshot"), false);
1012 assert.equal(applyRuntimeEvent(
1013 state,
1014 runtimeEvent(9, "tool_call.resolved", { call_id: "call-snapshot", status: "resolved" }),
1015 ), false);
1016 assert.equal(state.dynamicToolCalls.size, 1);
1017
1018 assert.equal(applyRuntimeEvent(
1019 state,
1020 runtimeEvent(10, "tool_call.canceled", { call_id: "call-live", status: "canceled" }),
1021 ), true);
1022 assert.equal(state.dynamicToolCalls.size, 0);
1023
1024 assert.equal(applyRuntimeEvent(
1025 state,
1026 runtimeEvent(11, "tool_call.requested", {
1027 call_id: "call-timeout",
1028 tool: "slow_lookup",
1029 arguments: {},
1030 }),
1031 ), true);
1032 assert.equal(state.dynamicToolCalls.has("call-timeout"), true);
1033 assert.equal(applyRuntimeEvent(
1034 state,
1035 runtimeEvent(12, "tool_call.timeout", { call_id: "call-timeout", status: "timeout" }),
1036 ), true);
1037 assert.equal(state.dynamicToolCalls.size, 0);
1038 });
1039
1040 test("preserves drafts per thread without browser storage", () => {
1041 const drafts = new Map();
1042 saveDraft(drafts, "thread-a", "draft A");
1043 saveDraft(drafts, "thread-b", "draft B");
1044 assert.equal(restoreDraft(drafts, "thread-a"), "draft A");
1045 assert.equal(restoreDraft(drafts, "thread-b"), "draft B");
1046 saveDraft(drafts, "thread-a", "");
1047 assert.equal(restoreDraft(drafts, "thread-a"), "");
1048 });
1049
1050 test("renders hostile Runtime text only through the textContent sink", async () => {
1051 const hostile = `<img src=x onerror=alert(1)><script>alert(2)</script>`;
1052 const fakeElement = { textContent: "" };
1053 setSafeText(fakeElement, hostile);
1054 assert.equal(fakeElement.textContent, hostile);
1055
1056 const source = await readFile(new URL("../src/runtime_web/app.mjs", import.meta.url), "utf8");
1057 assert.equal(source.includes("inner" + "HTML"), false);
1058 assert.equal(source.includes("insertAdjacent" + "HTML"), false);
1059 assert.equal(source.includes("local" + "Storage"), false);
1060 assert.equal(source.includes("session" + "Storage"), false);
1061 });
1062
1062 lines Plain Text